[MERGE] project: remove project overview and improve analytic account
The project overview is a significant technical debt as it is a custom qweb view. It is quite limited: - It is not possible to group the data, to filter on dates, SOs or Field Service projects. - Improving this is very difficult and would require weeks of development that are not worth it. In any case, all the information provided by the project overview can be found elsewhere. The stat buttons of the report are basically duplicates of the ones from the project form view. Therefore, we are removing this report and its twin, the Project Costs and Revenues. In addition, analytic items lack context for the user to understand what generated a certain cost or revenue: - there is no link to the source document; - the entries are not categorized (e.g. it is not easy to understand if a cost comes from a timesheet cost, a purchase order or an expense); - the billable type group by works fine for timesheets but then all the other entries are flagged as Undefined, which is not very helpful; - there is no option to easily isolate costs from revenues. task-2545084 See odoo/enterprise#19250 See odoo/upgrade#2706 closes odoo/odoo#72736 Signed-off-by: LTU-Odoo <IT-Ideas@users.noreply.github.com>
This commit is contained in:
@@ -38,7 +38,7 @@ class AccountAnalyticAccount(models.Model):
|
||||
('analytic_account_id', 'in', self.ids)
|
||||
]
|
||||
groups = self.env['account.move.line'].read_group(domain, ['move_id:count_distinct'], ['analytic_account_id'])
|
||||
moves_count_mapping = dict((g['analytic_account_id'][0], g['analytic_account_id_count']) for g in groups)
|
||||
moves_count_mapping = dict((g['analytic_account_id'][0], g['move_id']) for g in groups)
|
||||
for account in self:
|
||||
account.invoice_count = moves_count_mapping.get(account.id, 0)
|
||||
|
||||
@@ -51,7 +51,7 @@ class AccountAnalyticAccount(models.Model):
|
||||
('analytic_account_id', 'in', self.ids)
|
||||
]
|
||||
groups = self.env['account.move.line'].read_group(domain, ['move_id:count_distinct'], ['analytic_account_id'])
|
||||
moves_count_mapping = dict((g['analytic_account_id'][0], g['analytic_account_id_count']) for g in groups)
|
||||
moves_count_mapping = dict((g['analytic_account_id'][0], g['move_id']) for g in groups)
|
||||
for account in self:
|
||||
account.vendor_bill_count = moves_count_mapping.get(account.id, 0)
|
||||
|
||||
|
||||
@@ -71,8 +71,6 @@ class ResConfigSettings(models.TransientModel):
|
||||
domain=lambda self: [('reconcile', '=', True), ('user_type_id.id', '=', self.env.ref('account.data_account_type_current_assets').id)],
|
||||
help="Intermediary account used when moving from a liquidity account to another.")
|
||||
module_account_accountant = fields.Boolean(string='Accounting')
|
||||
group_analytic_accounting = fields.Boolean(string='Analytic Accounting',
|
||||
implied_group='analytic.group_analytic_accounting')
|
||||
group_analytic_tags = fields.Boolean(string='Analytic Tags', implied_group='analytic.group_analytic_tags')
|
||||
group_warning_account = fields.Boolean(string="Warnings in Invoices", implied_group='account.group_warning_account')
|
||||
group_cash_rounding = fields.Boolean(string="Cash Rounding", implied_group='account.group_cash_rounding')
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
|
||||
{
|
||||
'name': 'Account Sale Timesheet',
|
||||
'version': '1.0',
|
||||
'category': 'Account/sale/timesheet',
|
||||
'summary': 'Account sale timesheet',
|
||||
'description': 'Bridge created to add the number of invoices linked to an AA to a project form',
|
||||
'depends': ['account', 'sale_timesheet'],
|
||||
'data': [
|
||||
'views/project_project_views.xml',
|
||||
],
|
||||
'demo': [],
|
||||
'installable': True,
|
||||
'auto_install': True,
|
||||
'license': 'OEEL-1',
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import project_overview
|
||||
from . import project
|
||||
@@ -0,0 +1,26 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models, _
|
||||
|
||||
class Project(models.Model):
|
||||
_inherit = 'project.project'
|
||||
|
||||
invoice_count = fields.Integer(related='analytic_account_id.invoice_count')
|
||||
|
||||
# ----------------------------
|
||||
# Project Updates
|
||||
# ----------------------------
|
||||
|
||||
def _get_stat_buttons(self):
|
||||
buttons = super(Project, self)._get_stat_buttons()
|
||||
buttons.append({
|
||||
'icon': 'pencil-square-o',
|
||||
'text': _('Invoices'),
|
||||
'number': self.invoice_count,
|
||||
'action_type': 'object',
|
||||
'action': 'action_open_project_invoices',
|
||||
'show': self.user_has_groups('account.group_account_readonly') and bool(self.analytic_account_id) and self.invoice_count > 0,
|
||||
'sequence': 11,
|
||||
})
|
||||
return buttons
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="project_project_view_form" model="ir.ui.view">
|
||||
<field name="name">project.project.form.inherit</field>
|
||||
<field name="model">project.project</field>
|
||||
<field name="inherit_id" ref="project.edit_project"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//button[@name='%(project.action_project_task_burndown_chart_report)d']" position="after">
|
||||
<button name="action_open_project_invoices" type="object" class="oe_stat_button" icon="fa-pencil-square-o"
|
||||
attrs="{'invisible': ['|', ('invoice_count', '=', 0), ('analytic_account_id', '=', False)]}"
|
||||
groups="account.group_account_readonly">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_value">
|
||||
<field name="invoice_count" nolabel="1"/>
|
||||
</span>
|
||||
<span class="o_stat_text">
|
||||
Invoices
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -2,3 +2,4 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import analytic_account
|
||||
from . import res_config_settings
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# -*- 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'
|
||||
|
||||
group_analytic_accounting = fields.Boolean(string='Analytic Accounting', implied_group='analytic.group_analytic_accounting')
|
||||
@@ -30,7 +30,6 @@ up a management by affair.
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/project_views.xml',
|
||||
'views/project_portal_templates.xml',
|
||||
'views/project_update_templates.xml',
|
||||
'views/hr_timesheet_portal_templates.xml',
|
||||
'report/hr_timesheet_report_view.xml',
|
||||
'report/project_report_view.xml',
|
||||
|
||||
@@ -9,5 +9,4 @@ from . import res_company
|
||||
from . import res_config_settings
|
||||
from . import project
|
||||
from . import project_collaborator
|
||||
from . import project_update
|
||||
from . import uom
|
||||
|
||||
@@ -174,11 +174,48 @@ class Project(models.Model):
|
||||
warning_msg, self.env.ref('hr_timesheet.timesheet_action_project').id,
|
||||
_('See timesheet entries'), {'active_ids': projects_with_timesheets.ids})
|
||||
|
||||
def action_show_timesheets_by_employee_invoice_type(self):
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("hr_timesheet.timesheet_action_all")
|
||||
#Let's put the chart view first
|
||||
new_views = []
|
||||
for view in action['views']:
|
||||
new_views.insert(0, view) if view[1] == 'graph' else new_views.append(view)
|
||||
action.update({
|
||||
'display_name': _("Timesheets"),
|
||||
'domain': [('project_id', '=', self.id)],
|
||||
'context': {
|
||||
'default_project_id': self.id,
|
||||
'search_default_groupby_employee': True,
|
||||
'search_default_groupby_timesheet_invoice_type': True
|
||||
},
|
||||
'views': new_views
|
||||
})
|
||||
|
||||
return action
|
||||
|
||||
def _convert_project_uom_to_timesheet_encode_uom(self, time):
|
||||
uom_from = self.company_id.project_time_mode_id
|
||||
uom_to = self.env.company.timesheet_encode_uom_id
|
||||
return round(uom_from._compute_quantity(time, uom_to, raise_if_failure=False), 2)
|
||||
|
||||
# ----------------------------
|
||||
# Project Updates
|
||||
# ----------------------------
|
||||
|
||||
def _get_stat_buttons(self):
|
||||
buttons = super(Project, self)._get_stat_buttons()
|
||||
buttons.append({
|
||||
'icon': 'clock-o',
|
||||
'text': _('Recorded'),
|
||||
'number': '%s %s' % (self.total_timesheet_time, self.env.company.timesheet_encode_uom_id.name),
|
||||
'action_type': 'object',
|
||||
'action': 'action_show_timesheets_by_employee_invoice_type',
|
||||
'show': self.user_has_groups('hr_timesheet.group_hr_timesheet_user') and self.allow_timesheets,
|
||||
'sequence': 4,
|
||||
})
|
||||
return buttons
|
||||
|
||||
|
||||
class Task(models.Model):
|
||||
_name = "project.task"
|
||||
_inherit = "project.task"
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
# -*- 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.tools.misc import formatLang
|
||||
|
||||
class ProjectUpdate(models.Model):
|
||||
_inherit = 'project.update'
|
||||
|
||||
@api.model
|
||||
def _get_template_values(self, project):
|
||||
return {
|
||||
**super(ProjectUpdate, self)._get_template_values(project),
|
||||
'people': self._get_people_values(project),
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_people_values(self, project):
|
||||
return {
|
||||
'uom': self.env.company._timesheet_uom_text(),
|
||||
'is_uom_hour': self.env.company._is_timesheet_hour_uom(),
|
||||
'activities': self._get_activities(project)
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_activities(self, project):
|
||||
if not self.user_has_groups('hr_timesheet.group_hr_timesheet_user'):
|
||||
return []
|
||||
today = fields.Date.context_today(self)
|
||||
query = """
|
||||
SELECT timesheet.employee_id as employee_id,
|
||||
gs::date as period,
|
||||
sum(timesheet.unit_amount) as unit_amount,
|
||||
employee.name as name
|
||||
FROM project_project p
|
||||
INNER JOIN account_analytic_line timesheet
|
||||
ON timesheet.project_id = p.id
|
||||
INNER JOIN hr_employee employee
|
||||
ON timesheet.employee_id = employee.id
|
||||
CROSS JOIN generate_series(
|
||||
%(today)s - '180 days'::interval,
|
||||
%(today)s,
|
||||
'30 days'::interval
|
||||
) gs
|
||||
WHERE p.id = %(project_id)s
|
||||
AND gs >= timesheet.date
|
||||
AND gs - '30 days'::interval < timesheet.date
|
||||
GROUP BY timesheet.employee_id,
|
||||
gs, employee.name
|
||||
ORDER BY gs DESC, employee.name ASC
|
||||
"""
|
||||
self.env.cr.execute(query, {'project_id': project.id, 'today': today})
|
||||
results = self.env.cr.dictfetchall()
|
||||
activities = defaultdict(lambda: {
|
||||
'unit_amount': 0,
|
||||
'worked': False,
|
||||
})
|
||||
digits = not self.env.company._is_timesheet_hour_uom() and 2 or 0
|
||||
for result in results:
|
||||
if result['period'] == today:
|
||||
activities[result['employee_id']] = {
|
||||
'name': result['name'],
|
||||
'unit_amount': formatLang(self.env, project._convert_project_uom_to_timesheet_encode_uom(result['unit_amount']), digits=digits),
|
||||
'worked': True,
|
||||
'new': True,
|
||||
}
|
||||
else:
|
||||
name = activities[result['employee_id']].get('name', result['name'])
|
||||
activities[result['employee_id']].update(name=name, new=False)
|
||||
return list(activities.values())
|
||||
@@ -2,4 +2,3 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import test_timesheet
|
||||
from . import test_project_update
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import Form
|
||||
|
||||
from odoo.addons.project.tests.test_project_update_flow import TestProjectUpdate
|
||||
|
||||
@tagged('-at_install', 'post_install')
|
||||
class TestProjectUpdateHrTimesheet(TestProjectUpdate):
|
||||
|
||||
def _create_timesheet(self):
|
||||
self.employee = self.env['hr.employee'].create({
|
||||
'name': "Randy Heart",
|
||||
'company_id': self.env.company.id
|
||||
})
|
||||
self.timesheet = self.env['account.analytic.line'].create({
|
||||
'project_id': self.project_pigs.id,
|
||||
'task_id': self.task_1.id,
|
||||
'name': 'my first timesheet',
|
||||
'unit_amount': 4,
|
||||
'employee_id': self.employee.id,
|
||||
})
|
||||
|
||||
def test_project_update_form_people(self):
|
||||
self._create_timesheet()
|
||||
try:
|
||||
with Form(self.env['project.update'].with_context({'default_project_id': self.project_pigs.id})) as update_form:
|
||||
update_form.name = "Test"
|
||||
update_form.progress = 65
|
||||
update = update_form.save()
|
||||
except Exception as e:
|
||||
raise AssertionError("Error raised unexpectedly while filling the project update form ! Exception : " + e.args[0])
|
||||
|
||||
self.assertTrue("People" in update.description, "The description should contain 'People'.")
|
||||
|
||||
def test_project_update_form_no_people(self):
|
||||
try:
|
||||
with Form(self.env['project.update'].with_context({'default_project_id': self.project_pigs.id})) as update_form:
|
||||
update_form.name = "Test"
|
||||
update_form.progress = 65
|
||||
update = update_form.save()
|
||||
except Exception as e:
|
||||
raise AssertionError("Error raised unexpectedly while filling the project update form ! Exception : " + e.args[0])
|
||||
|
||||
self.assertFalse("People" in update.description, "The description should not contain 'People'.")
|
||||
|
||||
def test_project_update_description_people(self):
|
||||
self._create_timesheet()
|
||||
template_values = self.env['project.update']._get_template_values(self.project_pigs)
|
||||
|
||||
self.assertEqual(template_values['people']['uom'], "hours", "The timesheet uom is well given to template")
|
||||
self.assertTrue(template_values['people']['is_uom_hour'], "Company default timesheet uom is hours")
|
||||
self.assertEqual(len(template_values['people']['activities']), 1, "The number of recorded timesheet activities is 1")
|
||||
self.assertEqual(template_values['people']['activities'][0]['name'], "Randy Heart", "The number of recorded timesheet activities is 1")
|
||||
@@ -1,30 +0,0 @@
|
||||
<?xml version="1.0"?>
|
||||
<odoo>
|
||||
<template id="project_update_default_description" inherit_id="project.project_update_default_description">
|
||||
<!--As this template is rendered in an html field, the spaces may be interpreted as nbsp while editing. -->
|
||||
<xpath expr="//div[@name='milestone']" position="after">
|
||||
<t t-if="project.allow_timesheets and people['activities'] and user.has_group('hr_timesheet.group_hr_timesheet_approver')">
|
||||
<hr/>
|
||||
<div name="people">
|
||||
<h1>People</h1>
|
||||
|
||||
Activities of the past 30 days:
|
||||
<ul>
|
||||
<t t-foreach="people['activities']" t-as="activity">
|
||||
<li>
|
||||
<t t-if="activity['worked']">
|
||||
<t t-esc="activity['name']"/>: <t t-esc="activity['unit_amount']" /> <t t-esc="people['uom']"/>
|
||||
<span t-if="activity['new']" class="text-success"><font style="color: rgb(0, 128, 0);"><b>(new this month)</b></font></span>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<s><t t-esc="activity['name']"/></s> <span class="text-muted"><font style="color: rgb(128, 128, 128);"><b>(no activity in past 30 days)</b></font></span>
|
||||
</t>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</div>
|
||||
</t>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -37,7 +37,7 @@
|
||||
<field name="priority">24</field>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//button[@name='%(project.project_milestone_all)d']" position="after">
|
||||
<button class="oe_stat_button" name="%(act_hr_timesheet_line_by_project)d" type="action" icon="fa-clock-o" attrs="{'invisible': [('allow_timesheets', '=', False)]}" groups="hr_timesheet.group_hr_timesheet_user">
|
||||
<button class="oe_stat_button" name="action_show_timesheets_by_employee_invoice_type" type="object" icon="fa-clock-o" attrs="{'invisible': [('allow_timesheets', '=', False)]}" groups="hr_timesheet.group_hr_timesheet_user">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<div class="oe_inline">
|
||||
<span class="o_stat_value mr-1">
|
||||
|
||||
@@ -9,6 +9,7 @@ from random import randint
|
||||
|
||||
from odoo import api, Command, fields, models, tools, SUPERUSER_ID, _
|
||||
from odoo.exceptions import UserError, ValidationError, AccessError
|
||||
from odoo.tools import format_amount
|
||||
from odoo.osv.expression import OR
|
||||
|
||||
from .project_task_recurrence import DAYS, WEEKS
|
||||
@@ -137,7 +138,6 @@ class ProjectTaskType(models.Model):
|
||||
else:
|
||||
stage.disabled_rating_warning = False
|
||||
|
||||
|
||||
class Project(models.Model):
|
||||
_name = "project.project"
|
||||
_description = "Project"
|
||||
@@ -659,6 +659,7 @@ class Project(models.Model):
|
||||
'user': self._get_user_values(),
|
||||
'tasks_analysis': self._get_tasks_analysis(),
|
||||
'milestones': self._get_milestones(),
|
||||
'buttons': sorted(self._get_stat_buttons(), key=lambda k: k['sequence'])
|
||||
}
|
||||
|
||||
def _get_user_values(self):
|
||||
@@ -705,6 +706,63 @@ class Project(models.Model):
|
||||
'data': self.milestone_ids._get_data_list(),
|
||||
}
|
||||
|
||||
def _get_stat_buttons(self):
|
||||
self.ensure_one()
|
||||
buttons = [{
|
||||
'icon': 'tasks',
|
||||
'text': _('Tasks'),
|
||||
'number': self.task_count,
|
||||
'action_type': 'action',
|
||||
'action': 'project.act_project_project_2_project_task_all',
|
||||
'additional_context': json.dumps({
|
||||
'active_id': self.id,
|
||||
}),
|
||||
'show': True,
|
||||
'sequence': 2,
|
||||
},
|
||||
{
|
||||
'icon': 'smile-o',
|
||||
'text': _('Customer Satisfaction'),
|
||||
'number': '%s %%' % (self.rating_percentage_satisfaction),
|
||||
'action_type': 'object',
|
||||
'action': 'action_view_all_rating',
|
||||
'show': self.user_has_groups('project.group_project_rating') and self.rating_active and self.rating_percentage_satisfaction > -1,
|
||||
'sequence': 5,
|
||||
},
|
||||
{
|
||||
'icon': 'area-chart',
|
||||
'text': _('Burndown Chart'),
|
||||
'action_type': 'action',
|
||||
'action': 'project.action_project_task_burndown_chart_report',
|
||||
'additional_context': json.dumps({
|
||||
'active_id': self.id,
|
||||
}),
|
||||
'show': self.user_has_groups('project.group_project_manager'),
|
||||
'sequence': 7,
|
||||
},
|
||||
{
|
||||
'icon': 'usd',
|
||||
'text': _('Gross Margin'),
|
||||
'number': format_amount(self.env, self.analytic_account_balance, self.company_id.currency_id),
|
||||
'action_type': 'object',
|
||||
'action': 'action_view_analytic_account_entries',
|
||||
'show': self.user_has_groups('analytic.group_analytic_accounting'),
|
||||
'sequence': 18,
|
||||
},
|
||||
{
|
||||
'icon': 'users',
|
||||
'text': _('Collaborators'),
|
||||
'number': self.collaborator_count,
|
||||
'action_type': 'action',
|
||||
'action': 'project.project_collaborator_action',
|
||||
'additional_context': json.dumps({
|
||||
'active_id': self.id,
|
||||
}),
|
||||
'show': self.user_has_groups('project.group_project_manager'),
|
||||
'sequence': 19,
|
||||
}]
|
||||
return buttons
|
||||
|
||||
def _get_tasks_analysis_counts(self, created=False, updated=False):
|
||||
tasks = self.env['project.task'].search([('display_project_id', '=', self.id)])
|
||||
open_tasks_count = created_tasks_count = updated_tasks_count = 0
|
||||
|
||||
@@ -6,12 +6,14 @@ from dateutil.relativedelta import relativedelta
|
||||
from werkzeug.urls import url_encode
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.osv import expression
|
||||
|
||||
STATUS_COLOR = {
|
||||
'on_track': 20, # green / success
|
||||
'at_risk': 2, # orange
|
||||
'off_track': 23, # red / danger
|
||||
'on_hold': 4, # light blue
|
||||
False: 0, # default grey -- for studio
|
||||
}
|
||||
|
||||
class ProjectUpdate(models.Model):
|
||||
@@ -87,55 +89,9 @@ class ProjectUpdate(models.Model):
|
||||
return {
|
||||
'user': self.env.user,
|
||||
'project': project,
|
||||
'tasks': self._get_tasks_values(project),
|
||||
'milestones': self._get_milestone_values(project)
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_tasks_values(self, project):
|
||||
counts = project._get_tasks_analysis_counts(created=True)
|
||||
return {
|
||||
'open_tasks': counts['open_tasks_count'],
|
||||
'total_tasks': counts['tasks_count'],
|
||||
'created_tasks': counts['created_tasks_count'],
|
||||
'closed_tasks': self._get_last_stage_changes(project=project),
|
||||
'action': {
|
||||
'url_default': '/web#' + url_encode({
|
||||
'menu_id': self.env.ref('project.menu_projects').id,
|
||||
'action': self.env.ref('project.action_project_task_burndown_chart_report').id,
|
||||
'active_id': project.id
|
||||
}),
|
||||
'name': 'action_burndown_open_tasks',
|
||||
}
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_last_stage_changes(self, project):
|
||||
query = """
|
||||
SELECT DISTINCT pt.id as task_id
|
||||
FROM mail_message mm
|
||||
INNER JOIN mail_tracking_value mtv
|
||||
ON mm.id = mtv.mail_message_id
|
||||
INNER JOIN ir_model_fields imf
|
||||
ON mtv.field = imf.id
|
||||
AND imf.model = 'project.task'
|
||||
AND imf.name = 'stage_id'
|
||||
INNER JOIN project_task_type new_stage
|
||||
ON mtv.new_value_integer = new_stage.id
|
||||
INNER JOIN project_task pt
|
||||
ON mm.res_id = pt.id
|
||||
AND pt.stage_id = new_stage.id
|
||||
WHERE mm.model = 'project.task'
|
||||
AND mm.message_type = 'notification'
|
||||
AND pt.display_project_id = %(project_id)s
|
||||
AND (new_stage.fold OR new_stage.is_closed)
|
||||
AND mm.date > (now() at time zone 'utc')::date - '1 month'::interval
|
||||
AND pt.active
|
||||
"""
|
||||
self.env.cr.execute(query, {'project_id': project.id})
|
||||
task_ids = [res['task_id'] for res in self.env.cr.dictfetchall()]
|
||||
return self.env['project.task'].search_count([('id', 'in', task_ids)])
|
||||
|
||||
@api.model
|
||||
def _get_milestone_values(self, project):
|
||||
Milestone = self.env['project.milestone']
|
||||
@@ -143,13 +99,15 @@ class ProjectUpdate(models.Model):
|
||||
[('project_id', '=', project.id),
|
||||
'|', ('deadline', '<', fields.Date.context_today(self) + relativedelta(years=1)), ('deadline', '=', False)])._get_data_list()
|
||||
updated_milestones = self._get_last_updated_milestone(project)
|
||||
created_milestones = Milestone.search(
|
||||
[('project_id', '=', project.id),
|
||||
('create_date', '>', fields.Datetime.now() + timedelta(days=-30))])._get_data_list()
|
||||
domain = [('project_id', '=', project.id)]
|
||||
if project.last_update_id.date:
|
||||
domain = expression.AND([domain, [('create_date', '>=', project.last_update_id.date)]])
|
||||
created_milestones = Milestone.search(domain)._get_data_list()
|
||||
return {
|
||||
'show_section': (list_milestones or updated_milestones or created_milestones) and True or False,
|
||||
'list': list_milestones,
|
||||
'updated': updated_milestones,
|
||||
'last_update_date': project.last_update_id.date or None,
|
||||
'created': created_milestones,
|
||||
}
|
||||
|
||||
@@ -172,14 +130,21 @@ class ProjectUpdate(models.Model):
|
||||
WHERE mm.model = 'project.milestone'
|
||||
AND mm.message_type = 'notification'
|
||||
AND pm.project_id = %(project_id)s
|
||||
AND mm.date > (now() at time zone 'utc')::date - '1 month'::interval
|
||||
"""
|
||||
if project.last_update_id.date:
|
||||
query = query + "AND mm.date <= %(last_update_date)s"
|
||||
query = query + """
|
||||
WINDOW w_partition AS (
|
||||
PARTITION BY pm.id
|
||||
ORDER BY mm.date ASC
|
||||
)
|
||||
ORDER BY pm.deadline ASC;
|
||||
ORDER BY pm.deadline ASC
|
||||
LIMIT 1;
|
||||
"""
|
||||
self.env.cr.execute(query, {'project_id': project.id})
|
||||
query_params = {'project_id': project.id}
|
||||
if project.last_update_id.date:
|
||||
query_params['last_update_date'] = project.last_update_id.date
|
||||
self.env.cr.execute(query, query_params)
|
||||
results = self.env.cr.dictfetchall()
|
||||
mapped_result = {res['milestone_id']: {'new_value': res['new_value'], 'old_value': res['old_value']} for res in results}
|
||||
milestones = self.env['project.milestone'].search([('id', 'in', list(mapped_result.keys()))])
|
||||
@@ -188,14 +153,3 @@ class ProjectUpdate(models.Model):
|
||||
'new_value': mapped_result[milestone.id]['new_value'],
|
||||
'old_value': mapped_result[milestone.id]['old_value'],
|
||||
} for milestone in milestones]
|
||||
|
||||
def action_burndown_open_tasks(self):
|
||||
if not self.id or not self.project_id:
|
||||
return False
|
||||
action = self.env['ir.actions.act_window']._for_xml_id('project.action_project_task_burndown_chart_report')
|
||||
context = {
|
||||
'active_id': self.project_id.id,
|
||||
'search_default_project_id': self.project_id.id,
|
||||
'graph_mode': 'bar',
|
||||
}
|
||||
return dict(action, context=context)
|
||||
|
||||
@@ -11,9 +11,6 @@ export default class ProjectRightPanel extends owl.Component {
|
||||
this.project_id = this.context.active_id;
|
||||
this.state = useState({
|
||||
data: {
|
||||
tasks_analysis: {
|
||||
data: []
|
||||
},
|
||||
milestones: {
|
||||
data: []
|
||||
},
|
||||
@@ -48,9 +45,18 @@ export default class ProjectRightPanel extends owl.Component {
|
||||
const additionalContext = JSON.parse(event.currentTarget.dataset.additional_context || "{}");
|
||||
if (event.currentTarget.dataset.type === "object") {
|
||||
action = await this.rpc({
|
||||
model: 'project.project',
|
||||
method: event.currentTarget.dataset.action,
|
||||
args: [this.project_id]
|
||||
// Use the call_button method in order to have an action
|
||||
// with the correct view naming, i.e. list view is named
|
||||
// 'list' rather than 'tree'.
|
||||
route: '/web/dataset/call_button',
|
||||
params: {
|
||||
model: 'project.project',
|
||||
method: event.currentTarget.dataset.action,
|
||||
args: [this.project_id],
|
||||
kwargs: {
|
||||
context: this.context
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
this._doAction(action, {
|
||||
|
||||
@@ -18,6 +18,7 @@ class MilestoneComponent extends owl.Component {
|
||||
});
|
||||
this._dialogRef = useRef('dialog');
|
||||
this._isDialogOpen = false;
|
||||
this._createContext = this._createContext.bind(this);
|
||||
this._onDialogSaved = this._onDialogSaved.bind(this);
|
||||
}
|
||||
|
||||
@@ -46,6 +47,12 @@ class MilestoneComponent extends owl.Component {
|
||||
}
|
||||
}
|
||||
|
||||
_createContext() {
|
||||
return Object.assign({}, {
|
||||
'default_project_id': this.contextValue.active_id,
|
||||
}, this.contextValue);
|
||||
}
|
||||
|
||||
async _onDialogSaved() {
|
||||
await this.__owl__.parent.willUpdateProps();
|
||||
}
|
||||
@@ -124,7 +131,7 @@ export class OpenMilestone extends MilestoneComponent {
|
||||
args: [[this.milestone.id], !this.milestone.is_reached],
|
||||
});
|
||||
this.state.colorClass = this.milestone.is_deadline_exceeded ? "o_milestone_danger" : "";
|
||||
this.state.checkboxIcon = this.milestone.is_reached ? "fa-check-square" : "fa-square-o";
|
||||
this.state.checkboxIcon = this.milestone.is_reached ? "fa-check-square-o" : "fa-square-o";
|
||||
this.write_mutex = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ html .o_web_client > .o_action_manager > .o_action > .o_content > .o_controller_
|
||||
}
|
||||
|
||||
.o_rightpanel {
|
||||
flex: 0 0 30%;
|
||||
flex: 0 0 25%;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
padding: $o-rightpanel-p-small $o-rightpanel-p $o-rightpanel-p*2 $o-rightpanel-p;
|
||||
@@ -35,10 +35,18 @@ html .o_web_client > .o_action_manager > .o_action > .o_content > .o_controller_
|
||||
padding-bottom: $o-rightpanel-p;
|
||||
padding-top: $o-rightpanel-p;
|
||||
|
||||
&:not(:first-child){
|
||||
&:nth-child(n+3){
|
||||
border-top: 1px solid $gray-300;
|
||||
}
|
||||
|
||||
&:first-child{
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
|
||||
&:nth-child(2){
|
||||
padding-top: 0px;
|
||||
}
|
||||
|
||||
.o_rightpanel_header {
|
||||
padding: $o-rightpanel-p-tiny 0 $o-rightpanel-p-small 0;
|
||||
|
||||
@@ -84,6 +92,11 @@ html .o_web_client > .o_action_manager > .o_action > .o_content > .o_controller_
|
||||
float: right;
|
||||
}
|
||||
|
||||
.o_rightpanel_center_col {
|
||||
flex-grow: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.o_open_milestone {
|
||||
margin-bottom: $o-rightpanel-p-tiny;
|
||||
display: flex;
|
||||
@@ -117,6 +130,47 @@ html .o_web_client > .o_action_manager > .o_action > .o_content > .o_controller_
|
||||
.o_color_green {
|
||||
color: $green;
|
||||
}
|
||||
|
||||
.o_form_view {
|
||||
.oe_button_box {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-left: -16px;
|
||||
margin-right: -16px;
|
||||
|
||||
@media screen and (max-device-width: 768px) {
|
||||
margin-top: -4px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.oe_stat_button {
|
||||
flex-basis: 33.333333%;
|
||||
border-width: 1px;
|
||||
margin-top: -1px;
|
||||
margin-left: -1px;
|
||||
height: 56px;
|
||||
|
||||
&:nth-child(3n){
|
||||
border-right-width: 0px;
|
||||
}
|
||||
|
||||
.o_stat_text {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1600px) {
|
||||
.oe_stat_button {
|
||||
flex-basis: 50%;
|
||||
|
||||
&:nth-child(2n){
|
||||
border-right-width: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -43,30 +43,32 @@
|
||||
|
||||
<t t-name="project.ProjectRightPanel" owl="1">
|
||||
<div class="o_rightpanel">
|
||||
<t t-call="project.TaskSection"/>
|
||||
<t t-call="project.StatButtonsSection"/>
|
||||
<t t-call="project.MilestoneSection"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="project.TaskSection" owl="1">
|
||||
<t t-set="tasksAnalysis" t-value="state.data.tasks_analysis"/>
|
||||
<div class="o_rightpanel_section" name="task_analysis">
|
||||
<div class="o_rightpanel_header">
|
||||
<div class="o_rightpanel_title">
|
||||
Tasks Analysis
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_rightpanel_data">
|
||||
<div t-foreach="tasksAnalysis.data" t-as="analysis" t-key="analysis.name" class="o_rightpanel_data_row">
|
||||
<a t-if="state.data.user.is_project_user" class="btn btn-link o_rightpanel_left_col o_rightpanel_left_button" t-on-click="onProjectActionClick"
|
||||
t-att-data-action="analysis.action.action"
|
||||
t-att-data-additional_context="analysis.action.additional_context"
|
||||
data-type="action">
|
||||
<t t-esc="analysis.name"/>
|
||||
</a>
|
||||
<span t-else="" class="o_rightpanel_left_col o_rightpanel_left_text"><t t-esc="analysis.name"/></span>
|
||||
<span class="o_rightpanel_right_col"><t t-esc="analysis.value"/></span>
|
||||
<t t-name="project.StatButtonsSection" owl="1">
|
||||
<div class="o_rightpanel_section">
|
||||
<div class="o_form_view">
|
||||
<div class="oe_button_box o_full">
|
||||
<t t-foreach="state.data.buttons" t-as="button" t-key="button.order">
|
||||
<button class="btn oe_stat_button" t-if="button.show"
|
||||
t-on-click="onProjectActionClick"
|
||||
t-att-data-action="button.action"
|
||||
t-att-data-additional_context="button.additional_context"
|
||||
t-att-data-type="button.action_type">
|
||||
<i t-att-class="'fa fa-fw o_button_icon fa-' + button.icon"/>
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_value">
|
||||
<t t-esc="button.number"/>
|
||||
</span>
|
||||
<span class="o_stat_text">
|
||||
<t t-esc="button.text"/>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,11 +104,12 @@
|
||||
Component="FormViewDialog"
|
||||
params="{
|
||||
context: context,
|
||||
_createContext: _createContext,
|
||||
on_saved: _onDialogSaved,
|
||||
res_id: false,
|
||||
res_model: 'project.milestone',
|
||||
title: NEW_PROJECT_MILESTONE,
|
||||
disable_multiple_selection: true,
|
||||
multi_select: true,
|
||||
}"
|
||||
t-ref="dialog"
|
||||
/>
|
||||
@@ -120,8 +123,10 @@
|
||||
<i class="fa" t-att-class="state.checkboxIcon"></i>
|
||||
</span>
|
||||
<div class="o_milestone_detail" t-on-click="onOpenMilestone">
|
||||
<t t-esc="milestone.name"/>
|
||||
<span class="o_rightpanel_right_col">
|
||||
<span style="flex: 10%">
|
||||
<t t-esc="milestone.name"/>
|
||||
</span>
|
||||
<span class="o_rightpanel_center_col">
|
||||
<t t-esc="deadline"/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -73,9 +73,93 @@ tour.register('project_update_tour', {
|
||||
extra_trigger: '.o_kanban_project_tasks',
|
||||
run: "drag_and_drop .o_kanban_group:eq(1) ",
|
||||
}, {
|
||||
trigger: '.o_back_button',
|
||||
content: 'Go back to edit the project created',
|
||||
}, {
|
||||
// an invisible element cannot be used as a trigger so this small hack is mandatory for the next step
|
||||
trigger: '.o_kanban_record:contains("New Project")',
|
||||
run: function () {
|
||||
$('.o_kanban_record:contains("New Project") .o_dropdown_kanban').css('visibility', 'visible');
|
||||
}
|
||||
}, {
|
||||
trigger: '.oe_kanban_global_click :contains("New Project") .o_dropdown_kanban',
|
||||
content: 'Open the project dropdown'
|
||||
}, {
|
||||
trigger: '.o_kanban_record:contains("New Project") .dropdown-menu a:contains("See More")',
|
||||
content: 'Start editing the project',
|
||||
}, {
|
||||
trigger: '.nav-item:contains("Invoicing") .nav-link',
|
||||
content: 'Start the assignement of a default sale order id to the project'
|
||||
}, {
|
||||
trigger: '.o_form_button_edit',
|
||||
content: 'Start editing the project'
|
||||
}, {
|
||||
trigger: 'div[name="sale_line_id"]',
|
||||
content: 'Select the first sale order of the list',
|
||||
run: function (actions) {
|
||||
actions.text('Prepaid', this.$anchor.find('input'));
|
||||
},
|
||||
}, {
|
||||
trigger: 'ul.ui-autocomplete > li:first-child > a',
|
||||
content: 'Select the first item on the autocomplete dropdown'
|
||||
}, {
|
||||
trigger: '.o_form_button_save',
|
||||
content: 'Save the modifications'
|
||||
}, {
|
||||
trigger: '.o_back_button',
|
||||
content: 'Go back to the kanban view the project created',
|
||||
}, {
|
||||
trigger: '.oe_kanban_global_click :contains("New Project")',
|
||||
content: 'Open the project'
|
||||
}, {
|
||||
trigger: ".o_project_updates_breadcrumb",
|
||||
content: 'Open Updates'
|
||||
}, {
|
||||
trigger: ".oe_button_box .o_stat_text:contains('Sales Order')",
|
||||
run: function () {}
|
||||
}, {
|
||||
trigger: ".o_rightpanel_title:eq(0):contains('Sold')",
|
||||
content: 'Check the user sees Sold section',
|
||||
run: function () {}
|
||||
}, {
|
||||
trigger: ".o_rightpanel_left_text:eq(0):contains('Prepaid Hours')",
|
||||
content: 'Check the user sees Sold subsection',
|
||||
run: function () {},
|
||||
}, {
|
||||
trigger: ".o_rightpanel_title:eq(1):contains('Total Sold')",
|
||||
content: 'Check the user sees Total Sold section',
|
||||
run: function () {}
|
||||
}, {
|
||||
trigger: ".o_rightpanel_header:eq(1) .o_rightpanel_right_col:contains('Hours')",
|
||||
content: 'Check the user sees Total Sold hours title',
|
||||
run: function () {},
|
||||
}, {
|
||||
trigger: ".o_rightpanel_section:eq(2) .o_rightpanel_data_row:contains('Effective')",
|
||||
content: 'Check the user sees Total Sold subsection row',
|
||||
run: function () {},
|
||||
}, {
|
||||
trigger: ".o_rightpanel_section:eq(2) .o_rightpanel_data_row:contains('Remaining')",
|
||||
content: 'Check the user sees Total Sold subsection row',
|
||||
run: function () {},
|
||||
}, {
|
||||
trigger: ".o_rightpanel_title:eq(2):contains('Profitability')",
|
||||
content: 'Check the user sees Profitability section',
|
||||
run: function () {}
|
||||
}, {
|
||||
trigger: ".o_rightpanel_section:eq(3) .o_rightpanel_data_row:contains('Revenues')",
|
||||
content: 'Check the user sees Profitability subsection row',
|
||||
run: function () {},
|
||||
}, {
|
||||
trigger: ".o_rightpanel_section:eq(3) .o_rightpanel_data_row:contains('Costs')",
|
||||
content: 'Check the user sees Profitability subsection row',
|
||||
run: function () {},
|
||||
}, {
|
||||
trigger: ".o_rightpanel_section:eq(3) .o_rightpanel_data_row:contains('Margin')",
|
||||
content: 'Check the user sees Profitability subsection row',
|
||||
run: function () {},
|
||||
}, {
|
||||
trigger: ".o_add_milestone a",
|
||||
content: "Add a first milestone"
|
||||
}, {
|
||||
trigger: "input.o_field_widget[name=name]",
|
||||
run: 'text New milestone'
|
||||
@@ -91,22 +175,13 @@ tour.register('project_update_tour', {
|
||||
run: 'text Second milestone'
|
||||
}, {
|
||||
trigger: "input.datetimepicker-input[name=deadline]",
|
||||
run: 'text 12/12/2021'
|
||||
run: 'text 12/12/2022'
|
||||
}, {
|
||||
trigger: ".modal-footer button"
|
||||
}, {
|
||||
trigger: ".o_rightpanel_left_button:eq(0)"
|
||||
}, {
|
||||
trigger: ".breadcrumb :contains('Tasks Analysis')",
|
||||
}, {
|
||||
trigger: ".o_back_button"
|
||||
}, {
|
||||
trigger: ".o_rightpanel_left_button:eq(1)"
|
||||
}, {
|
||||
trigger: ".breadcrumb :contains('Burndown Chart')",
|
||||
}, {
|
||||
trigger: ".o_back_button"
|
||||
}, {
|
||||
trigger: '.o_back_button',
|
||||
content: 'Go back to edit to the list view',
|
||||
|
||||
trigger: ".o_open_milestone:eq(1) .o_milestone_detail span:eq(0)",
|
||||
extra_trigger: ".o_add_milestone a",
|
||||
run: function () {
|
||||
@@ -120,25 +195,35 @@ tour.register('project_update_tour', {
|
||||
}, {
|
||||
trigger: ".modal-footer button"
|
||||
}, {
|
||||
trigger: ".o-kanban-button-new"
|
||||
trigger: ".o-kanban-button-new",
|
||||
content: "Create a new update"
|
||||
}, {
|
||||
trigger: "input.o_field_widget[name=name]",
|
||||
run: 'text New update'
|
||||
}, {
|
||||
trigger: ".o_form_button_save"
|
||||
}, {
|
||||
trigger: ".o_field_widget[name=description] :contains('1 tasks are open, out of 2.')",
|
||||
trigger: ".o_field_widget[name=description] h1:contains('Activities')",
|
||||
run: function () {},
|
||||
}, {
|
||||
trigger: ".o_field_widget[name=description] :contains('2 were created')",
|
||||
trigger: ".o_field_widget[name=description] h3:contains('Sold')",
|
||||
run: function () {},
|
||||
}, {
|
||||
trigger: ".o_field_widget[name=description] td:contains('Customer Care')",
|
||||
run: function () {},
|
||||
}, {
|
||||
trigger: ".o_field_widget[name=description] :contains('1 was closed')",
|
||||
trigger: ".o_field_widget[name=description] h3:contains('Profitability')",
|
||||
run: function () {},
|
||||
}, {
|
||||
trigger: ".o_field_widget[name=description] :contains('New milestone') :contains('(due on 12/12/2100)')",
|
||||
trigger: ".o_field_widget[name=description] h3:contains('Milestones')",
|
||||
run: function () {},
|
||||
}, {
|
||||
trigger: ".o_field_widget[name=description] :contains('New milestone') :contains('(12/12/2099 => 12/12/2100)')",
|
||||
trigger: ".o_field_widget[name=description] div[name='milestone'] ul li:contains('(12/12/2099 => 12/12/2100)')",
|
||||
run: function () {},
|
||||
}, {
|
||||
trigger: ".o_field_widget[name=description] div[name='milestone'] ul li span:contains('(due on 12/12/2022)')",
|
||||
run: function () {},
|
||||
}, {
|
||||
trigger: ".o_field_widget[name=description] div[name='milestone'] ul li span:contains('(due on 12/12/2100)')",
|
||||
run: function () {},
|
||||
}]);
|
||||
|
||||
@@ -26,8 +26,7 @@ class TestProjectUpdate(TestProjectCommon):
|
||||
|
||||
self.assertEqual(update.user_id, self.env.user, "The author is the user who created the update.")
|
||||
self.assertNotEqual(len(update.description), 0, "The description should not be empty.")
|
||||
self.assertTrue("Tasks" in update.description, "The description should contain 'Tasks'.")
|
||||
self.assertTrue("Milestone" in update.description, "The description should contain 'Milestone'.")
|
||||
self.assertTrue("Activities" in update.description, "The description should contain 'Activities'.")
|
||||
self.assertEqual(update.status, 'on_track', "The status should be the default one.")
|
||||
|
||||
with Form(self.env['project.update'].with_context({'default_project_id': self.project_pigs.id})) as update_form:
|
||||
@@ -48,11 +47,6 @@ class TestProjectUpdate(TestProjectCommon):
|
||||
|
||||
template_values = self.env['project.update']._get_template_values(self.project_pigs)
|
||||
|
||||
self.assertEqual(template_values['tasks']['open_tasks'], 2, "Open tasks should be equal to 2")
|
||||
self.assertEqual(template_values['tasks']['total_tasks'], 2, "Total tasks should be equal to 2")
|
||||
self.assertEqual(template_values['tasks']['created_tasks'], 2, "Created tasks should be equal to 2")
|
||||
self.assertEqual(template_values['tasks']['closed_tasks'], 0, "Closed tasks should be equal to 0")
|
||||
|
||||
self.assertEqual(len(template_values['milestones']['list']), 2, "Milestone list length should be equal to 2")
|
||||
self.assertEqual(len(template_values['milestones']['created']), 3, "Milestone created length tasks should be equal to 3")
|
||||
|
||||
@@ -70,7 +64,6 @@ class TestProjectUpdate(TestProjectCommon):
|
||||
|
||||
panel_data = self.project_pigs.get_panel_data()
|
||||
|
||||
self.assertEqual(len(panel_data['tasks_analysis']['data']), 2, "Panel data should contain 'tasks' entry")
|
||||
self.assertEqual(len(panel_data['milestones']['data']), 3, "Panel data should contain 'milestone' entry")
|
||||
self.assertFalse(panel_data['milestones']['data'][0]['is_deadline_exceeded'], "Milestone is achieved")
|
||||
self.assertTrue(panel_data['milestones']['data'][1]['is_deadline_exceeded'], "Milestone is exceeded")
|
||||
|
||||
@@ -10,23 +10,19 @@
|
||||
|
||||
<template id="project_update_default_description" name="Project Update Description">
|
||||
<!--As this template is rendered in an html field, the spaces may be interpreted as nbsp while editing. -->
|
||||
<div name="tasks">
|
||||
<h1>Tasks</h1>
|
||||
|
||||
<a t-if="user.has_group('project.group_project_manager')" type="object" t-att-href="tasks['action']['url_default']" t-att-name="tasks['action']['name']"><t t-esc="tasks['open_tasks']"/> tasks are open</a><t t-else=""><t t-esc="tasks['open_tasks']"/> tasks are open</t>, out of <t t-esc="tasks['total_tasks']"/>. In the last 30 days,
|
||||
<ul>
|
||||
<li t-if="tasks['created_tasks'] > 1"><t t-esc="tasks['created_tasks']"/> were created</li>
|
||||
<li t-else=""><t t-esc="tasks['created_tasks']"/> was created</li>
|
||||
<li t-if="tasks['closed_tasks'] > 1"><t t-esc="tasks['closed_tasks']"/> were closed (folded stage)</li>
|
||||
<li t-else=""><t t-esc="tasks['closed_tasks']"/> was closed (folded stage)</li>
|
||||
</ul>
|
||||
<div name="summary">
|
||||
<h1>Sprint Summary</h1>
|
||||
<p>How’s this project going?</p><br/>
|
||||
</div>
|
||||
|
||||
<hr t-if="milestones['show_section']"/>
|
||||
<div name="activities" t-if="milestones['show_section']">
|
||||
<h1>Activities</h1>
|
||||
</div>
|
||||
|
||||
<div name="milestone">
|
||||
<t t-if="milestones['show_section']">
|
||||
<h1>Milestones</h1>
|
||||
<br/>
|
||||
<h3><b><u>Milestones</u></b></h3>
|
||||
|
||||
<ul class="o_checklist" t-if="milestones['list']">
|
||||
<t t-foreach="milestones['list']" t-as="milestone" t-key="milestone['id']">
|
||||
@@ -39,8 +35,8 @@
|
||||
</ul>
|
||||
|
||||
<t t-if="milestones['updated']">
|
||||
<t t-if="len(milestones['updated']) > 1">Over the last 30 days, the deadline of the following milestones has been updated :</t>
|
||||
<t t-else="">Over the last 30 days, the deadline of the following milestone has been updated :</t>
|
||||
<t t-if="len(milestones['updated']) > 1">The deadline of the following milestones has been updated <t t-if="milestones['last_update_date']">since the last project update (<t t-esc="milestones['last_update_date']" t-options='{"widget": "date"}'/>) </t> :</t>
|
||||
<t t-else="">The deadline of the following milestone has been updated <t t-if="milestones['last_update_date']">since the last project update (<t t-esc="milestones['last_update_date']" t-options='{"widget": "date"}'/>) </t> :</t>
|
||||
<ul>
|
||||
<t t-foreach="milestones['updated']" t-as="milestone" t-key="milestone['id']">
|
||||
<li>
|
||||
@@ -51,8 +47,8 @@
|
||||
</t>
|
||||
|
||||
<t t-if="milestones['created']">
|
||||
<t t-if="len(milestones['created']) > 1">The following milestones have been added :</t>
|
||||
<t t-else="">The following milestone has been added :</t>
|
||||
<t t-if="len(milestones['created']) > 1">The following milestones have been added <t t-if="milestones['last_update_date']">since the last project update (<t t-esc="milestones['last_update_date']" t-options='{"widget": "date"}'/>) </t>:</t>
|
||||
<t t-else="">The following milestone has been added <t t-if="milestones['last_update_date']">since the last project update (<t t-esc="milestones['last_update_date']" t-options='{"widget": "date"}'/>) </t>:</t>
|
||||
<ul>
|
||||
<t t-foreach="milestones['created']" t-as="milestone" t-key="milestone['id']">
|
||||
<li>
|
||||
|
||||
@@ -35,7 +35,11 @@
|
||||
</group>
|
||||
</group>
|
||||
<separator/>
|
||||
<field name="description" widget="html_with_action" nolabel="1" class="o_project_update_description"/>
|
||||
<notebook>
|
||||
<page string="Description" name="description">
|
||||
<field name="description" widget="html_with_action" nolabel="1" class="o_project_update_description"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" options="{'post_refresh':True}" groups="base.group_user"/>
|
||||
@@ -124,7 +128,7 @@
|
||||
<group>
|
||||
<group>
|
||||
<field name="project_id" invisible="1"/>
|
||||
<field name="name"/>
|
||||
<field name="name" placeholder="E.g: Product Launch"/>
|
||||
<field name="deadline"/>
|
||||
<field name="is_reached"/>
|
||||
</group>
|
||||
|
||||
@@ -274,14 +274,6 @@
|
||||
<sheet string="Project">
|
||||
<div class="oe_button_box" name="button_box" groups="base.group_user">
|
||||
<field name="currency_id" invisible="1"/>
|
||||
<button class="oe_stat_button" type="object" name="action_view_analytic_account_entries" icon="fa-usd" attrs="{'invisible': [('analytic_account_id', '=', False)]}" groups="analytic.group_analytic_accounting">
|
||||
<div class="o_form_field o_stat_info">
|
||||
<span class="o_stat_text">Gross Margin</span>
|
||||
<span class="o_stat_value">
|
||||
<field name="analytic_account_balance" widget='monetary'/>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button class="oe_stat_button" type="action"
|
||||
name="%(act_project_project_2_project_task_all)d" icon="fa-tasks">
|
||||
<field string="Tasks In Progress" name="task_count" widget="statinfo" options="{'label_field': 'label_tasks'}"/>
|
||||
@@ -312,16 +304,12 @@
|
||||
Burndown Chart
|
||||
</span>
|
||||
</button>
|
||||
<button class="oe_stat_button" name="%(project.project_update_all_action)d" type="action" groups="project.group_project_manager">
|
||||
<div class="pl-4">
|
||||
<field name="last_update_color" invisible="1"/>
|
||||
<field name="last_update_status" widget="status_with_color" options="{'color_field': 'last_update_color'}"/>
|
||||
</div>
|
||||
</button>
|
||||
<button class="oe_stat_button o_project_not_clickable" disabled="disabled" groups="!project.group_project_manager">
|
||||
<div class="pl-4">
|
||||
<field name="last_update_color" invisible="1"/>
|
||||
<field name="last_update_status" widget="status_with_color" options="{'color_field': 'last_update_color', 'no_quick_edit': 1}"/>
|
||||
<button class="oe_stat_button" type="object" name="action_view_analytic_account_entries" icon="fa-usd" attrs="{'invisible': [('analytic_account_id', '=', False)]}" groups="analytic.group_analytic_accounting">
|
||||
<div class="o_form_field o_stat_info">
|
||||
<span class="o_stat_value">
|
||||
<field name="analytic_account_balance"/>
|
||||
</span>
|
||||
<span class="o_stat_text">Gross Margin</span>
|
||||
</div>
|
||||
</button>
|
||||
<button class="oe_stat_button" name="%(project.project_collaborator_action)d" type="action" icon="fa-users" groups="project.group_project_manager">
|
||||
@@ -334,6 +322,18 @@
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button class="oe_stat_button" name="%(project.project_update_all_action)d" type="action" groups="project.group_project_manager">
|
||||
<div class="pl-4">
|
||||
<field name="last_update_color" invisible="1"/>
|
||||
<field name="last_update_status" widget="status_with_color" options="{'color_field': 'last_update_color'}"/>
|
||||
</div>
|
||||
</button>
|
||||
<button class="oe_stat_button o_project_not_clickable" disabled="disabled" groups="!project.group_project_manager">
|
||||
<div class="pl-4">
|
||||
<field name="last_update_color" invisible="1"/>
|
||||
<field name="last_update_status" widget="status_with_color" options="{'color_field': 'last_update_color', 'no_quick_edit': 1}"/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<widget name="web_ribbon" title="Archived" bg_color="bg-danger" attrs="{'invisible': [('active', '=', True)]}"/>
|
||||
<div class="oe_title">
|
||||
|
||||
@@ -32,22 +32,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box" id="track_customer_satisfaction_setting">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="group_project_rating"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="group_project_rating"/>
|
||||
<div class="text-muted">
|
||||
Track customer satisfaction on tasks
|
||||
</div>
|
||||
<div class="content-group" attrs="{'invisible': [('group_project_rating', '=', False)]}">
|
||||
<div class="mt8">
|
||||
<button name="%(project.open_task_type_form)d" icon="fa-arrow-right" type="action" string="Set a Rating Email Template on Stages" class="btn-link"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box" id="recurring_tasks_setting">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="group_project_recurring_tasks"/>
|
||||
@@ -112,6 +96,36 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Analytics</h2>
|
||||
<div class="row mt16 o_settings_container" name="analytic">
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="group_analytic_accounting"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="group_analytic_accounting" string="Profitability"/>
|
||||
<div class="text-muted">
|
||||
Track the costs and revenues linked to your projects
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box" id="track_customer_satisfaction_setting">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="group_project_rating"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="group_project_rating"/>
|
||||
<div class="text-muted">
|
||||
Track customer satisfaction on tasks
|
||||
</div>
|
||||
<div class="content-group" attrs="{'invisible': [('group_project_rating', '=', False)]}">
|
||||
<div class="mt8">
|
||||
<button name="%(project.open_task_type_form)d" icon="fa-arrow-right" type="action" string="Set a Rating Email Template on Stages" class="btn-link"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</xpath>
|
||||
</field>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
@@ -0,0 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
|
||||
{
|
||||
'name': 'Project Accounting',
|
||||
'version': '1.0',
|
||||
'category': 'Project/account',
|
||||
'summary': 'Project accounting',
|
||||
'description': 'Bridge created to remove the profitability setting if the account module is installed',
|
||||
'depends': ['project', 'account'],
|
||||
'data': [
|
||||
'views/project_project_templates.xml',
|
||||
],
|
||||
'demo': [],
|
||||
'installable': True,
|
||||
'auto_install': True,
|
||||
'license': 'OEEL-1',
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="res_config_settings_view_form" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.inherit.project</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="project.res_config_settings_view_form" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='analytic']/div" position="replace"/>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import models
|
||||
@@ -0,0 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
|
||||
{
|
||||
'name': 'Project Expenses',
|
||||
'version': '1.0',
|
||||
'category': 'Project/expenses',
|
||||
'summary': 'Project expenses',
|
||||
'description': 'Bridge created to add the number of expenses linked to an AA to a project form',
|
||||
'depends': ['project', 'hr_expense'],
|
||||
'data': [
|
||||
'views/project_project_views.xml',
|
||||
],
|
||||
'demo': [],
|
||||
'installable': True,
|
||||
'auto_install': True,
|
||||
'license': 'OEEL-1',
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import project
|
||||
@@ -0,0 +1,58 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
class Project(models.Model):
|
||||
_inherit = 'project.project'
|
||||
|
||||
expenses_count = fields.Integer('# Expenses', compute='_compute_expenses_count')
|
||||
|
||||
@api.depends('analytic_account_id')
|
||||
def _compute_expenses_count(self):
|
||||
expenses_data = self.env['hr.expense'].read_group([
|
||||
('analytic_account_id', '!=', False),
|
||||
('analytic_account_id', 'in', self.analytic_account_id.ids)
|
||||
],
|
||||
['analytic_account_id'], ['analytic_account_id'])
|
||||
mapped_data = {data['analytic_account_id'][0]: data['analytic_account_id_count'] for data in expenses_data}
|
||||
for project in self:
|
||||
project.expenses_count = mapped_data.get(project.analytic_account_id.id, 0)
|
||||
|
||||
# ----------------------------
|
||||
# Actions
|
||||
# ----------------------------
|
||||
|
||||
def action_open_project_expenses(self):
|
||||
expenses = self.env['hr.expense'].search([
|
||||
('analytic_account_id', '!=', False),
|
||||
('analytic_account_id', 'in', self.analytic_account_id.ids)
|
||||
])
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("hr_expense.hr_expense_actions_all")
|
||||
action.update({
|
||||
'display_name': _('Expenses'),
|
||||
'views': [[False, 'tree'], [False, 'form'], [False, 'kanban'], [False, 'graph'], [False, 'pivot']],
|
||||
'context': {'default_analytic_account_id': self.analytic_account_id.id},
|
||||
'domain': [('id', 'in', expenses.ids)]
|
||||
})
|
||||
if(len(expenses) == 1):
|
||||
action["views"] = [[False, 'form']]
|
||||
action["res_id"] = expenses.id
|
||||
return action
|
||||
|
||||
# ----------------------------
|
||||
# Project Updates
|
||||
# ----------------------------
|
||||
|
||||
def _get_stat_buttons(self):
|
||||
buttons = super(Project, self)._get_stat_buttons()
|
||||
buttons.append({
|
||||
'icon': 'money',
|
||||
'text': _('Expenses'),
|
||||
'number': self.expenses_count,
|
||||
'action_type': 'object',
|
||||
'action': 'action_open_project_expenses',
|
||||
'show': self.user_has_groups('hr_expense.group_hr_expense_team_approver') and self.expenses_count > 0,
|
||||
'sequence': 10,
|
||||
})
|
||||
return buttons
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="project_project_view_form" model="ir.ui.view">
|
||||
<field name="name">project.project.form.inherit</field>
|
||||
<field name="model">project.project</field>
|
||||
<field name="inherit_id" ref="project.edit_project"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//button[@name='%(project.action_project_task_burndown_chart_report)d']" position="after">
|
||||
<button name="action_open_project_expenses" type="object" class="oe_stat_button" icon="fa-money" attrs="{'invisible': [('expenses_count', '=', 0)]}" groups="hr_expense.group_hr_expense_team_approver">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_value">
|
||||
<field name="expenses_count" nolabel="1"/>
|
||||
</span>
|
||||
<span class="o_stat_text">
|
||||
Expenses
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,14 +1,61 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class Project(models.Model):
|
||||
_inherit = "project.project"
|
||||
|
||||
purchase_order_count = fields.Integer(related="analytic_account_id.purchase_order_count")
|
||||
purchase_orders_count = fields.Integer('# Purchase Orders', compute='_compute_purchase_orders_count')
|
||||
|
||||
def action_view_purchase_orders(self):
|
||||
self.ensure_one()
|
||||
return self.analytic_account_id.action_view_purchase_orders()
|
||||
@api.depends('analytic_account_id')
|
||||
def _compute_purchase_orders_count(self):
|
||||
purchase_orders_data = self.env['purchase.order.line'].read_group([
|
||||
('account_analytic_id', '!=', False),
|
||||
('account_analytic_id', 'in', self.analytic_account_id.ids)
|
||||
], ['account_analytic_id', 'order_id:count_distinct'], ['account_analytic_id'])
|
||||
mapped_data = dict([(data['account_analytic_id'][0], data['order_id']) for data in purchase_orders_data])
|
||||
for project in self:
|
||||
project.purchase_orders_count = mapped_data.get(project.analytic_account_id.id, 0)
|
||||
|
||||
# ----------------------------
|
||||
# Actions
|
||||
# ----------------------------
|
||||
|
||||
def action_open_project_purchase_orders(self):
|
||||
purchase_orders = self.env['purchase.order'].search([
|
||||
('order_line.account_analytic_id', '!=', False),
|
||||
('order_line.account_analytic_id', 'in', self.analytic_account_id.ids)
|
||||
])
|
||||
action_window = {
|
||||
'name': _('Purchase Orders'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'purchase.order',
|
||||
'views': [[False, 'tree'], [False, 'form']],
|
||||
'domain': [('id', 'in', purchase_orders.ids)],
|
||||
'context': {
|
||||
'create': False,
|
||||
}
|
||||
}
|
||||
if len(purchase_orders) == 1:
|
||||
action_window['views'] = [[False, 'form']]
|
||||
action_window['res_id'] = purchase_orders.id
|
||||
return action_window
|
||||
|
||||
# ----------------------------
|
||||
# Project Updates
|
||||
# ----------------------------
|
||||
|
||||
def _get_stat_buttons(self):
|
||||
buttons = super(Project, self)._get_stat_buttons()
|
||||
buttons.append({
|
||||
'icon': 'credit-card',
|
||||
'text': _('Purchase Orders'),
|
||||
'number': self.purchase_orders_count,
|
||||
'action_type': 'object',
|
||||
'action': 'action_open_project_purchase_orders',
|
||||
'show': self.user_has_groups('purchase.group_purchase_user') and self.purchase_orders_count > 0,
|
||||
'sequence': 13,
|
||||
})
|
||||
return buttons
|
||||
|
||||
@@ -3,13 +3,18 @@
|
||||
<record id="project_project_form_view_inherited" model="ir.ui.view">
|
||||
<field name="name">project.project.view.inherited</field>
|
||||
<field name="model">project.project</field>
|
||||
<field name="inherit_id" ref="project.edit_project" />
|
||||
<field name="priority">20</field>
|
||||
<field name="inherit_id" ref="project.edit_project"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//button[@name='action_view_analytic_account_entries']" position="after">
|
||||
<button class="oe_stat_button" type="object" name="action_view_purchase_orders"
|
||||
icon="fa-shopping-cart" attrs="{'invisible': [('purchase_order_count', '=', 0)]}">
|
||||
<field string="Purchase Orders" name="purchase_order_count" widget="statinfo"/>
|
||||
<xpath expr="//button[@name='%(project.action_project_task_burndown_chart_report)d']" position="after">
|
||||
<button name="action_open_project_purchase_orders" type="object" class="oe_stat_button" icon="fa-credit-card" attrs="{'invisible': [('purchase_orders_count', '=', 0)]}" groups="purchase.group_purchase_user">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_value">
|
||||
<field name="purchase_orders_count" nolabel="1"/>
|
||||
</span>
|
||||
<span class="o_stat_text">
|
||||
Purchase Orders
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</xpath>
|
||||
</field>
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
from ast import literal_eval
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
from odoo.exceptions import ValidationError, UserError
|
||||
|
||||
class Project(models.Model):
|
||||
_inherit = 'project.project'
|
||||
@@ -17,7 +16,7 @@ class Project(models.Model):
|
||||
help="Sales order item to which the project is linked. Link the timesheet entry to the sales order item defined on the project. "
|
||||
"Only applies on tasks without sale order item defined, and if the employee is not in the 'Employee/Sales Order Item Mapping' of the project.")
|
||||
sale_order_id = fields.Many2one(string='Sales Order', related='sale_line_id.order_id', help="Sales order to which the project is linked.")
|
||||
project_overview = fields.Boolean('Show Project Overview', compute='_compute_project_overview')
|
||||
has_any_so_to_invoice = fields.Boolean('Has SO to Invoice', compute='_compute_has_any_so_to_invoice')
|
||||
|
||||
@api.model
|
||||
def _map_tasks_default_valeus(self, task, project):
|
||||
@@ -34,13 +33,17 @@ class Project(models.Model):
|
||||
)
|
||||
).update({'sale_line_id': False})
|
||||
|
||||
@api.depends('analytic_account_id')
|
||||
def _compute_project_overview(self):
|
||||
overview = self.env['project.project']
|
||||
if self.user_has_groups('analytic.group_analytic_accounting'):
|
||||
overview = self.filtered(lambda p: p.analytic_account_id)
|
||||
overview.project_overview = True
|
||||
(self - overview).project_overview = False
|
||||
@api.depends('sale_order_id')
|
||||
def _compute_has_any_so_to_invoice(self):
|
||||
"""Has any Sale Order whose invoice_status is set as To Invoice"""
|
||||
if not self.user_has_groups('sales_team.group_sale_salesman_all_leads'):
|
||||
self.has_any_so_to_invoice = False
|
||||
else:
|
||||
for project in self:
|
||||
has_any_so_to_invoice = project.sale_order_id and project.sale_order_id.invoice_status == "to invoice"
|
||||
for task in project.task_ids:
|
||||
has_any_so_to_invoice = has_any_so_to_invoice or (task.sale_order_id and task.sale_order_id.invoice_status == "to invoice")
|
||||
project.has_any_so_to_invoice = has_any_so_to_invoice
|
||||
|
||||
def action_view_so(self):
|
||||
self.ensure_one()
|
||||
@@ -54,6 +57,34 @@ class Project(models.Model):
|
||||
}
|
||||
return action_window
|
||||
|
||||
def action_create_invoice(self):
|
||||
if not self.sale_order_id and self.sale_order_id.invoice_status != 'to invoice':
|
||||
raise UserError(_("The selected Sales Order should contain something to invoice."))
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("sale.action_view_sale_advance_payment_inv")
|
||||
sols = self.sale_order_id.ids
|
||||
for task in self.task_ids:
|
||||
if task.sale_order_id.id not in sols:
|
||||
sols += [task.sale_order_id.id]
|
||||
action['context'] = {
|
||||
'active_ids': sols
|
||||
}
|
||||
return action
|
||||
|
||||
# ----------------------------
|
||||
# Project Updates
|
||||
# ----------------------------
|
||||
|
||||
def _get_stat_buttons(self):
|
||||
buttons = super(Project, self)._get_stat_buttons()
|
||||
buttons.append({
|
||||
'icon': 'dollar',
|
||||
'text': _('Sales Order'),
|
||||
'action_type': 'object',
|
||||
'action': 'action_view_so',
|
||||
'show': self.user_has_groups('sales_team.group_sale_salesman_all_leads') and bool(self.sale_order_id),
|
||||
'sequence': 1,
|
||||
})
|
||||
return buttons
|
||||
|
||||
class ProjectTask(models.Model):
|
||||
_inherit = "project.task"
|
||||
|
||||
@@ -18,7 +18,6 @@ class SaleOrder(models.Model):
|
||||
'project.project', 'Project', readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]},
|
||||
help='Select a non billable project on which tasks can be created.')
|
||||
project_ids = fields.Many2many('project.project', compute="_compute_project_ids", string='Projects', copy=False, groups="project.group_project_user", help="Projects used in this sales order.")
|
||||
project_overview = fields.Boolean('Show Project Overview', compute='_compute_project_overview')
|
||||
project_count = fields.Integer(string='Number of Projects', compute='_compute_project_ids', groups='project.group_project_user')
|
||||
|
||||
@api.depends('order_line.product_id.project_id')
|
||||
@@ -36,12 +35,6 @@ class SaleOrder(models.Model):
|
||||
service_tracking == 'task_in_project' for service_tracking in order.order_line.mapped('product_id.service_tracking')
|
||||
)
|
||||
|
||||
@api.depends('project_ids')
|
||||
def _compute_project_overview(self):
|
||||
for order in self:
|
||||
billable_projects = order.project_ids.filtered('sale_line_id')
|
||||
order.project_overview = len(order.project_ids) == 1 and len(billable_projects) == 1 and billable_projects.project_overview
|
||||
|
||||
@api.depends('order_line.product_id', 'order_line.project_id')
|
||||
def _compute_project_ids(self):
|
||||
for order in self:
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
<field name="model">project.project</field>
|
||||
<field name="inherit_id" ref="project.edit_project"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//header" position="inside">
|
||||
<field name="has_any_so_to_invoice" invisible="1"/>
|
||||
<button name="action_create_invoice" string="Create Invoice" type="object" class="oe_highlight oe_read_only" groups="sales_team.group_sale_salesman_all_leads" attrs="{'invisible': [('has_any_so_to_invoice', '=', False)]}"/>
|
||||
</xpath>
|
||||
<xpath expr="//button[@name='%(project.act_project_project_2_project_task_all)d']" position="before">
|
||||
<button class="d-none d-md-inline oe_stat_button"
|
||||
type="object" name="action_view_so" icon="fa-dollar"
|
||||
@@ -43,7 +47,6 @@
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_sale_project_inherit_form" model="ir.ui.view">
|
||||
<field name="name">project.task.view.inherit</field>
|
||||
<field name="model">project.task</field>
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
<field name="priority">10</field>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//button[@name='preview_sale_order']" position="before">
|
||||
<button type="object" name="action_view_project_ids" class="oe_stat_button" icon="fa-puzzle-piece" attrs="{'invisible': ['|', ('project_ids', '=', []), ('project_overview', '=', True)]}" groups="project.group_project_manager">
|
||||
<button type="object" name="action_view_project_ids" class="oe_stat_button" icon="fa-puzzle-piece" attrs="{'invisible': [('project_ids', '=', [])]}" groups="project.group_project_manager">
|
||||
<field name="project_ids" invisible="1"/>
|
||||
<field name="project_overview" invisible="1"/>
|
||||
<field name="project_count" widget="statinfo" string="Projects"/>
|
||||
</button>
|
||||
<button type="object" name="action_view_project_ids" class="oe_stat_button" icon="fa-puzzle-piece" string="Project Overview" attrs="{'invisible': ['|', ('project_ids', '=', []), ('project_overview', '=', False)]}" groups="project.group_project_manager"/>
|
||||
</xpath>
|
||||
<xpath expr="//button[@name='action_view_invoice']" position="before">
|
||||
<button type="object" name="action_view_task" class="oe_stat_button" icon="fa-tasks" attrs="{'invisible': [('tasks_count', '=', 0)]}" groups="project.group_project_user">
|
||||
<field name="tasks_count" widget="statinfo" string="Tasks"/>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import models
|
||||
@@ -0,0 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
|
||||
{
|
||||
'name': 'Project Sales Accounting',
|
||||
'version': '1.0',
|
||||
'category': 'Project/account',
|
||||
'summary': 'Project sales accounting',
|
||||
'description': 'Bridge created to add the number of vendor bills linked to an AA to a project form',
|
||||
'depends': ['sale_timesheet', 'account'],
|
||||
'data': [
|
||||
'views/project_project_views.xml',
|
||||
],
|
||||
'demo': [],
|
||||
'installable': True,
|
||||
'auto_install': True,
|
||||
'license': 'OEEL-1',
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import project
|
||||
@@ -0,0 +1,47 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models, _
|
||||
|
||||
class Project(models.Model):
|
||||
_inherit = 'project.project'
|
||||
|
||||
vendor_bill_count = fields.Integer(related='analytic_account_id.vendor_bill_count')
|
||||
|
||||
# ----------------------------
|
||||
# Actions
|
||||
# ----------------------------
|
||||
|
||||
def action_open_project_vendor_bills(self):
|
||||
vendor_bills = self.env['account.move'].search([('line_ids.analytic_account_id', '!=', False), ('line_ids.analytic_account_id', 'in', self.analytic_account_id.ids), ('move_type', '=', 'in_invoice')])
|
||||
action_window = {
|
||||
'name': _('Vendor Bills'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'account.move',
|
||||
'views': [[False, 'tree'], [False, 'form'], [False, 'kanban']],
|
||||
'domain': [('id', 'in', vendor_bills.ids)],
|
||||
'context': {
|
||||
'create': False,
|
||||
}
|
||||
}
|
||||
if len(vendor_bills) == 1:
|
||||
action_window['views'] = [[False, 'form']]
|
||||
action_window['res_id'] = vendor_bills.id
|
||||
return action_window
|
||||
|
||||
# ----------------------------
|
||||
# Project Updates
|
||||
# ----------------------------
|
||||
|
||||
def _get_stat_buttons(self):
|
||||
buttons = super(Project, self)._get_stat_buttons()
|
||||
buttons.append({
|
||||
'icon': 'pencil-square-o',
|
||||
'text': _('Vendor Bills'),
|
||||
'number': self.vendor_bill_count,
|
||||
'action_type': 'object',
|
||||
'action': 'action_open_project_vendor_bills',
|
||||
'show': self.user_has_groups('account.group_account_readonly') and self.vendor_bill_count > 0,
|
||||
'sequence': 14,
|
||||
})
|
||||
return buttons
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="project_project_view_form" model="ir.ui.view">
|
||||
<field name="name">project.project.form.inherit</field>
|
||||
<field name="model">project.project</field>
|
||||
<field name="inherit_id" ref="project.edit_project"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//button[@name='%(project.action_project_task_burndown_chart_report)d']" position="after">
|
||||
<button name="action_open_project_vendor_bills" type="object" class="oe_stat_button" icon="fa-pencil-square-o" attrs="{'invisible': [('vendor_bill_count', '=', 0)]}" groups="account.group_account_readonly">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_value">
|
||||
<field name="vendor_bill_count" nolabel="1"/>
|
||||
</span>
|
||||
<span class="o_stat_text">
|
||||
Vendor Bills
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -25,7 +25,6 @@ have real delivered quantities in sales orders.
|
||||
'views/project_update_templates.xml',
|
||||
'views/hr_timesheet_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/hr_timesheet_templates.xml',
|
||||
'views/sale_timesheet_portal_templates.xml',
|
||||
'views/project_sharing_views.xml',
|
||||
'report/project_profitability_report_analysis_views.xml',
|
||||
@@ -44,8 +43,6 @@ have real delivered quantities in sales orders.
|
||||
'sale_timesheet/static/src/scss/sale_timesheet_portal.scss',
|
||||
],
|
||||
'web.assets_backend': [
|
||||
'sale_timesheet/static/src/scss/sale_timesheet.scss',
|
||||
'sale_timesheet/static/src/js/project_overview.js',
|
||||
'sale_timesheet/static/src/js/so_line_one2many.js',
|
||||
],
|
||||
'web.assets_tests': [
|
||||
|
||||
@@ -5,7 +5,6 @@ from . import account
|
||||
from . import account_move
|
||||
from . import product
|
||||
from . import project
|
||||
from . import project_overview
|
||||
from . import project_update
|
||||
from . import sale_order
|
||||
from . import res_config_settings
|
||||
|
||||
@@ -18,7 +18,11 @@ class AccountAnalyticLine(models.Model):
|
||||
('billable_time', 'Billed on Timesheets'),
|
||||
('billable_fixed', 'Billed at a Fixed price'),
|
||||
('non_billable', 'Non Billable Tasks'),
|
||||
('non_billable_project', 'No task found')], string="Billable Type", compute='_compute_timesheet_invoice_type', compute_sudo=True, store=True, readonly=True)
|
||||
('timesheet_revenues', 'Timesheet Revenues'),
|
||||
('service_revenues', 'Service Revenues'),
|
||||
('other_revenues', 'Other Revenues'),
|
||||
('other_costs', 'Other Costs')], 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")
|
||||
so_line = fields.Many2one(compute="_compute_so_line", store=True, readonly=False, domain="[('is_service', '=', True), ('is_expense', '=', False), ('state', 'in', ['sale', 'done']), ('order_partner_id', 'child_of', commercial_partner_id)]")
|
||||
@@ -37,23 +41,25 @@ class AccountAnalyticLine(models.Model):
|
||||
if not self.is_so_line_edited:
|
||||
self.is_so_line_edited = True
|
||||
|
||||
# TODO: [XBO] Since the task_id is not required in this model, then it should more efficient to depends to pricing_type of project (See in master)
|
||||
@api.depends('so_line.product_id', 'project_id', 'task_id', 'task_id.pricing_type')
|
||||
@api.depends('so_line.product_id', 'project_id', 'amount')
|
||||
def _compute_timesheet_invoice_type(self):
|
||||
for timesheet in self:
|
||||
if timesheet.project_id: # AAL will be set to False
|
||||
invoice_type = 'non_billable_project' if not timesheet.task_id else 'non_billable'
|
||||
if timesheet.task_id and timesheet.so_line.product_id.type == 'service':
|
||||
invoice_type = 'non_billable' if not timesheet.so_line else False
|
||||
if timesheet.so_line and 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 = 'billable_time'
|
||||
invoice_type = 'timesheet_revenues' if timesheet.amount > 0 else 'billable_time'
|
||||
else:
|
||||
invoice_type = 'billable_fixed'
|
||||
elif timesheet.so_line.product_id.invoice_policy == 'order':
|
||||
invoice_type = 'billable_fixed'
|
||||
timesheet.timesheet_invoice_type = invoice_type
|
||||
else:
|
||||
timesheet.timesheet_invoice_type = False
|
||||
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' if timesheet.amount >= 0 else '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):
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
# -*- 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, _
|
||||
from odoo.osv import expression
|
||||
from odoo.exceptions import ValidationError, UserError
|
||||
from odoo.tools import format_amount, float_round, float_is_zero
|
||||
from odoo.tools.misc import formatLang
|
||||
|
||||
from odoo.tools import format_amount, float_is_zero
|
||||
|
||||
# YTI PLEASE SPLIT ME
|
||||
class Project(models.Model):
|
||||
@@ -39,6 +38,9 @@ class Project(models.Model):
|
||||
help="Employee/Sale Order Item Mapping:\n Defines to which sales order item an employee's timesheet entry will be linked."
|
||||
"By extension, it defines the rate at which an employee's time on the project is billed.")
|
||||
allow_billable = fields.Boolean("Billable", help="Invoice your time and material from tasks.")
|
||||
billable_percentage = fields.Integer(
|
||||
compute='_compute_billable_percentage',
|
||||
help="% of timesheets that are billable compared to the total number of timesheets linked to the AA of the project, rounded to the unit.")
|
||||
display_create_order = fields.Boolean(compute='_compute_display_create_order')
|
||||
timesheet_product_id = fields.Many2one(
|
||||
'product.product', string='Timesheet Product',
|
||||
@@ -108,6 +110,21 @@ class Project(models.Model):
|
||||
domain = expression.AND([domain, [('allow_billable', '=', True)]])
|
||||
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'], ['project_id', 'so_line'], lazy=False)
|
||||
timesheets_by_project = defaultdict(list)
|
||||
for res in timesheets_read_group:
|
||||
timesheets_by_project[res['project_id'][0]].append((res['unit_amount'], bool(res['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('partner_id', 'pricing_type')
|
||||
def _compute_display_create_order(self):
|
||||
for project in self:
|
||||
@@ -134,12 +151,6 @@ class Project(models.Model):
|
||||
|
||||
(self - projects).warning_employee_rate = False
|
||||
|
||||
@api.depends('analytic_account_id', 'allow_billable', 'allow_timesheets')
|
||||
def _compute_project_overview(self):
|
||||
super()._compute_project_overview()
|
||||
for project in self.filtered(lambda p: not p.project_overview):
|
||||
project.project_overview = project.allow_billable or project.allow_timesheets
|
||||
|
||||
@api.depends('sale_line_employee_ids.sale_line_id', 'sale_line_id')
|
||||
def _compute_partner_id(self):
|
||||
for project in self:
|
||||
@@ -188,10 +199,29 @@ class Project(models.Model):
|
||||
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_open_project_invoices(self):
|
||||
invoices = self.env['account.move'].search([
|
||||
('line_ids.analytic_account_id', '!=', False),
|
||||
('line_ids.analytic_account_id', 'in', self.analytic_account_id.ids),
|
||||
('move_type', '=', 'out_invoice')
|
||||
])
|
||||
action = {
|
||||
'name': _('Invoices'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'account.move',
|
||||
'views': [[False, 'tree'], [False, 'form'], [False, 'kanban']],
|
||||
'domain': [('id', 'in', invoices.ids)],
|
||||
'context': {
|
||||
'create': False,
|
||||
}
|
||||
}
|
||||
if len(invoices) == 1:
|
||||
action['views'] = [[False, 'form']]
|
||||
action['res_id'] = invoices.id
|
||||
return action
|
||||
|
||||
def action_view_timesheet(self):
|
||||
self.ensure_one()
|
||||
if self.allow_timesheets:
|
||||
return self.action_view_timesheet_plan()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Timesheets of %s', self.name),
|
||||
@@ -215,18 +245,6 @@ class Project(models.Model):
|
||||
}
|
||||
}
|
||||
|
||||
def action_view_timesheet_plan(self):
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("sale_timesheet.project_timesheet_action_client_timesheet_plan")
|
||||
action['params'] = {
|
||||
'project_ids': self.ids,
|
||||
}
|
||||
action['context'] = {
|
||||
'active_id': self.id,
|
||||
'active_ids': self.ids,
|
||||
'search_default_name': self.name,
|
||||
}
|
||||
return action
|
||||
|
||||
def action_make_billable(self):
|
||||
return {
|
||||
"name": _("Create Sales Order"),
|
||||
@@ -241,6 +259,27 @@ class Project(models.Model):
|
||||
},
|
||||
}
|
||||
|
||||
def action_billable_time_button(self):
|
||||
self.ensure_one()
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("hr_timesheet.timesheet_action_all")
|
||||
action.update({
|
||||
'context': {
|
||||
'search_default_groupby_timesheet_invoice_type': True,
|
||||
'default_project_id': self.id,
|
||||
},
|
||||
'domain': [('project_id', '=', self.id)],
|
||||
'view_mode': 'tree,grid,kanban,pivot,graph,form',
|
||||
'views': [
|
||||
[self.env.ref('hr_timesheet.timesheet_view_tree_user').id, 'tree'],
|
||||
[self.env.ref('timesheet_grid.timesheet_view_grid_by_employee').id, 'grid'],
|
||||
[self.env.ref('hr_timesheet.view_kanban_account_analytic_line').id, 'kanban'],
|
||||
[self.env.ref('hr_timesheet.view_hr_timesheet_line_pivot').id, 'pivot'],
|
||||
[self.env.ref('hr_timesheet.view_hr_timesheet_line_graph_all').id, 'graph'],
|
||||
[self.env.ref('timesheet_grid.timesheet_view_form').id, 'form'],
|
||||
],
|
||||
})
|
||||
return action
|
||||
|
||||
# ----------------------------
|
||||
# Project Updates
|
||||
# ----------------------------
|
||||
@@ -249,26 +288,52 @@ class Project(models.Model):
|
||||
panel_data = super(Project, self).get_panel_data()
|
||||
return {
|
||||
**panel_data,
|
||||
'analytic_account_id': self.analytic_account_id.id,
|
||||
'sold_items': self._get_sold_items(),
|
||||
'profitability_items': self._get_profitability_items()
|
||||
}
|
||||
|
||||
def _get_sale_order_lines(self):
|
||||
sale_orders = self.sale_order_id | self.tasks.sale_order_id
|
||||
return self.env['sale.order.line'].search([('order_id', 'in', sale_orders.ids), ('is_service', '=', True)], order='id asc')
|
||||
|
||||
def _get_sold_items(self):
|
||||
sols = self._get_sale_order_lines()
|
||||
number_sale_orders = len(sols.order_id)
|
||||
sold_items = {
|
||||
'allow_billable': self.allow_billable,
|
||||
'data': [],
|
||||
'number_sols': len(sols),
|
||||
'total_sold': 0,
|
||||
'effective_sold': 0,
|
||||
'company_unit_name': self.env.company.timesheet_encode_uom_id.name
|
||||
}
|
||||
for sol in sols:
|
||||
name = [x[1] for x in sol.name_get()] if number_sale_orders > 1 else sol.name
|
||||
qty_delivered = int(sol.qty_delivered) if sol.qty_delivered.is_integer() else sol.qty_delivered
|
||||
product_uom_qty = int(sol.product_uom_qty) if sol.product_uom_qty.is_integer() else sol.product_uom_qty
|
||||
sold_items['data'].append({
|
||||
'name': name,
|
||||
'value': '%s / %s %s' % (qty_delivered, product_uom_qty, sol.product_uom.name),
|
||||
'color': 'red' if qty_delivered > product_uom_qty else 'black'
|
||||
})
|
||||
#We only want to consider hours and days for this calculation
|
||||
if sol.product_uom.category_id == self.env.company.timesheet_encode_uom_id.category_id:
|
||||
sold_items['total_sold'] += sol.product_uom._compute_quantity(sol.product_uom_qty, self.env.company.timesheet_encode_uom_id, raise_if_failure=False)
|
||||
sold_items['effective_sold'] = round(sol.product_uom._compute_quantity(self.total_timesheet_time, self.env.company.timesheet_encode_uom_id, raise_if_failure=False), 2)
|
||||
remaining = sold_items['total_sold'] - sold_items['effective_sold']
|
||||
sold_items['remaining'] = {
|
||||
'value': remaining,
|
||||
'color': 'red' if remaining < 0 else 'black',
|
||||
}
|
||||
return sold_items
|
||||
|
||||
def _get_profitability_items(self):
|
||||
if not self.user_has_groups('project.group_project_manager'):
|
||||
return {'data': []}
|
||||
timesheets = self.allow_timesheets and self.user_has_groups('hr_timesheet.group_hr_timesheet_user')
|
||||
profitability = self._get_profitability_common(costs_revenues=self.allow_billable, timesheets=timesheets)
|
||||
data = []
|
||||
if timesheets:
|
||||
data += [{
|
||||
'name': _("Timesheets"),
|
||||
'value': self.env.ref('sale_timesheet.project_profitability_timesheet_panel')._render({
|
||||
'timesheet_unit_amount': float_round(profitability['timesheet_unit_amount'], precision_digits=2),
|
||||
'timesheet_uom': self.env.company._timesheet_uom_text(),
|
||||
'is_timesheet_uom_hour': self.env.company._is_timesheet_hour_uom(),
|
||||
'percentage_billable': formatLang(self.env, profitability['timesheet_percentage_billable'], digits=0),
|
||||
}, engine='ir.qweb'),
|
||||
}]
|
||||
if self.allow_billable:
|
||||
profitability = self._get_profitability_common()
|
||||
margin_color = False
|
||||
if not float_is_zero(profitability['margin'], precision_digits=0):
|
||||
margin_color = profitability['margin'] > 0 and 'green' or 'red'
|
||||
@@ -289,51 +354,54 @@ class Project(models.Model):
|
||||
'data': data,
|
||||
}
|
||||
|
||||
def _get_profitability_common(self, costs_revenues=True, timesheets=True):
|
||||
def _get_profitability_common(self):
|
||||
self.ensure_one()
|
||||
result = {
|
||||
'costs': 0.0,
|
||||
'margin': 0.0,
|
||||
'revenues': 0.0,
|
||||
'timesheet_unit_amount': 0.0,
|
||||
'timesheet_percentage_billable': 0.0,
|
||||
'revenues': 0.0
|
||||
}
|
||||
if costs_revenues:
|
||||
profitability = self.env['project.profitability.report'].read_group(
|
||||
[('project_id', '=', self.id)],
|
||||
['project_id',
|
||||
'amount_untaxed_to_invoice',
|
||||
'amount_untaxed_invoiced',
|
||||
'expense_amount_untaxed_to_invoice',
|
||||
'expense_amount_untaxed_invoiced',
|
||||
'other_revenues',
|
||||
'expense_cost',
|
||||
'timesheet_cost',
|
||||
'margin'],
|
||||
['project_id'], limit=1)
|
||||
if profitability:
|
||||
profitability = profitability[0]
|
||||
result.update({
|
||||
'costs': profitability['timesheet_cost'] + profitability['expense_cost'],
|
||||
'margin': profitability['margin'],
|
||||
'revenues': (profitability['amount_untaxed_invoiced'] + profitability['amount_untaxed_to_invoice'] +
|
||||
profitability['expense_amount_untaxed_invoiced'] + profitability['expense_amount_untaxed_to_invoice'] +
|
||||
profitability['other_revenues']),
|
||||
})
|
||||
if timesheets:
|
||||
timesheets = self.env['account.analytic.line'].read_group([('project_id', '=', self.id)], ['so_line', 'unit_amount'], ['so_line'])
|
||||
timesheet_billable = timesheet_non_billable = 0.0
|
||||
for timesheet in timesheets:
|
||||
if timesheet['so_line']:
|
||||
timesheet_billable += timesheet['unit_amount']
|
||||
else:
|
||||
timesheet_non_billable += timesheet['unit_amount']
|
||||
|
||||
profitability = self.env['project.profitability.report'].read_group(
|
||||
[('project_id', '=', self.id)],
|
||||
['project_id',
|
||||
'amount_untaxed_to_invoice',
|
||||
'amount_untaxed_invoiced',
|
||||
'expense_amount_untaxed_to_invoice',
|
||||
'expense_amount_untaxed_invoiced',
|
||||
'other_revenues',
|
||||
'expense_cost',
|
||||
'timesheet_cost',
|
||||
'margin'],
|
||||
['project_id'], limit=1)
|
||||
if profitability:
|
||||
profitability = profitability[0]
|
||||
result.update({
|
||||
'timesheet_unit_amount': self._convert_project_uom_to_timesheet_encode_uom(timesheet_billable + timesheet_non_billable),
|
||||
'timesheet_percentage_billable': timesheet_billable and timesheet_billable / (timesheet_billable + timesheet_non_billable) * 100 or 0.0,
|
||||
'costs': profitability['timesheet_cost'] + profitability['expense_cost'],
|
||||
'margin': profitability['margin'],
|
||||
'revenues': (profitability['amount_untaxed_invoiced'] + profitability['amount_untaxed_to_invoice'] +
|
||||
profitability['expense_amount_untaxed_invoiced'] + profitability['expense_amount_untaxed_to_invoice'] +
|
||||
profitability['other_revenues']),
|
||||
})
|
||||
return result
|
||||
|
||||
def _get_stat_buttons(self):
|
||||
buttons = super(Project, self)._get_stat_buttons()
|
||||
buttons.append({
|
||||
'icon': 'clock-o',
|
||||
'text': _('Billable Time'),
|
||||
'number': '%s %%' % (self.billable_percentage),
|
||||
'action_type': 'object',
|
||||
'action': 'action_billable_time_button',
|
||||
'additional_context': json.dumps({
|
||||
'active_id': self.id,
|
||||
'default_project_id': self.id
|
||||
}),
|
||||
'show': self.user_has_groups('hr_timesheet.group_hr_timesheet_approver') and self.allow_timesheets and bool(self.analytic_account_id),
|
||||
'sequence': 9,
|
||||
})
|
||||
return buttons
|
||||
|
||||
class ProjectTask(models.Model):
|
||||
_inherit = "project.task"
|
||||
|
||||
|
||||
@@ -1,580 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import babel.dates
|
||||
from dateutil.relativedelta import relativedelta
|
||||
import itertools
|
||||
import json
|
||||
|
||||
from odoo import fields, _, models
|
||||
from odoo.osv import expression
|
||||
from odoo.tools import float_round
|
||||
from odoo.tools.misc import get_lang
|
||||
|
||||
from odoo.addons.web.controllers.main import clean_action
|
||||
from datetime import date
|
||||
|
||||
DEFAULT_MONTH_RANGE = 3
|
||||
|
||||
|
||||
class Project(models.Model):
|
||||
_inherit = 'project.project'
|
||||
|
||||
|
||||
def _qweb_prepare_qcontext(self, view_id, domain):
|
||||
values = super()._qweb_prepare_qcontext(view_id, domain)
|
||||
|
||||
projects = self.search(domain)
|
||||
values.update(projects._plan_prepare_values())
|
||||
values['actions'] = projects._plan_prepare_actions(values)
|
||||
|
||||
return values
|
||||
|
||||
def _plan_get_employee_ids(self):
|
||||
aal_employee_ids = self.env['account.analytic.line'].read_group([('project_id', 'in', self.ids), ('employee_id', '!=', False)], ['employee_id'], ['employee_id'])
|
||||
employee_ids = list(map(lambda x: x['employee_id'][0], aal_employee_ids))
|
||||
return employee_ids
|
||||
|
||||
def _plan_prepare_values(self):
|
||||
currency = self.env.company.currency_id
|
||||
uom_hour = self.env.ref('uom.product_uom_hour')
|
||||
company_uom = self.env.company.timesheet_encode_uom_id
|
||||
is_uom_day = company_uom == self.env.ref('uom.product_uom_day')
|
||||
hour_rounding = uom_hour.rounding
|
||||
billable_types = ['non_billable', 'non_billable_project', 'billable_time', 'non_billable_timesheet', 'billable_fixed']
|
||||
|
||||
values = {
|
||||
'projects': self,
|
||||
'currency': currency,
|
||||
'timesheet_domain': [('project_id', 'in', self.ids)],
|
||||
'profitability_domain': [('project_id', 'in', self.ids)],
|
||||
'stat_buttons': self._plan_get_stat_button(),
|
||||
'is_uom_day': is_uom_day,
|
||||
}
|
||||
|
||||
#
|
||||
# Hours, Rates and Profitability
|
||||
#
|
||||
dashboard_values = {
|
||||
'time': dict.fromkeys(billable_types + ['total'], 0.0),
|
||||
'rates': dict.fromkeys(billable_types + ['total'], 0.0),
|
||||
'profit': {
|
||||
'invoiced': 0.0,
|
||||
'to_invoice': 0.0,
|
||||
'cost': 0.0,
|
||||
'total': 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
# hours from non-invoiced timesheets that are linked to canceled so
|
||||
canceled_hours_domain = [('project_id', 'in', self.ids), ('timesheet_invoice_type', '!=', False), ('so_line.state', '=', 'cancel')]
|
||||
total_canceled_hours = sum(self.env['account.analytic.line'].search(canceled_hours_domain).mapped('unit_amount'))
|
||||
canceled_hours = float_round(total_canceled_hours, precision_rounding=hour_rounding)
|
||||
if is_uom_day:
|
||||
# convert time from hours to days
|
||||
canceled_hours = round(uom_hour._compute_quantity(canceled_hours, company_uom, raise_if_failure=False), 2)
|
||||
dashboard_values['time']['canceled'] = canceled_hours
|
||||
dashboard_values['time']['total'] += canceled_hours
|
||||
|
||||
# hours (from timesheet) and rates (by billable type)
|
||||
dashboard_domain = [('project_id', 'in', self.ids), ('timesheet_invoice_type', '!=', False), '|', ('so_line', '=', False), ('so_line.state', '!=', 'cancel')] # force billable type
|
||||
dashboard_data = self.env['account.analytic.line'].read_group(dashboard_domain, ['unit_amount', 'timesheet_invoice_type'], ['timesheet_invoice_type'])
|
||||
dashboard_total_hours = sum([data['unit_amount'] for data in dashboard_data]) + total_canceled_hours
|
||||
for data in dashboard_data:
|
||||
billable_type = data['timesheet_invoice_type']
|
||||
amount = float_round(data.get('unit_amount'), precision_rounding=hour_rounding)
|
||||
if is_uom_day:
|
||||
# convert time from hours to days
|
||||
amount = round(uom_hour._compute_quantity(amount, company_uom, raise_if_failure=False), 2)
|
||||
dashboard_values['time'][billable_type] = amount
|
||||
dashboard_values['time']['total'] += amount
|
||||
# rates
|
||||
rate = round(data.get('unit_amount') / dashboard_total_hours * 100, 2) if dashboard_total_hours else 0.0
|
||||
dashboard_values['rates'][billable_type] = rate
|
||||
dashboard_values['rates']['total'] += rate
|
||||
dashboard_values['time']['total'] = round(dashboard_values['time']['total'], 2)
|
||||
|
||||
# rates from non-invoiced timesheets that are linked to canceled so
|
||||
dashboard_values['rates']['canceled'] = float_round(100 * total_canceled_hours / (dashboard_total_hours or 1), precision_rounding=hour_rounding)
|
||||
# profitability, using profitability SQL report
|
||||
field_map = {
|
||||
'amount_untaxed_invoiced': 'invoiced',
|
||||
'amount_untaxed_to_invoice': 'to_invoice',
|
||||
'timesheet_cost': 'cost',
|
||||
'expense_cost': 'expense_cost',
|
||||
'expense_amount_untaxed_invoiced': 'expense_amount_untaxed_invoiced',
|
||||
'other_revenues': 'other_revenues'
|
||||
}
|
||||
profit = dict.fromkeys(list(field_map.values()) + ['other_revenues', 'total'], 0.0)
|
||||
profitability_raw_data = self.env['project.profitability.report'].read_group([('project_id', 'in', self.ids)], ['project_id'] + list(field_map), ['project_id'])
|
||||
for data in profitability_raw_data:
|
||||
company_id = self.env['project.project'].browse(data.get('project_id')[0]).company_id
|
||||
from_currency = company_id.currency_id
|
||||
for field in field_map:
|
||||
value = data.get(field, 0.0)
|
||||
if from_currency != currency:
|
||||
value = from_currency._convert(value, currency, company_id, date.today())
|
||||
profit[field_map[field]] += value
|
||||
profit['total'] = sum([profit[item] for item in profit.keys()])
|
||||
dashboard_values['profit'] = profit
|
||||
|
||||
values['dashboard'] = dashboard_values
|
||||
|
||||
#
|
||||
# Time Repartition (per employee per billable types)
|
||||
#
|
||||
employee_ids = self._plan_get_employee_ids()
|
||||
employee_ids = list(set(employee_ids))
|
||||
# Retrieve the employees for which the current user can see theirs timesheets
|
||||
employee_domain = expression.AND([[('company_id', 'in', self.env.companies.ids)], self.env['account.analytic.line']._domain_employee_id()])
|
||||
employees = self.env['hr.employee'].sudo().browse(employee_ids).filtered_domain(employee_domain)
|
||||
repartition_domain = [('project_id', 'in', self.ids), ('employee_id', '!=', False), ('timesheet_invoice_type', '!=', False)] # force billable type
|
||||
# repartition data, without timesheet on cancelled so
|
||||
repartition_data = self.env['account.analytic.line'].read_group(repartition_domain + ['|', ('so_line', '=', False), ('so_line.state', '!=', 'cancel')], ['employee_id', 'timesheet_invoice_type', 'unit_amount'], ['employee_id', 'timesheet_invoice_type'], lazy=False)
|
||||
# read timesheet on cancelled so
|
||||
cancelled_so_timesheet = self.env['account.analytic.line'].read_group(repartition_domain + [('so_line.state', '=', 'cancel')], ['employee_id', 'unit_amount'], ['employee_id'], lazy=False)
|
||||
repartition_data += [{**canceled, 'timesheet_invoice_type': 'canceled'} for canceled in cancelled_so_timesheet]
|
||||
|
||||
# set repartition per type per employee
|
||||
repartition_employee = {}
|
||||
for employee in employees:
|
||||
repartition_employee[employee.id] = dict(
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
non_billable_project=0.0,
|
||||
non_billable=0.0,
|
||||
billable_time=0.0,
|
||||
non_billable_timesheet=0.0,
|
||||
billable_fixed=0.0,
|
||||
canceled=0.0,
|
||||
total=0.0,
|
||||
)
|
||||
for data in repartition_data:
|
||||
employee_id = data['employee_id'][0]
|
||||
repartition_employee.setdefault(employee_id, dict(
|
||||
employee_id=data['employee_id'][0],
|
||||
employee_name=data['employee_id'][1],
|
||||
non_billable_project=0.0,
|
||||
non_billable=0.0,
|
||||
billable_time=0.0,
|
||||
non_billable_timesheet=0.0,
|
||||
billable_fixed=0.0,
|
||||
canceled=0.0,
|
||||
total=0.0,
|
||||
))[data['timesheet_invoice_type']] = float_round(data.get('unit_amount', 0.0), precision_rounding=hour_rounding)
|
||||
repartition_employee[employee_id]['__domain_' + data['timesheet_invoice_type']] = data['__domain']
|
||||
# compute total
|
||||
for employee_id, vals in repartition_employee.items():
|
||||
repartition_employee[employee_id]['total'] = sum([vals[inv_type] for inv_type in [*billable_types, 'canceled']])
|
||||
if is_uom_day:
|
||||
# convert all times from hours to days
|
||||
for time_type in ['non_billable_project', 'non_billable', 'billable_time', 'non_billable_timesheet', 'billable_fixed', 'canceled', 'total']:
|
||||
if repartition_employee[employee_id][time_type]:
|
||||
repartition_employee[employee_id][time_type] = round(uom_hour._compute_quantity(repartition_employee[employee_id][time_type], company_uom, raise_if_failure=False), 2)
|
||||
hours_per_employee = [repartition_employee[employee_id]['total'] for employee_id in repartition_employee]
|
||||
values['repartition_employee_max'] = (max(hours_per_employee) if hours_per_employee else 1) or 1
|
||||
values['repartition_employee'] = repartition_employee
|
||||
|
||||
#
|
||||
# Table grouped by SO / SOL / Employees
|
||||
#
|
||||
timesheet_forecast_table_rows = self._table_get_line_values(employees)
|
||||
if timesheet_forecast_table_rows:
|
||||
values['timesheet_forecast_table'] = timesheet_forecast_table_rows
|
||||
return values
|
||||
|
||||
def _table_get_line_values(self, employees=None):
|
||||
""" return the header and the rows informations of the table """
|
||||
if not self:
|
||||
return False
|
||||
|
||||
uom_hour = self.env.ref('uom.product_uom_hour')
|
||||
company_uom = self.env.company.timesheet_encode_uom_id
|
||||
is_uom_day = company_uom and company_uom == self.env.ref('uom.product_uom_day')
|
||||
|
||||
# build SQL query and fetch raw data
|
||||
query, query_params = self._table_rows_sql_query()
|
||||
self.env.cr.execute(query, query_params)
|
||||
raw_data = self.env.cr.dictfetchall()
|
||||
rows_employee = self._table_rows_get_employee_lines(raw_data)
|
||||
default_row_vals = self._table_row_default()
|
||||
|
||||
empty_line_ids, empty_order_ids = self._table_get_empty_so_lines()
|
||||
|
||||
# extract row labels
|
||||
sale_line_ids = set()
|
||||
sale_order_ids = set()
|
||||
for key_tuple, row in rows_employee.items():
|
||||
if row[0]['sale_line_id']:
|
||||
sale_line_ids.add(row[0]['sale_line_id'])
|
||||
if row[0]['sale_order_id']:
|
||||
sale_order_ids.add(row[0]['sale_order_id'])
|
||||
|
||||
sale_orders = self.env['sale.order'].sudo().browse(sale_order_ids | empty_order_ids)
|
||||
sale_order_lines = self.env['sale.order.line'].sudo().browse(sale_line_ids | empty_line_ids)
|
||||
map_so_names = {so.id: so.name for so in sale_orders}
|
||||
map_so_cancel = {so.id: so.state == 'cancel' for so in sale_orders}
|
||||
map_sol = {sol.id: sol for sol in sale_order_lines}
|
||||
map_sol_names = {sol.id: sol.name.split('\n')[0] if sol.name else _('No Sales Order Line') for sol in sale_order_lines}
|
||||
map_sol_so = {sol.id: sol.order_id.id for sol in sale_order_lines}
|
||||
|
||||
rows_sale_line = {} # (so, sol) -> [INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted]
|
||||
for sale_line_id in empty_line_ids: # add service SO line having no timesheet
|
||||
sale_line_row_key = (map_sol_so.get(sale_line_id), sale_line_id)
|
||||
sale_line = map_sol.get(sale_line_id)
|
||||
is_milestone = sale_line.product_id.invoice_policy == 'delivery' and sale_line.product_id.service_type == 'manual' if sale_line else False
|
||||
rows_sale_line[sale_line_row_key] = [{'label': map_sol_names.get(sale_line_id, _('No Sales Order Line')), 'res_id': sale_line_id, 'res_model': 'sale.order.line', 'type': 'sale_order_line', 'is_milestone': is_milestone}] + default_row_vals[:]
|
||||
if not is_milestone:
|
||||
rows_sale_line[sale_line_row_key][-2] = sale_line.product_uom._compute_quantity(sale_line.product_uom_qty, uom_hour, raise_if_failure=False) if sale_line else 0.0
|
||||
|
||||
rows_sale_line_all_data = {}
|
||||
if not employees:
|
||||
employees = self.env['hr.employee'].sudo().search(self.env['account.analytic.line']._domain_employee_id())
|
||||
for row_key, row_employee in rows_employee.items():
|
||||
sale_order_id, sale_line_id, employee_id = row_key
|
||||
# sale line row
|
||||
sale_line_row_key = (sale_order_id, sale_line_id)
|
||||
if sale_line_row_key not in rows_sale_line:
|
||||
sale_line = map_sol.get(sale_line_id, self.env['sale.order.line'])
|
||||
is_milestone = sale_line.product_id.invoice_policy == 'delivery' and sale_line.product_id.service_type == 'manual' if sale_line else False
|
||||
rows_sale_line[sale_line_row_key] = [{'label': map_sol_names.get(sale_line.id) if sale_line else _('No Sales Order Line'), 'res_id': sale_line_id, 'res_model': 'sale.order.line', 'type': 'sale_order_line', 'is_milestone': is_milestone}] + default_row_vals[:] # INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted
|
||||
if not is_milestone:
|
||||
rows_sale_line[sale_line_row_key][-2] = sale_line.product_uom._compute_quantity(sale_line.product_uom_qty, uom_hour, raise_if_failure=False) if sale_line else 0.0
|
||||
|
||||
if sale_line_row_key not in rows_sale_line_all_data:
|
||||
rows_sale_line_all_data[sale_line_row_key] = [0] * len(row_employee)
|
||||
for index in range(1, len(row_employee)):
|
||||
if employee_id in employees.ids:
|
||||
rows_sale_line[sale_line_row_key][index] += row_employee[index]
|
||||
rows_sale_line_all_data[sale_line_row_key][index] += row_employee[index]
|
||||
if not rows_sale_line[sale_line_row_key][0].get('is_milestone'):
|
||||
rows_sale_line[sale_line_row_key][-1] = rows_sale_line[sale_line_row_key][-2] - rows_sale_line_all_data[sale_line_row_key][5]
|
||||
else:
|
||||
rows_sale_line[sale_line_row_key][-1] = 0
|
||||
|
||||
rows_sale_order = {} # so -> [INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted]
|
||||
for row_key, row_sale_line in rows_sale_line.items():
|
||||
sale_order_id = row_key[0]
|
||||
# sale order row
|
||||
if sale_order_id not in rows_sale_order:
|
||||
rows_sale_order[sale_order_id] = [{'label': map_so_names.get(sale_order_id, _('No Sales Order')), 'canceled': map_so_cancel.get(sale_order_id, False), 'res_id': sale_order_id, 'res_model': 'sale.order', 'type': 'sale_order'}] + default_row_vals[:] # INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted
|
||||
|
||||
for index in range(1, len(row_sale_line)):
|
||||
rows_sale_order[sale_order_id][index] += row_sale_line[index]
|
||||
|
||||
# group rows SO, SOL and their related employee rows.
|
||||
timesheet_forecast_table_rows = []
|
||||
for sale_order_id, sale_order_row in rows_sale_order.items():
|
||||
timesheet_forecast_table_rows.append(sale_order_row)
|
||||
for sale_line_row_key, sale_line_row in rows_sale_line.items():
|
||||
if sale_order_id == sale_line_row_key[0]:
|
||||
sale_order_row[0]['has_children'] = True
|
||||
timesheet_forecast_table_rows.append(sale_line_row)
|
||||
for employee_row_key, employee_row in rows_employee.items():
|
||||
if sale_order_id == employee_row_key[0] and sale_line_row_key[1] == employee_row_key[1] and employee_row_key[2] in employees.ids:
|
||||
sale_line_row[0]['has_children'] = True
|
||||
timesheet_forecast_table_rows.append(employee_row)
|
||||
|
||||
if is_uom_day:
|
||||
# convert all values from hours to days
|
||||
for row in timesheet_forecast_table_rows:
|
||||
for index in range(1, len(row)):
|
||||
row[index] = round(uom_hour._compute_quantity(row[index], company_uom, raise_if_failure=False), 2)
|
||||
# complete table data
|
||||
return {
|
||||
'header': self._table_header(),
|
||||
'rows': timesheet_forecast_table_rows
|
||||
}
|
||||
def _table_header(self):
|
||||
initial_date = fields.Date.from_string(fields.Date.today())
|
||||
ts_months = sorted([fields.Date.to_string(initial_date - relativedelta(months=i, day=1)) for i in range(0, DEFAULT_MONTH_RANGE)]) # M1, M2, M3
|
||||
|
||||
def _to_short_month_name(date):
|
||||
month_index = fields.Date.from_string(date).month
|
||||
return babel.dates.get_month_names('abbreviated', locale=get_lang(self.env).code)[month_index]
|
||||
|
||||
header_names = [_('Sales Order'), _('Before')] + [_to_short_month_name(date) for date in ts_months] + [_('Total'), _('Sold'), _('Remaining')]
|
||||
|
||||
result = []
|
||||
for name in header_names:
|
||||
result.append({
|
||||
'label': name,
|
||||
'tooltip': '',
|
||||
})
|
||||
# add tooltip for reminaing
|
||||
result[-1]['tooltip'] = _('What is still to deliver based on sold hours and hours already done. Equals to sold hours - done hours.')
|
||||
return result
|
||||
|
||||
def _table_row_default(self):
|
||||
lenght = len(self._table_header())
|
||||
return [0.0] * (lenght - 1) # before, M1, M2, M3, Done, Sold, Remaining
|
||||
|
||||
def _table_rows_sql_query(self):
|
||||
initial_date = fields.Date.from_string(fields.Date.today())
|
||||
ts_months = sorted([fields.Date.to_string(initial_date - relativedelta(months=i, day=1)) for i in range(0, DEFAULT_MONTH_RANGE)]) # M1, M2, M3
|
||||
# build query
|
||||
query = """
|
||||
SELECT
|
||||
'timesheet' AS type,
|
||||
date_trunc('month', date)::date AS month_date,
|
||||
E.id AS employee_id,
|
||||
S.order_id AS sale_order_id,
|
||||
A.so_line AS sale_line_id,
|
||||
SUM(A.unit_amount) AS number_hours
|
||||
FROM account_analytic_line A
|
||||
JOIN hr_employee E ON E.id = A.employee_id
|
||||
LEFT JOIN sale_order_line S ON S.id = A.so_line
|
||||
WHERE A.project_id IS NOT NULL
|
||||
AND A.project_id IN %s
|
||||
AND A.date < %s
|
||||
GROUP BY date_trunc('month', date)::date, S.order_id, A.so_line, E.id
|
||||
"""
|
||||
|
||||
last_ts_month = fields.Date.to_string(fields.Date.from_string(ts_months[-1]) + relativedelta(months=1))
|
||||
query_params = (tuple(self.ids), last_ts_month)
|
||||
return query, query_params
|
||||
|
||||
def _table_rows_get_employee_lines(self, data_from_db):
|
||||
initial_date = fields.Date.today()
|
||||
ts_months = sorted([initial_date - relativedelta(months=i, day=1) for i in range(0, DEFAULT_MONTH_RANGE)]) # M1, M2, M3
|
||||
default_row_vals = self._table_row_default()
|
||||
|
||||
# extract employee names
|
||||
employee_ids = set()
|
||||
for data in data_from_db:
|
||||
employee_ids.add(data['employee_id'])
|
||||
map_empl_names = {empl.id: empl.name for empl in self.env['hr.employee'].sudo().browse(employee_ids)}
|
||||
|
||||
# extract rows data for employee, sol and so rows
|
||||
rows_employee = {} # (so, sol, employee) -> [INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted]
|
||||
for data in data_from_db:
|
||||
sale_line_id = data['sale_line_id']
|
||||
sale_order_id = data['sale_order_id']
|
||||
# employee row
|
||||
row_key = (data['sale_order_id'], sale_line_id, data['employee_id'])
|
||||
if row_key not in rows_employee:
|
||||
meta_vals = {
|
||||
'label': map_empl_names.get(row_key[2]),
|
||||
'sale_line_id': sale_line_id,
|
||||
'sale_order_id': sale_order_id,
|
||||
'res_id': row_key[2],
|
||||
'res_model': 'hr.employee',
|
||||
'type': 'hr_employee'
|
||||
}
|
||||
rows_employee[row_key] = [meta_vals] + default_row_vals[:] # INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted
|
||||
|
||||
index = False
|
||||
if data['type'] == 'timesheet':
|
||||
if data['month_date'] in ts_months:
|
||||
index = ts_months.index(data['month_date']) + 2
|
||||
elif data['month_date'] < ts_months[0]:
|
||||
index = 1
|
||||
rows_employee[row_key][index] += data['number_hours']
|
||||
rows_employee[row_key][5] += data['number_hours']
|
||||
return rows_employee
|
||||
|
||||
def _table_get_empty_so_lines(self):
|
||||
""" get the Sale Order Lines having no timesheet but having generated a task or a project """
|
||||
so_lines = self.sudo().mapped('sale_line_id.order_id.order_line').filtered(lambda sol: sol.is_service and not sol.is_expense and not sol.is_downpayment)
|
||||
# include the service SO line of SO sharing the same project
|
||||
sale_order = self.env['sale.order'].search([('project_id', 'in', self.ids)])
|
||||
return set(so_lines.ids) | set(sale_order.mapped('order_line').filtered(lambda sol: sol.is_service and not sol.is_expense).ids), set(so_lines.mapped('order_id').ids) | set(sale_order.ids)
|
||||
|
||||
# --------------------------------------------------
|
||||
# Actions: Stat buttons, ...
|
||||
# --------------------------------------------------
|
||||
|
||||
def _plan_prepare_actions(self, values):
|
||||
actions = []
|
||||
if len(self) == 1:
|
||||
task_order_line_ids = []
|
||||
# retrieve all the sale order line that we will need later below
|
||||
if self.env.user.has_group('sales_team.group_sale_salesman') or self.env.user.has_group('sales_team.group_sale_salesman_all_leads'):
|
||||
task_order_line_ids = self.env['project.task'].read_group([('project_id', '=', self.id), ('sale_line_id', '!=', False)], ['sale_line_id'], ['sale_line_id'])
|
||||
task_order_line_ids = [ol['sale_line_id'][0] for ol in task_order_line_ids]
|
||||
|
||||
if self.env.user.has_group('sales_team.group_sale_salesman'):
|
||||
if self.pricing_type != 'task_rate' and self.allow_billable and not self.sale_order_id:
|
||||
actions.append({
|
||||
'label': _("Create a Sales Order"),
|
||||
'type': 'action',
|
||||
'action_id': 'sale_timesheet.project_project_action_multi_create_sale_order',
|
||||
'context': json.dumps({'active_id': self.id, 'active_model': 'project.project'}),
|
||||
})
|
||||
if self.env.user.has_group('sales_team.group_sale_salesman_all_leads'):
|
||||
to_invoice_amount = values['dashboard']['profit'].get('to_invoice', False) # plan project only takes services SO line with timesheet into account
|
||||
|
||||
sale_order_ids = self.env['sale.order.line'].read_group([('id', 'in', task_order_line_ids)], ['order_id'], ['order_id'])
|
||||
sale_order_ids = [s['order_id'][0] for s in sale_order_ids]
|
||||
sale_order_ids = self.env['sale.order'].search_read([('id', 'in', sale_order_ids), ('invoice_status', '=', 'to invoice')], ['id'])
|
||||
sale_order_ids = list(map(lambda x: x['id'], sale_order_ids))
|
||||
|
||||
if to_invoice_amount and sale_order_ids:
|
||||
if len(sale_order_ids) == 1:
|
||||
actions.append({
|
||||
'label': _("Create Invoice"),
|
||||
'type': 'action',
|
||||
'action_id': 'sale.action_view_sale_advance_payment_inv',
|
||||
'context': json.dumps({'active_ids': sale_order_ids, 'active_model': 'project.project'}),
|
||||
})
|
||||
else:
|
||||
actions.append({
|
||||
'label': _("Create Invoice"),
|
||||
'type': 'action',
|
||||
'action_id': 'sale_timesheet.project_project_action_multi_create_invoice',
|
||||
'context': json.dumps({'active_id': self.id, 'active_model': 'project.project'}),
|
||||
})
|
||||
return actions
|
||||
|
||||
def _plan_get_stat_button(self):
|
||||
stat_buttons = []
|
||||
num_projects = len(self)
|
||||
if num_projects == 1:
|
||||
action_data = _to_action_data('project.project', res_id=self.id,
|
||||
views=[[self.env.ref('project.edit_project').id, 'form']])
|
||||
else:
|
||||
action_data = _to_action_data(action=self.env.ref('project.open_view_project_all_config').sudo(),
|
||||
domain=[('id', 'in', self.ids)])
|
||||
|
||||
stat_buttons.append({
|
||||
'name': _('Project') if num_projects == 1 else _('Projects'),
|
||||
'count': num_projects,
|
||||
'icon': 'fa fa-puzzle-piece',
|
||||
'action': action_data
|
||||
})
|
||||
|
||||
# if only one project, add it in the context as default value
|
||||
tasks_domain = [('project_id', 'in', self.ids)]
|
||||
tasks_context = self.env.context.copy()
|
||||
tasks_context.pop('search_default_name', False)
|
||||
late_tasks_domain = [('project_id', 'in', self.ids), ('date_deadline', '<', fields.Date.to_string(fields.Date.today())), ('date_end', '=', False)]
|
||||
overtime_tasks_domain = [('project_id', 'in', self.ids), ('overtime', '>', 0), ('planned_hours', '>', 0)]
|
||||
|
||||
if len(self) == 1:
|
||||
tasks_context = {**tasks_context, 'default_project_id': self.id}
|
||||
elif len(self):
|
||||
task_projects_ids = self.env['project.task'].read_group([('project_id', 'in', self.ids)], ['project_id'], ['project_id'])
|
||||
task_projects_ids = [p['project_id'][0] for p in task_projects_ids]
|
||||
if len(task_projects_ids) == 1:
|
||||
tasks_context = {**tasks_context, 'default_project_id': task_projects_ids[0]}
|
||||
|
||||
stat_buttons.append({
|
||||
'name': _('Tasks'),
|
||||
'count': sum(self.mapped('task_count')),
|
||||
'icon': 'fa fa-tasks',
|
||||
'action': _to_action_data(
|
||||
action=self.env.ref('project.action_view_task').sudo(),
|
||||
domain=tasks_domain,
|
||||
context=tasks_context
|
||||
)
|
||||
})
|
||||
stat_buttons.append({
|
||||
'name': [_("Tasks"), _("Late")],
|
||||
'count': self.env['project.task'].search_count(late_tasks_domain),
|
||||
'icon': 'fa fa-tasks',
|
||||
'action': _to_action_data(
|
||||
action=self.env.ref('project.action_view_task').sudo(),
|
||||
domain=late_tasks_domain,
|
||||
context=tasks_context,
|
||||
),
|
||||
})
|
||||
stat_buttons.append({
|
||||
'name': [_("Tasks"), _("in Overtime")],
|
||||
'count': self.env['project.task'].search_count(overtime_tasks_domain),
|
||||
'icon': 'fa fa-tasks',
|
||||
'action': _to_action_data(
|
||||
action=self.env.ref('project.action_view_task').sudo(),
|
||||
domain=overtime_tasks_domain,
|
||||
context=tasks_context,
|
||||
),
|
||||
})
|
||||
|
||||
if self.env.user.has_group('sales_team.group_sale_salesman_all_leads'):
|
||||
# read all the sale orders linked to the projects' tasks
|
||||
task_so_ids = self.env['project.task'].search_read([
|
||||
('project_id', 'in', self.ids), ('sale_order_id', '!=', False)
|
||||
], ['sale_order_id'])
|
||||
task_so_ids = [o['sale_order_id'][0] for o in task_so_ids]
|
||||
|
||||
sale_orders = self.mapped('sale_line_id.order_id') | self.env['sale.order'].browse(task_so_ids)
|
||||
if sale_orders:
|
||||
so_action = dict(
|
||||
context={'create': False, 'edit': False, 'delete': False},
|
||||
domain=[('id', 'in', sale_orders.ids)],
|
||||
)
|
||||
|
||||
if len(sale_orders) == 1:
|
||||
so_action.update({
|
||||
'action': self.env.ref('sale.action_sale_order_form_view').sudo(),
|
||||
'res_id': sale_orders.id,
|
||||
})
|
||||
else:
|
||||
so_action['action'] = self.env.ref('sale.action_orders').sudo()
|
||||
|
||||
stat_buttons.append({
|
||||
'name': _('Sales Orders'),
|
||||
'count': len(sale_orders),
|
||||
'icon': 'fa fa-dollar',
|
||||
'action': _to_action_data(**so_action),
|
||||
})
|
||||
|
||||
invoice_ids = self.env['sale.order'].search_read([('id', 'in', sale_orders.ids)], ['invoice_ids'])
|
||||
invoice_ids = list(itertools.chain(*[i['invoice_ids'] for i in invoice_ids]))
|
||||
invoice_ids = self.env['account.move'].search_read([('id', 'in', invoice_ids), ('move_type', '=', 'out_invoice')], ['id'])
|
||||
invoice_ids = list(map(lambda x: x['id'], invoice_ids))
|
||||
|
||||
if invoice_ids:
|
||||
stat_buttons.append({
|
||||
'name': _('Invoices'),
|
||||
'count': len(invoice_ids),
|
||||
'icon': 'fa fa-pencil-square-o',
|
||||
'action': _to_action_data(
|
||||
action=self.env.ref('account.action_move_out_invoice_type').sudo(),
|
||||
domain=[('id', 'in', invoice_ids), ('move_type', '=', 'out_invoice')],
|
||||
context={'create': False, 'delete': False}
|
||||
)
|
||||
})
|
||||
|
||||
ts_tree = self.env.ref('hr_timesheet.hr_timesheet_line_tree')
|
||||
ts_form = self.env.ref('hr_timesheet.hr_timesheet_line_form')
|
||||
if self.env.company.timesheet_encode_uom_id == self.env.ref('uom.product_uom_day'):
|
||||
timesheet_label = [_('Days'), _('Recorded')]
|
||||
else:
|
||||
timesheet_label = [_('Hours'), _('Recorded')]
|
||||
|
||||
default_project_ctx = {}
|
||||
if len(self) == 1:
|
||||
default_project_ctx = {'default_project_id': self.id}
|
||||
|
||||
stat_buttons.append({
|
||||
'name': timesheet_label,
|
||||
'count': sum(self.mapped('total_timesheet_time')),
|
||||
'icon': 'fa fa-calendar',
|
||||
'action': _to_action_data(
|
||||
'account.analytic.line',
|
||||
domain=[('project_id', 'in', self.ids)],
|
||||
views=[(ts_tree.id, 'list'), (ts_form.id, 'form')],
|
||||
context=default_project_ctx,
|
||||
)
|
||||
})
|
||||
|
||||
return stat_buttons
|
||||
|
||||
|
||||
def _to_action_data(model=None, *, action=None, views=None, res_id=None, domain=None, context=None):
|
||||
# pass in either action or (model, views)
|
||||
if action:
|
||||
assert model is None and views is None
|
||||
act = clean_action(action.read()[0], env=action.env)
|
||||
model = act['res_model']
|
||||
views = act['views']
|
||||
# FIXME: search-view-id, possibly help?
|
||||
descr = {
|
||||
'data-model': model,
|
||||
'data-views': json.dumps(views),
|
||||
}
|
||||
if context is not None: # otherwise copy action's?
|
||||
descr['data-context'] = json.dumps(context)
|
||||
if res_id:
|
||||
descr['data-res-id'] = res_id
|
||||
elif domain:
|
||||
descr['data-domain'] = json.dumps(domain)
|
||||
return descr
|
||||
@@ -1,10 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from odoo import api, models
|
||||
from odoo.tools import float_utils, format_amount, formatLang
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.tools import float_utils, format_amount, formatLang, format_date, DEFAULT_SERVER_DATE_FORMAT
|
||||
from collections import defaultdict
|
||||
|
||||
class ProjectUpdate(models.Model):
|
||||
_inherit = 'project.update'
|
||||
@@ -13,55 +13,66 @@ class ProjectUpdate(models.Model):
|
||||
def _get_template_values(self, project):
|
||||
return {
|
||||
**super(ProjectUpdate, self)._get_template_values(project),
|
||||
'services': self._get_services_values(project),
|
||||
'profitability': self._get_profitability_values(project),
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_project_sols(self, project):
|
||||
return self.env['sale.order.line'].search([('order_id', '=', project.sale_order_id.id), ('is_service', '=', True)])
|
||||
|
||||
@api.model
|
||||
def _get_services_values(self, project):
|
||||
if not project.allow_billable:
|
||||
return {}
|
||||
services = []
|
||||
total_sold_per_uom = defaultdict(float)
|
||||
total_effective_per_uom = defaultdict(float)
|
||||
total_sold, total_effective, total_remaining = 0, 0, 0
|
||||
sols = self._get_project_sols(project)
|
||||
for sol in sols:
|
||||
#We only want to consider hours and days for this calculation
|
||||
if sol.product_uom.category_id == self.env.company.timesheet_encode_uom_id.category_id:
|
||||
services.append({
|
||||
'name': sol.name,
|
||||
'uom': sol.product_uom.name,
|
||||
'sold_value': sol.product_uom_qty,
|
||||
'effective_value': sol.qty_delivered,
|
||||
'remaining_value': sol.product_uom_qty - sol.qty_delivered,
|
||||
'sol': sol,
|
||||
})
|
||||
total_sold_per_uom[sol.product_uom] += sol.product_uom_qty
|
||||
total_effective_per_uom[sol.product_uom] += sol.qty_delivered
|
||||
total_sold = sum(
|
||||
uom._compute_quantity(sol.product_uom_qty, self.env.company.timesheet_encode_uom_id, raise_if_failure=False)
|
||||
for uom, quantity in total_sold_per_uom.items()
|
||||
)
|
||||
total_effective = sum(
|
||||
uom._compute_quantity(sol.product_uom_qty, self.env.company.timesheet_encode_uom_id, raise_if_failure=False)
|
||||
for uom, quantity in total_effective_per_uom.items()
|
||||
)
|
||||
total_remaining = total_sold - total_effective
|
||||
return {
|
||||
'data': services,
|
||||
'total_sold': total_sold,
|
||||
'total_effective': total_effective,
|
||||
'total_remaining': total_remaining,
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_profitability_values(self, project):
|
||||
costs_revenues = project.analytic_account_id and project.allow_billable
|
||||
timesheets = project.allow_timesheets and self.user_has_groups('hr_timesheet.group_hr_timesheet_user')
|
||||
if not (self.user_has_groups('project.group_project_manager') and (costs_revenues or timesheets)):
|
||||
return {}
|
||||
this_month = fields.Date.context_today(self) + relativedelta(day=1)
|
||||
previous_month = this_month + relativedelta(months=-1, day=1)
|
||||
result = {
|
||||
'allow_timesheets': timesheets,
|
||||
'allow_costs_and_revenues': costs_revenues,
|
||||
profitability = project._get_profitability_common()
|
||||
return {
|
||||
'analytic_account_id': project.analytic_account_id,
|
||||
'month': format_date(self.env, this_month, date_format='LLLL y'),
|
||||
'previous_month': format_date(self.env, previous_month, date_format='LLLL'),
|
||||
'is_timesheet_uom_hour': self.env.company._is_timesheet_hour_uom(),
|
||||
'timesheet_uom': self.env.company._timesheet_uom_text(),
|
||||
'timesheet_unit_amount': '', 'previous_timesheet_unit_amount': '',
|
||||
'timesheet_trend': '', 'costs': '', 'revenues': '', 'margin': '',
|
||||
'margin_formatted': '', 'margin_percentage': '', 'billing_rate': '',
|
||||
'costs': format_amount(self.env, -profitability['costs'], self.env.company.currency_id),
|
||||
'revenues': format_amount(self.env, profitability['revenues'], self.env.company.currency_id),
|
||||
'margin': profitability['margin'],
|
||||
'margin_formatted': format_amount(self.env, profitability['margin'], self.env.company.currency_id),
|
||||
'margin_percentage': formatLang(self.env,
|
||||
not float_utils.float_is_zero(profitability['costs'], precision_digits=2) and -(profitability['margin'] / profitability['costs']) * 100 or 0.0,
|
||||
digits=0),
|
||||
}
|
||||
if timesheets:
|
||||
timesheets_per_month = self.env['account.analytic.line'].read_group(
|
||||
[('project_id', '=', project.id),
|
||||
('date', '>=', previous_month)],
|
||||
['date', 'unit_amount'],
|
||||
['date:month'])
|
||||
timesheet_unit_amount = {ts['__range']['date']['from']: ts['unit_amount'] for ts in timesheets_per_month}
|
||||
this_amount = timesheet_unit_amount.get(this_month.replace(day=1).strftime(DEFAULT_SERVER_DATE_FORMAT), 0.0)
|
||||
previous_amount = timesheet_unit_amount.get(previous_month.replace(day=1).strftime(DEFAULT_SERVER_DATE_FORMAT), 0.0)
|
||||
result.update({
|
||||
'timesheet_unit_amount': formatLang(self.env, project._convert_project_uom_to_timesheet_encode_uom(this_amount), digits=0),
|
||||
'previous_timesheet_unit_amount': formatLang(self.env, project._convert_project_uom_to_timesheet_encode_uom(previous_amount), digits=0),
|
||||
'timesheet_trend': formatLang(self.env, previous_amount > 0 and ((this_amount / previous_amount) - 1) * 100 or 0.0, digits=0),
|
||||
})
|
||||
if costs_revenues:
|
||||
profitability = project._get_profitability_common(costs_revenues=True, timesheets=False)
|
||||
result.update({
|
||||
'costs': format_amount(self.env, -profitability['costs'], self.env.company.currency_id),
|
||||
'revenues': format_amount(self.env, profitability['revenues'], self.env.company.currency_id),
|
||||
'margin': profitability['margin'],
|
||||
'margin_formatted': format_amount(self.env, profitability['margin'], self.env.company.currency_id),
|
||||
'margin_percentage': formatLang(self.env,
|
||||
not float_utils.float_is_zero(profitability['costs'], precision_digits=2) and -(profitability['margin'] / profitability['costs']) * 100 or 0.0,
|
||||
digits=0),
|
||||
'billing_rate': formatLang(self.env,
|
||||
not float_utils.float_is_zero(profitability['costs'], precision_digits=2) and -(profitability['revenues'] / profitability['costs']) * 100 or 0.0,
|
||||
digits=0),
|
||||
})
|
||||
return result
|
||||
|
||||
@@ -94,15 +94,6 @@ class SaleOrder(models.Model):
|
||||
) >= 0
|
||||
)
|
||||
|
||||
def action_view_project_ids(self):
|
||||
self.ensure_one()
|
||||
# redirect to form or kanban view
|
||||
if len(self.project_ids) == 1 and self.project_ids.project_overview and self.env.user.has_group('project.group_project_manager'):
|
||||
action = self.project_ids.action_view_timesheet_plan()
|
||||
else:
|
||||
action = super().action_view_project_ids()
|
||||
return action
|
||||
|
||||
def action_view_timesheet(self):
|
||||
self.ensure_one()
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("sale_timesheet.timesheet_action_from_sales_order")
|
||||
|
||||
@@ -79,31 +79,4 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="project_profitability_report_action" model="ir.actions.act_window">
|
||||
<field name="name">Project Costs and Revenues</field>
|
||||
<field name="res_model">project.profitability.report</field>
|
||||
<field name="view_mode">pivot,graph</field>
|
||||
<field name="search_view_id" ref="project_profitability_report_view_search"/>
|
||||
<field name="context">{
|
||||
'pivot_row_groupby': ['line_date:month'],
|
||||
'group_by_no_leaf':1,
|
||||
'graph_groupbys': ['project_id'],
|
||||
'group_by':[],
|
||||
'sale_show_order_product_name': 1,
|
||||
}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_empty_folder">
|
||||
No data yet!
|
||||
</p><p>
|
||||
Analyze the profitability of your projects by comparing their costs and revenues.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_project_profitability_analysis"
|
||||
parent="project.menu_project_report"
|
||||
action="project_profitability_report_action"
|
||||
name="Project Costs and Revenues"
|
||||
sequence="50"/>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
odoo.define('sale_timesheet.project_overview', function (require) {
|
||||
"use strict";
|
||||
|
||||
var qweb = require('web.qweb');
|
||||
var viewRegistry = require('web.view_registry');
|
||||
|
||||
const Controller = qweb.Controller.extend({
|
||||
events: _.extend({}, qweb.Controller.prototype.events, {
|
||||
'click .project_overview_foldable': '_onFoldToggle',
|
||||
}),
|
||||
|
||||
_onFoldToggle(ev) {
|
||||
const {model, resId} = ev.target.dataset;
|
||||
const shouldOpen = ev.target.classList.contains('fa-caret-right');
|
||||
if (model === 'sale.order' && shouldOpen) {
|
||||
this._openSaleOrder(resId);
|
||||
}
|
||||
else if (model === 'sale.order.line' && shouldOpen) {
|
||||
this._openSaleOrderLine(resId);
|
||||
}
|
||||
else {
|
||||
const targetClass = `.${model.replace(/\./g, '_')}_${resId || 'None'}`;
|
||||
this.$(targetClass).hide();
|
||||
}
|
||||
$(ev.target).toggleClass('fa-caret-right');
|
||||
$(ev.target).toggleClass('fa-caret-down');
|
||||
},
|
||||
|
||||
_openSaleOrder(id) {
|
||||
id = id || 'None';
|
||||
const targetClass = `.sale_order_${id}`;
|
||||
this.$(`.o_timesheet_forecast_sale_order_line${targetClass}`).show();
|
||||
this.$(`.o_timesheet_forecast_hr_employee${targetClass}`).hide();
|
||||
this.$(`.o_timesheet_forecast_sale_order_line${targetClass} .fa`).removeClass('fa-caret-down');
|
||||
this.$(`.o_timesheet_forecast_sale_order_line${targetClass} .fa`).addClass('fa-caret-right');
|
||||
|
||||
},
|
||||
|
||||
_openSaleOrderLine(id) {
|
||||
id = id || 'None';
|
||||
const targetClass = `.sale_order_line_${id}`;
|
||||
this.$(`.o_timesheet_forecast_hr_employee${targetClass}`).show();
|
||||
},
|
||||
});
|
||||
|
||||
var ProjectOverview = qweb.View.extend({
|
||||
withSearchBar: true,
|
||||
searchMenuTypes: ['filter', 'favorite'],
|
||||
|
||||
config: _.extend({}, qweb.View.prototype.config, {
|
||||
Controller: Controller,
|
||||
}),
|
||||
});
|
||||
|
||||
viewRegistry.add('project_overview', ProjectOverview);
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
|
||||
.o_timesheet_plan_sale_timesheet {
|
||||
|
||||
.o_title {
|
||||
margin-top: 30px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
[type='action'] {
|
||||
color: $o-brand-primary;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.o_timesheet_plan_stat_buttons {
|
||||
text-align: right;
|
||||
float: none;
|
||||
width: auto;
|
||||
box-shadow: unset;
|
||||
margin-left: -$o-horizontal-padding - 1;
|
||||
@include media-breakpoint-between(lg, xl, $o-extra-grid-breakpoints) {
|
||||
margin-left: (-$o-horizontal-padding*2) - 1;
|
||||
}
|
||||
> .btn.oe_stat_button {
|
||||
border-bottom: 1px solid #ced4da;
|
||||
}
|
||||
}
|
||||
|
||||
.o_profitability_wrapper {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.o_profitability_section {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.o_timesheet_plan_sale_timesheet_dashboard {
|
||||
|
||||
.table {
|
||||
margin-top: 15px;
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.table > thead > tr > th, .table > thead > tr > td,
|
||||
.table > tbody > tr > th, .table > tbody > tr > td {
|
||||
border: none;
|
||||
}
|
||||
.table > tbody > tr > td.o_timesheet_plan_dashboard_cell {
|
||||
text-align: right;
|
||||
}
|
||||
.table > tbody > tr > td.o_timesheet_plan_dashboard_total {
|
||||
border-top: 1px solid;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.section {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.o_timesheet_plan_sale_timesheet_people_time {
|
||||
$-timesheet-progress-colors: (
|
||||
'billable_time': nth($o-colors-complete, 3),
|
||||
'billable_fixed': nth($o-colors-complete, 5),
|
||||
'non_billable_project': theme-color('primary'),
|
||||
'non_billable_timesheet': nth($o-colors-complete, 11),
|
||||
'non_billable': nth($o-colors-complete, 18),
|
||||
'canceled': $o-gray-500,
|
||||
);
|
||||
|
||||
@each $-key, $-bg in $-timesheet-progress-colors {
|
||||
.o_progress_#{$-key} {
|
||||
background-color: $-bg;
|
||||
|
||||
a {
|
||||
color: color-yiq($-bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table > thead > tr > td,
|
||||
.table > tbody > tr > th, .table > tbody > tr > td {
|
||||
border: none;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.table > thead > tr > th {
|
||||
border: none;
|
||||
padding-left: 0px;;
|
||||
}
|
||||
}
|
||||
|
||||
.o_project_plan_project_timesheet_forecast {
|
||||
|
||||
margin-top: 50px;
|
||||
|
||||
th{
|
||||
text-align: center;
|
||||
vertical-align: top;
|
||||
}
|
||||
th.o_right_bordered, td.o_right_bordered {
|
||||
border-right: 2px solid $o-brand-primary;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.o_timesheet_forecast_sale_order{
|
||||
font-weight: bold;
|
||||
& > td:first-child {
|
||||
color: $o-brand-primary;
|
||||
}
|
||||
}
|
||||
.o_timesheet_forecast_sale_order_line{
|
||||
td:first-child {
|
||||
padding-left: 15px;
|
||||
}
|
||||
td:first-child > a {
|
||||
margin-left: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.o_timesheet_forecast_hr_employee{
|
||||
td:first-child {
|
||||
padding-left: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.o_timesheet_plan_redirect {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.o_canceled_tag {
|
||||
background-color: $o-gray-500;
|
||||
color: color-yiq($o-gray-500);
|
||||
border: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,31 +6,69 @@
|
||||
</button>
|
||||
|
||||
<t t-inherit="project.ProjectRightPanel" t-inherit-mode="extension" owl="1">
|
||||
<xpath expr="//t[@t-call='project.TaskSection']" position="before">
|
||||
<xpath expr="//t[@t-call='project.StatButtonsSection']" position="after">
|
||||
<t t-call="sale_timesheet.SoldSection"/>
|
||||
<t t-call="sale_timesheet.TotalSoldSection"/>
|
||||
<t t-call="sale_timesheet.ProfitabilitySection"/>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
<t t-name="sale_timesheet.ProfitabilitySection" owl="1">
|
||||
<t t-if="state.data.user.is_project_user && state.data.profitability_items.data.length > 0">
|
||||
<t t-set="profitability_items" t-value="state.data.profitability_items"/>
|
||||
<div class="o_rightpanel_section" name="profitability">
|
||||
<t t-name="sale_timesheet.SoldSection" owl="1">
|
||||
<t t-set="sold_items" t-value="state.data.sold_items"/>
|
||||
<t t-if="sold_items.number_sols and sold_items.allow_billable">
|
||||
<div name="sold" class="o_rightpanel_section">
|
||||
<div class="o_rightpanel_header">
|
||||
<div class="o_rightpanel_title">
|
||||
<t t-if="profitability_items.allow_billable">
|
||||
Profitability
|
||||
</t>
|
||||
<t t-else="">
|
||||
Recorded Hours
|
||||
</t>
|
||||
Sold
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_rightpanel_data">
|
||||
<div t-foreach="sold_items.data" t-as="sold_item" t-key="sold_item.name" class="o_rightpanel_data_row d-flex justify-content-between">
|
||||
<span class="o_rightpanel_left_col o_rightpanel_left_text" t-attf-class="o_color_{{sold_item.color}}"><t t-esc="sold_item.name"/></span>
|
||||
<span t-attf-class="o_rightpanel_right_col o_color_{{sold_item.color}}"><b><t t-esc="sold_item.value"/></b></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="sale_timesheet.TotalSoldSection" owl="1">
|
||||
<t t-set="sold_items" t-value="state.data.sold_items"/>
|
||||
<t t-if="sold_items.allow_billable">
|
||||
<div name="total_sold" class="o_rightpanel_section">
|
||||
<div class="o_rightpanel_header">
|
||||
<div class="o_rightpanel_title">
|
||||
<span class="o_rightpanel_left_col o_rightpanel_left_text"> Total Sold </span>
|
||||
</div>
|
||||
<span class="o_rightpanel_right_col font-weight-bold float-right"><t t-esc="sold_items.total_sold"/> <t t-esc="sold_items.company_unit_name"/></span>
|
||||
</div>
|
||||
<div class="o_rightpanel_data">
|
||||
<div class="o_rightpanel_data_row">
|
||||
<span class="o_rightpanel_left_col o_rightpanel_left_text">Effective</span>
|
||||
<span class="o_rightpanel_right_col"><t t-esc="sold_items.effective_sold"/> <t t-esc="sold_items.company_unit_name"/></span>
|
||||
</div>
|
||||
<div class="o_rightpanel_data_row" t-if="sold_items.planned_sold > 0 and sold_items.allow_forecast">
|
||||
<span class="o_rightpanel_left_col o_rightpanel_left_text">Planned</span>
|
||||
<span class="o_rightpanel_right_col"><t t-esc="sold_items.planned_sold"/> <t t-esc="sold_items.company_unit_name"/></span>
|
||||
</div>
|
||||
<div class="o_rightpanel_data_row">
|
||||
<span class="o_rightpanel_left_col o_rightpanel_left_text">Remaining</span>
|
||||
<span class="o_rightpanel_right_col" t-attf-class="o_color_{{sold_items.remaining.color}}"><t t-esc="sold_items.remaining.value"/> <t t-esc="sold_items.company_unit_name"/></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="sale_timesheet.ProfitabilitySection" owl="1">
|
||||
<t t-if="state.data.user.is_project_user && state.data.profitability_items.data.length > 0 && state.data.analytic_account_id">
|
||||
<t t-set="profitability_items" t-value="state.data.profitability_items"/>
|
||||
<div name="profitability" class="o_rightpanel_section">
|
||||
<div class="o_rightpanel_header">
|
||||
<div class="o_rightpanel_title">
|
||||
Profitability
|
||||
</div>
|
||||
<span class="o_rightpanel_button" t-if="profitability_items.action">
|
||||
<a t-on-click="onProjectActionClick"
|
||||
t-att-data-action="profitability_items.action"
|
||||
data-type="object">
|
||||
See Overview
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="o_rightpanel_data">
|
||||
|
||||
@@ -247,29 +247,21 @@ tour.register('sale_timesheet_tour', {
|
||||
trigger: ".o_project_updates_breadcrumb",
|
||||
content: 'Open Updates'
|
||||
}, {
|
||||
trigger: ".o_rightpanel_button a:contains('See Overview')",
|
||||
content: 'See Overview'
|
||||
trigger: ".o_rightpanel_title:eq(0):contains('Sold')",
|
||||
content: 'Check the user sees Sold section'
|
||||
}, {
|
||||
trigger: ".breadcrumb :contains('Overview')",
|
||||
content: 'Check the user sees Overview'
|
||||
}, {
|
||||
trigger: ".o_back_button"
|
||||
},{
|
||||
trigger: ".o_rightpanel_left_text:eq(0):contains('Timesheets')",
|
||||
content: 'Check the user sees Timesheet stat',
|
||||
trigger: ".o_rightpanel_left_text:eq(0):contains('Prepaid Hours')",
|
||||
content: 'Check the user sees Sold subsection',
|
||||
run: function () {},
|
||||
}, {
|
||||
trigger: ".o_rightpanel_title:eq(1):contains('Total Sold')",
|
||||
content: 'Check the user sees Total Sold section'
|
||||
}, {
|
||||
trigger: ".o_rightpanel_left_text:eq(1):contains('Revenues')",
|
||||
content: 'Check the user sees Revenues stat',
|
||||
run: function () {},
|
||||
trigger: ".o_rightpanel_title:eq(2):contains('Profitability')",
|
||||
content: 'Check the user sees Profitability section'
|
||||
}, {
|
||||
trigger: ".o_rightpanel_left_text:eq(2):contains('Costs')",
|
||||
content: 'Check the user sees Cost stat',
|
||||
run: function () {},
|
||||
}, {
|
||||
trigger: ".o_rightpanel_left_text:eq(3):contains('Margin')",
|
||||
content: 'Check the user sees Margin stat',
|
||||
run: function () {},
|
||||
trigger: ".o_rightpanel_title:eq(3):contains('Milestones')",
|
||||
content: 'Check the user sees Milestones section'
|
||||
}, {
|
||||
trigger: ".o-kanban-button-new"
|
||||
}, {
|
||||
@@ -277,22 +269,6 @@ tour.register('sale_timesheet_tour', {
|
||||
run: 'text New update'
|
||||
}, {
|
||||
trigger: ".o_form_button_save"
|
||||
}, {
|
||||
trigger: ".o_field_widget[name=description] :contains('Profitability')",
|
||||
content: 'Check the user sees Profitability section',
|
||||
run: function () {},
|
||||
}, {
|
||||
trigger: ".o_field_widget[name=description] :contains('billing rate')",
|
||||
content: 'Check the user sees Profitability section content',
|
||||
run: function () {},
|
||||
}, {
|
||||
trigger: ".o_field_widget[name=description] :contains('People')",
|
||||
content: 'Check the user sees People section',
|
||||
run: function () {},
|
||||
}, {
|
||||
trigger: ".o_field_widget[name=description] :contains('(new this month)')",
|
||||
content: 'Check the user sees People section content',
|
||||
run: function () {},
|
||||
}]);
|
||||
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ from . import test_sale_service
|
||||
from . import test_project_billing
|
||||
from . import test_reinvoice
|
||||
from . import test_reporting
|
||||
from . import test_project_overview
|
||||
from . import test_project_billing_multicompany
|
||||
from . import test_upsell_warning
|
||||
from . import test_edit_so_line_timesheet
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.addons.sale_timesheet.tests.test_reporting import TestReporting
|
||||
from odoo.tools import float_compare
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install')
|
||||
class TestSaleProject(TestReporting):
|
||||
|
||||
def test_project_overview_by_project(self):
|
||||
rounding = self.env.company.currency_id.rounding
|
||||
|
||||
so_line_deliver_global_project = self.env['sale.order.line'].create({
|
||||
'name': self.product_delivery_timesheet2.name,
|
||||
'product_id': self.product_delivery_timesheet2.id,
|
||||
'product_uom_qty': 50,
|
||||
'product_uom': self.product_delivery_timesheet2.uom_id.id,
|
||||
'price_unit': self.product_delivery_timesheet2.list_price,
|
||||
'order_id': self.sale_order_2.id,
|
||||
})
|
||||
so_line_deliver_no_task = self.env['sale.order.line'].create({
|
||||
'name': self.product_delivery_manual1.name,
|
||||
'product_id': self.product_delivery_manual1.id,
|
||||
'product_uom_qty': 50,
|
||||
'product_uom': self.product_delivery_manual1.uom_id.id,
|
||||
'price_unit': self.product_delivery_manual1.list_price,
|
||||
'order_id': self.sale_order_2.id,
|
||||
})
|
||||
so_line_deliver_no_task.write({'qty_delivered': 1.0})
|
||||
|
||||
self.sale_order_2.action_confirm()
|
||||
project_so = self.so_line_order_project.project_id
|
||||
# log timesheet for billable time
|
||||
timesheet1 = self._log_timesheet_manager(project_so, 10, so_line_deliver_global_project.task_id)
|
||||
|
||||
task_so = self.so_line_order_project.task_id
|
||||
# logged some timesheets: on project only, then on tasks with different employees
|
||||
timesheet2 = self._log_timesheet_user(project_so, 2)
|
||||
timesheet3 = self._log_timesheet_user(project_so, 3, task_so)
|
||||
timesheet4 = self._log_timesheet_manager(project_so, 1, task_so)
|
||||
|
||||
# create a task which is not linked to sales order and fill non-billable timesheet
|
||||
task = self.env['project.task'].create({
|
||||
'name': 'Task',
|
||||
'project_id': project_so.id,
|
||||
'allow_billable': False,
|
||||
'sale_line_id': False
|
||||
})
|
||||
timesheet5 = self._log_timesheet_user(project_so, 5, task)
|
||||
|
||||
# invoice the Sales Order SO2
|
||||
context = {
|
||||
"active_model": 'sale.order',
|
||||
"active_ids": [self.sale_order_2.id],
|
||||
"active_id": self.sale_order_2.id,
|
||||
'open_invoices': True,
|
||||
}
|
||||
|
||||
payment = self.env['sale.advance.payment.inv'].create({
|
||||
'advance_payment_method': 'delivered',
|
||||
})
|
||||
|
||||
action_invoice = payment.with_context(context).create_invoices()
|
||||
invoice = self.env['account.move'].browse(action_invoice['res_id'])
|
||||
invoice.action_post()
|
||||
|
||||
# simulate the auto creation of the SO line for expense, like we confirm a vendor bill.
|
||||
so_line_expense = self.env['sale.order.line'].create({
|
||||
'name': self.product_expense.name,
|
||||
'product_id': self.product_expense.id,
|
||||
'product_uom_qty': 0.0,
|
||||
'product_uom': self.product_expense.uom_id.id,
|
||||
'price_unit': self.product_expense.list_price, # reinvoice at sales price
|
||||
'order_id': self.sale_order_2.id,
|
||||
'is_expense': True,
|
||||
})
|
||||
|
||||
expense = self.env['account.analytic.line'].create({
|
||||
'name': 'expense on project_so',
|
||||
'account_id': project_so.analytic_account_id.id,
|
||||
'so_line': so_line_expense.id,
|
||||
'employee_id': self.employee_user.id,
|
||||
'unit_amount': 4,
|
||||
'amount': 4 * self.product_expense.list_price * -1,
|
||||
'product_id': self.product_expense.id,
|
||||
'product_uom_id': self.product_expense.uom_id.id,
|
||||
})
|
||||
|
||||
other_revenues = self.env['account.analytic.line'].create({
|
||||
'name': 'pther revenues on project_so',
|
||||
'account_id': project_so.analytic_account_id.id,
|
||||
'employee_id': self.employee_user.id,
|
||||
'unit_amount': 1,
|
||||
'amount': self.product_expense.list_price,
|
||||
'product_id': self.product_expense.id,
|
||||
'product_uom_id': self.product_expense.uom_id.id,
|
||||
})
|
||||
|
||||
view_id = self.env.ref('sale_timesheet.project_timesheet_action_client_timesheet_plan').id
|
||||
vals = self.env['project.project']._qweb_prepare_qcontext(view_id, [['id', '=', project_so.id]])
|
||||
|
||||
dashboard_value = timesheet2.unit_amount + timesheet3.unit_amount + timesheet4.unit_amount + timesheet5.unit_amount + timesheet1.unit_amount
|
||||
project_so_timesheet_sold_unit = timesheet3.unit_amount + timesheet4.unit_amount
|
||||
project_rate_non_billable = timesheet5.unit_amount / dashboard_value * 100
|
||||
project_rate_non_billable_project = timesheet2.unit_amount / dashboard_value * 100
|
||||
project_rate_billable_time = timesheet1.unit_amount / dashboard_value * 100
|
||||
project_rate_billable_fixed = project_so_timesheet_sold_unit / dashboard_value * 100
|
||||
project_rate_total = project_rate_non_billable + project_rate_non_billable_project + project_rate_billable_time + project_rate_billable_fixed
|
||||
project_invoiced = self.so_line_order_project.price_unit * self.so_line_order_project.product_uom_qty * timesheet1.unit_amount
|
||||
project_timesheet_cost = timesheet2.amount + timesheet3.amount + timesheet4.amount + timesheet5.amount + timesheet1.amount
|
||||
project_other_revenues = invoice.invoice_line_ids.search([('product_id', '=', self.product_delivery_manual1.id)])
|
||||
|
||||
self.assertEqual(float_compare(vals['dashboard']['time']['non_billable'], timesheet5.unit_amount, precision_rounding=rounding), 0, "The hours non-billable should be the one from the SO2 line, as we are in ordered quantity")
|
||||
self.assertEqual(float_compare(vals['dashboard']['time']['non_billable_project'], timesheet2.unit_amount, precision_rounding=rounding), 0, "The hours non-billable-project should be the one from the SO2 line, as we are in ordered quantity")
|
||||
self.assertEqual(float_compare(vals['dashboard']['time']['billable_time'], timesheet1.unit_amount, precision_rounding=rounding), 0, "The hours billable-time should be the one from the SO2 line, as we are in ordered quantity")
|
||||
self.assertEqual(float_compare(vals['dashboard']['time']['billable_fixed'], project_so_timesheet_sold_unit, precision_rounding=rounding), 0, "The hours billable-fixed should be the one from the SO2 line, as we are in ordered quantity")
|
||||
self.assertEqual(float_compare(vals['dashboard']['time']['total'], dashboard_value, precision_rounding=rounding), 0, "The total hours should be the one from the SO2 line, as we are in ordered quantity")
|
||||
self.assertEqual(float_compare(vals['dashboard']['rates']['non_billable'], project_rate_non_billable, precision_rounding=rounding), 0, "The rate non-billable should be the one from the SO2 line, as we are in ordered quantity")
|
||||
self.assertEqual(float_compare(vals['dashboard']['rates']['non_billable_project'], project_rate_non_billable_project, precision_rounding=rounding), 0, "The rate non-billable-project should be the one from the SO2 line, as we are in ordered quantity")
|
||||
self.assertEqual(float_compare(vals['dashboard']['rates']['billable_time'], project_rate_billable_time, precision_rounding=rounding), 0, "The rate billable-time should be the one from the SO2 line, as we are in ordered quantity")
|
||||
self.assertEqual(float_compare(vals['dashboard']['rates']['billable_fixed'], project_rate_billable_fixed, precision_rounding=rounding), 0, "The rate billable-fixed should be the one from the SO2 line, as we are in ordered quantity")
|
||||
self.assertEqual(float_compare(vals['dashboard']['rates']['total'], project_rate_total, precision_rounding=rounding), 0, "The total rates should be the one from the SO2 line, as we are in ordered quantity")
|
||||
self.assertEqual(float_compare(vals['dashboard']['profit']['invoiced'], project_invoiced, precision_rounding=rounding), 0, "The amount invoiced should be the one from the SO2 line, as we are in ordered quantity")
|
||||
self.assertEqual(float_compare(vals['dashboard']['profit']['cost'], project_timesheet_cost, precision_rounding=rounding), 0, "The amount cost should be the one from the SO2 line, as we are in ordered quantity")
|
||||
self.assertEqual(float_compare(vals['dashboard']['profit']['expense_cost'], expense.amount, precision_rounding=rounding), 0, "The amount expense-cost should be the one from the SO2 line, as we are in ordered quantity")
|
||||
self.assertEqual(float_compare(vals['dashboard']['profit']['other_revenues'], other_revenues.amount + project_other_revenues.price_total, precision_rounding=rounding), 0, "The amount of the other revenues should be equal to the corresponding account move line and the one from the SO line")
|
||||
self.assertEqual(float_compare(vals['dashboard']['profit']['total'], project_invoiced + project_timesheet_cost + expense.amount + other_revenues.amount + project_other_revenues.price_total, precision_rounding=rounding), 0, "The total amount should be the sum of the SO2 line and the created other_revenues account analytic line")
|
||||
self.assertEqual(float_compare(vals['repartition_employee_max'], 11.0, precision_rounding=rounding), 0, "The amount of repartition-employee-max should be the one from SO2 line")
|
||||
@@ -1,124 +1,31 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from datetime import date
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from freezegun import freeze_time
|
||||
|
||||
from odoo import fields
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import Form
|
||||
from odoo.tools import format_date
|
||||
|
||||
from odoo.addons.project.tests.test_project_update_flow import TestProjectUpdate
|
||||
|
||||
@tagged('-at_install', 'post_install')
|
||||
class TestProjectUpdateSaleTimesheet(TestProjectUpdate):
|
||||
|
||||
def test_project_update_form_profitability(self):
|
||||
try:
|
||||
with Form(self.env['project.update'].with_context({'default_project_id': self.project_pigs.id})) as update_form:
|
||||
update_form.name = "Test"
|
||||
update_form.progress = 65
|
||||
update = update_form.save()
|
||||
except Exception as e:
|
||||
raise AssertionError("Error raised unexpectedly while filling the project update form ! Exception : " + e.args[0])
|
||||
|
||||
self.assertTrue("Profitability" in update.description, "The description should contain 'Profitability'.")
|
||||
|
||||
def test_project_update_description_profitability(self):
|
||||
self.project_pigs.allow_billable = True
|
||||
template_values = self.env['project.update']._get_template_values(self.project_pigs)
|
||||
today = fields.Date.context_today(self.project_pigs)
|
||||
self.assertEqual(template_values['profitability']['month'], format_date(self.env, today, date_format='LLLL y'),
|
||||
"The month used in the template should be well defined")
|
||||
self.assertEqual(template_values['profitability']['previous_month'], format_date(self.env, (today + relativedelta(months=-1)), date_format='LLLL'),
|
||||
"The previous month used in the template should be well defined")
|
||||
self.assertTrue(template_values['profitability']['is_timesheet_uom_hour'], "Default timesheet uom is hour.")
|
||||
self.assertEqual(template_values['profitability']['timesheet_uom'], "hours", "Default timesheet uom should be displayed as 'hours'")
|
||||
self.assertEqual(template_values['profitability']['timesheet_unit_amount'], '0', "Timesheet unit amount used in the template should be defined.")
|
||||
self.assertEqual(template_values['profitability']['previous_timesheet_unit_amount'], '0', "Previous Timesheet unit amount used in the template should be defined.")
|
||||
self.assertEqual(template_values['profitability']['timesheet_trend'], "0", "Timesheet trend used in the template should be well defined.")
|
||||
self.assertEqual(template_values['profitability']['costs'], "$\xa00.00", "Project costs used in the template should be well defined")
|
||||
self.assertEqual(template_values['profitability']['revenues'], "$\xa00.00", "Project revenues used in the template should be well defined")
|
||||
self.assertEqual(template_values['profitability']['margin'], 0, "Margin used in the template should be well defined")
|
||||
self.assertEqual(template_values['profitability']['margin_formatted'], "$\xa00.00", "Margin formatted used in the template should be well defined")
|
||||
self.assertEqual(template_values['profitability']['margin_percentage'], "0", "Margin percentage used in the template should be well defined")
|
||||
self.assertEqual(template_values['profitability']['billing_rate'], "0", "Billing rate used in the template should be well defined")
|
||||
|
||||
def test_project_update_panel_profitability_no_billable(self):
|
||||
panel_data = self.project_pigs.get_panel_data()
|
||||
self.assertEqual(len(panel_data['profitability_items']['data']), 1, "Panel data should contain 'profitability_items>data' entries")
|
||||
self.assertEqual(panel_data['profitability_items']['allow_billable'], False, "Project should not be billable by default")
|
||||
try:
|
||||
self.project_pigs.action_view_timesheet()
|
||||
except Exception as e:
|
||||
raise AssertionError("Error raised unexpectedly while calling the action defined in profitalities action panel data ! Exception : " + e.args[0])
|
||||
|
||||
def test_project_update_panel_profitability_no_timesheet(self):
|
||||
self.project_pigs.allow_billable = True
|
||||
panel_data = self.project_pigs.get_panel_data()
|
||||
|
||||
self.assertEqual(len(panel_data['profitability_items']['data']), 4, "Panel data should contain 'profitability_items>data' entries")
|
||||
self.assertEqual(panel_data['profitability_items']['action'], "action_view_timesheet", "Panel data should contain 'profitability_items>action' entry")
|
||||
|
||||
try:
|
||||
self.project_pigs.action_view_timesheet()
|
||||
except Exception as e:
|
||||
raise AssertionError("Error raised unexpectedly while calling the action defined in profitalities action panel data ! Exception : " + e.args[0])
|
||||
|
||||
def test_project_update_description_date(self):
|
||||
self.project_pigs.allow_timesheets = True
|
||||
self.project_pigs.allow_billable = True
|
||||
self.employee_user = self.env['hr.employee'].create({
|
||||
'name': 'Employee User',
|
||||
'timesheet_cost': 15,
|
||||
})
|
||||
self.env['account.analytic.line'].create({
|
||||
'project_id': self.project_pigs.id,
|
||||
'task_id': self.task_1.id,
|
||||
'name': 'my first timesheet',
|
||||
'unit_amount': 4,
|
||||
'employee_id': self.employee_user.id,
|
||||
'date': '2021-07-23'
|
||||
})
|
||||
self.env['account.analytic.line'].create({
|
||||
'project_id': self.project_pigs.id,
|
||||
'task_id': self.task_1.id,
|
||||
'name': 'my second timesheet',
|
||||
'unit_amount': 6,
|
||||
'employee_id': self.employee_user.id,
|
||||
'date': '2021-08-23'
|
||||
})
|
||||
with freeze_time('2021-08-24'):
|
||||
template_values = self.env['project.update']._get_template_values(self.project_pigs)
|
||||
self.assertEqual(template_values['profitability']['timesheet_unit_amount'], '6', "Timesheet unit amount used in the template should be defined.")
|
||||
self.assertEqual(template_values['profitability']['previous_timesheet_unit_amount'], '4', "Previous Timesheet unit amount used in the template should be defined.")
|
||||
|
||||
def test_project_update_description_date_lang(self):
|
||||
self.project_pigs.allow_timesheets = True
|
||||
self.project_pigs.allow_billable = True
|
||||
self.env['res.lang']._activate_lang('fr_FR')
|
||||
self.env.user.write({'lang': 'fr_FR'})
|
||||
self.employee_user = self.env['hr.employee'].create({
|
||||
'name': 'Employee User',
|
||||
'timesheet_cost': 15,
|
||||
})
|
||||
self.env['account.analytic.line'].create({
|
||||
'project_id': self.project_pigs.id,
|
||||
'task_id': self.task_1.id,
|
||||
'name': 'my first timesheet',
|
||||
'unit_amount': 4,
|
||||
'employee_id': self.employee_user.id,
|
||||
'date': '2021-07-23'
|
||||
})
|
||||
self.env['account.analytic.line'].create({
|
||||
'project_id': self.project_pigs.id,
|
||||
'task_id': self.task_1.id,
|
||||
'name': 'my second timesheet',
|
||||
'unit_amount': 6,
|
||||
'employee_id': self.employee_user.id,
|
||||
'date': '2021-08-23'
|
||||
})
|
||||
with freeze_time('2021-08-24'):
|
||||
template_values = self.env['project.update']._get_template_values(self.project_pigs)
|
||||
self.assertEqual(template_values['profitability']['timesheet_unit_amount'], '6', "Timesheet unit amount used in the template should be defined.")
|
||||
self.assertEqual(template_values['profitability']['previous_timesheet_unit_amount'], '4', "Previous Timesheet unit amount used in the template should be defined.")
|
||||
|
||||
@@ -95,7 +95,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
|
||||
'employee_id': self.employee_user.id,
|
||||
})
|
||||
self.assertEqual(so_line_ordered_project_only.qty_delivered, 0.0, 'Timesheet directly on project does not increase delivered quantity on so line')
|
||||
self.assertEqual(timesheet3.timesheet_invoice_type, 'non_billable_project', "Timesheets without task shoulbe be 'no project found'")
|
||||
self.assertEqual(timesheet3.timesheet_invoice_type, 'non_billable', "Timesheets without SO should be be 'non-billable'")
|
||||
self.assertFalse(timesheet3.timesheet_invoice_id, "The timesheet should not be linked to the invoice, since we are in ordered quantity")
|
||||
|
||||
# log timesheet on task in global project (higher than the initial ordrered qty)
|
||||
@@ -270,7 +270,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
|
||||
})
|
||||
self.assertTrue(float_is_zero(so_line_deliver_only_project.qty_delivered, precision_digits=2), "Timesheeting on project should not incremented the delivered quantity on the SO line")
|
||||
self.assertEqual(sale_order.invoice_status, 'to invoice', 'Sale Timesheet: "invoice on delivery" timesheets should have quantity to invoice')
|
||||
self.assertEqual(timesheet3.timesheet_invoice_type, 'non_billable_project', "Timesheets without task shoulbe be 'no project found'")
|
||||
self.assertEqual(timesheet3.timesheet_invoice_type, 'billable_time', "Timesheets with an amount > 0 should be 'billable time'")
|
||||
self.assertFalse(timesheet3.timesheet_invoice_id, "The timesheet3 should not be linked to the invoice yet")
|
||||
|
||||
# let's log some timesheets on the task (new task/new project)
|
||||
@@ -357,7 +357,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
|
||||
self.assertEqual(sale_order.invoice_status, 'no', 'Sale Timesheet: "invoice on delivery" should not need to be invoiced on so confirmation')
|
||||
|
||||
self.assertEqual(timesheet1.timesheet_invoice_type, 'billable_fixed', "Timesheets linked to SO line with ordered product shoulbe be billable fixed since it is a milestone")
|
||||
self.assertEqual(timesheet2.timesheet_invoice_type, 'non_billable_project', "Timesheets without task shoulbe be 'no project found'")
|
||||
self.assertEqual(timesheet2.timesheet_invoice_type, 'non_billable', "Timesheets without SO should be be 'non-billable'")
|
||||
self.assertFalse(timesheet1.timesheet_invoice_id, "The timesheet1 should not be linked to the invoice")
|
||||
self.assertFalse(timesheet2.timesheet_invoice_id, "The timesheet2 should not be linked to the invoice")
|
||||
|
||||
|
||||
@@ -1,486 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="timesheet_plan" model="ir.ui.view">
|
||||
<field name="name">Timesheet Plan</field>
|
||||
<field name="type">qweb</field>
|
||||
<field name="model">project.project</field>
|
||||
<field name="arch" type="xml">
|
||||
<qweb js_class="project_overview">
|
||||
<nav class="o_qweb_cp_buttons" t-if="actions">
|
||||
<button t-foreach="actions" t-as="action"
|
||||
type="action" class="btn btn-primary"
|
||||
t-att-name="action['action_id']"
|
||||
t-att-data-context="action.get('context')"
|
||||
>
|
||||
<t t-esc="action['label']"/>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="o_form_view o_form_readonly o_project_plan">
|
||||
<div class="o_form_sheet_bg">
|
||||
<div class="o_form_sheet o_timesheet_plan_content">
|
||||
<div class="o_timesheet_plan_sale_timesheet">
|
||||
<div class="o_timesheet_plan_sale_timesheet_dashboard">
|
||||
|
||||
<div class="o_timesheet_plan_stat_buttons oe_button_box o_not_full d-flex justify-content-end flex-row flex-wrap">
|
||||
<t t-foreach="stat_buttons" t-as="stat_button">
|
||||
<a class="btn oe_stat_button"
|
||||
type="action"
|
||||
t-att="stat_button['action']"
|
||||
>
|
||||
<div t-attf-class="fa fa-fw o_button_icon #{stat_button['icon']}" role="img" aria-label="Statistics" title="Statistics"></div>
|
||||
<div class="o_field_widget o_stat_info o_readonly_modifier" t-att-title="stat_button['name']">
|
||||
<t t-if="not isinstance(stat_button['name'], list)">
|
||||
<span class="o_stat_value" t-if="'count' in stat_button">
|
||||
<t t-esc="stat_button['count']"/>
|
||||
</span>
|
||||
<span class="o_stat_text">
|
||||
<t t-esc="stat_button['name']"/>
|
||||
</span>
|
||||
</t>
|
||||
<t t-if="isinstance(stat_button['name'], list)">
|
||||
<div class="oe_inline">
|
||||
<span class="o_stat_value mr-1">
|
||||
<t t-esc="stat_button.get('count')"/>
|
||||
</span>
|
||||
<span class="o_stat_value">
|
||||
<t t-esc="stat_button['name'][0]"/>
|
||||
</span>
|
||||
</div>
|
||||
<span class="o_stat_text">
|
||||
<t t-esc="stat_button['name'][1]"/>
|
||||
</span>
|
||||
</t>
|
||||
</div>
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<div class="o_title" name="profitability_title_block">
|
||||
<h2 t-if="is_uom_day">Recorded Days and Profitability</h2>
|
||||
<h2 t-else="">Recorded Hours and Profitability</h2>
|
||||
</div>
|
||||
|
||||
<t t-set="display_cost" t-value="dashboard['profit']['expense_cost'] != 0.0"/>
|
||||
<div class="o_profitability_wrapper">
|
||||
<div class="o_profitability_section">
|
||||
<div>
|
||||
<table class="table" name="table_hours_recorded">
|
||||
<tbody>
|
||||
<th>
|
||||
<a type="action" data-model="account.analytic.line" t-att-data-domain="json.dumps(timesheet_domain)"
|
||||
data-context='{"pivot_row_groupby": ["date:month"],"pivot_column_groupby": ["timesheet_invoice_type"], "pivot_measures": ["unit_amount"]}'
|
||||
data-views='[[0, "pivot"], [0, "list"]]' tabindex="-1">
|
||||
<span t-if="is_uom_day">Days recorded</span>
|
||||
<span t-else="">Hours recorded</span>
|
||||
</a>
|
||||
</th>
|
||||
<tr name="row_billed_on_timesheets">
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-if="is_uom_day" t-esc="dashboard['time']['billable_time']" t-options="{'widget': 'timesheet_uom'}"/>
|
||||
<t t-else="" t-esc="dashboard['time']['billable_time']" t-options="{'widget': 'float_time'}"/>
|
||||
</td>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
(<t t-esc="dashboard['rates']['billable_time']"/> %)
|
||||
</td>
|
||||
<td title="Includes the time logged into tasks for which you invoice based on timesheets on tasks.">
|
||||
Billed on Timesheets
|
||||
</td>
|
||||
</tr>
|
||||
<tr name="row_billed_fix_price">
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-if="is_uom_day" t-esc="dashboard['time']['billable_fixed']" t-options="{'widget': 'timesheet_uom'}"/>
|
||||
<t t-else="" t-esc="dashboard['time']['billable_fixed']" t-options="{'widget': 'float_time'}"/>
|
||||
</td>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
(<t t-esc="dashboard['rates']['billable_fixed']"/> %)
|
||||
</td>
|
||||
<td title="Includes the time logged into tasks for which you invoice based on ordered quantities or on milestones.">
|
||||
Billed at a Fixed price
|
||||
</td>
|
||||
</tr>
|
||||
<tr t-if="dashboard['time']['non_billable_project'] != 0" name="row_non_billable_tasks_timesheets">
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-if="is_uom_day" t-esc="dashboard['time']['non_billable_project']" t-options="{'widget': 'timesheet_uom'}"/>
|
||||
<t t-else="" t-esc="dashboard['time']['non_billable_project']" t-options="{'widget': 'float_time'}"/>
|
||||
</td>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
(<t t-esc="dashboard['rates']['non_billable_project']"/> %)
|
||||
</td>
|
||||
<td title="Includes the time logged from the Timesheet module that is linked to a project, but not to a task.">
|
||||
No task found
|
||||
</td>
|
||||
</tr>
|
||||
<tr name="row_non_billable_tasks">
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-if="is_uom_day" t-esc="dashboard['time']['non_billable']" t-options="{'widget': 'timesheet_uom'}"/>
|
||||
<t t-else="" t-esc="dashboard['time']['non_billable']" t-options="{'widget': 'float_time'}"/>
|
||||
</td>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
(<t t-esc="dashboard['rates']['non_billable']"/> %)
|
||||
</td>
|
||||
<td>
|
||||
<a type="action"
|
||||
data-model="project.task"
|
||||
data-views='[[false, "list"], [false, "form"]]'
|
||||
t-att-data-domain="json.dumps([['project_id', 'in', projects.ids], ['sale_line_id', '=', False]])"
|
||||
>
|
||||
<span class="btn-link"
|
||||
style="font-weight:normal;"
|
||||
title="Includes the time logged into a task which is not linked to any Sales Order.">
|
||||
Non Billable Tasks
|
||||
</span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr t-if="dashboard['time']['non_billable_timesheet'] > 0" name="row_non_billable_timesheets">
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-if="is_uom_day" t-esc="dashboard['time']['non_billable_timesheet']" t-options="{'widget': 'timesheet_uom'}"/>
|
||||
<t t-else="" t-esc="dashboard['time']['non_billable_timesheet']" t-options="{'widget': 'float_time'}"/>
|
||||
</td>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
(<t t-esc="dashboard['rates']['non_billable_timesheet']"/> %)
|
||||
</td>
|
||||
<td>
|
||||
<a type="action"
|
||||
data-model="account.analytic.line"
|
||||
data-views='[[false, "list"], [false, "form"]]'
|
||||
t-att-data-domain="json.dumps(timesheet_domain + [('timesheet_invoice_type','=','non_billable_timesheet')])"
|
||||
>
|
||||
<span class="btn-link"
|
||||
style="font-weight:normal;">
|
||||
Non Billable Timesheets
|
||||
</span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr t-if="dashboard['time']['canceled'] > 0" name="row_cancelled_timesheets">
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-if="is_uom_day" t-esc="dashboard['time']['canceled']" t-options="{'widget': 'timesheet_uom'}"/>
|
||||
<t t-else="" t-esc="dashboard['time']['canceled']" t-options="{'widget': 'float_time'}"/>
|
||||
</td>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
(<t t-esc="dashboard['rates']['canceled']"/> %)
|
||||
</td>
|
||||
<td title="Includes the time logged into a task which is linked to a cancelled Sales Order.">
|
||||
Cancelled
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="o_timesheet_plan_dashboard_total">
|
||||
<b>
|
||||
<t t-if="is_uom_day" t-esc="dashboard['time']['total']" t-options="{'widget': 'timesheet_uom'}"/>
|
||||
<t t-else="" t-esc="dashboard['time']['total']" t-options="{'widget': 'float_time'}"/>
|
||||
</b>
|
||||
</td>
|
||||
<td><b>Total</b></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_profitability_section">
|
||||
<div>
|
||||
<table class="table" name="table_profitability">
|
||||
<tbody>
|
||||
<th>
|
||||
<a type="action" data-model="project.profitability.report" t-att-data-domain="json.dumps(profitability_domain)" data-context="{'group_by_no_leaf':1, 'group_by':[], 'sale_show_order_product_name': 1}" data-views='[[0, "pivot"], [0, "graph"]]' tabindex="-1">Profitability</a>
|
||||
</th>
|
||||
<tr name="row_invoiced">
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['profit']['invoiced']" t-options='{"widget": "monetary", "display_currency": currency}'/>
|
||||
</td>
|
||||
<td title="Revenues linked to Timesheets already invoiced.">
|
||||
Invoiced
|
||||
</td>
|
||||
</tr>
|
||||
<tr name="row_to_invoice">
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['profit']['to_invoice']" t-options='{"widget": "monetary", "display_currency": currency}'/>
|
||||
</td>
|
||||
<td title="Revenues linked to Timesheets not yet invoiced.">
|
||||
To invoice
|
||||
</td>
|
||||
</tr>
|
||||
<tr t-if="dashboard['profit']['other_revenues'] > 0" name="row_other_revenue">
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['profit']['other_revenues']" t-options='{"widget": "monetary", "display_currency": currency}'/>
|
||||
</td>
|
||||
<td title="All revenues that are not from timesheets and that are linked to the analytic account of the project.">Other Revenues</td>
|
||||
</tr>
|
||||
<tr name="row_timesheet_costs">
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['profit']['cost']" t-options='{"widget": "monetary", "display_currency": currency}'/>
|
||||
</td>
|
||||
<td title="This cost is based on the "Timesheet cost" set in the HR Settings of your employees.">
|
||||
Timesheet costs
|
||||
</td>
|
||||
</tr>
|
||||
<tr t-if="display_cost" name="row_other_costs">
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['profit']['expense_cost']" t-options='{"widget": "monetary", "display_currency": currency}'/>
|
||||
</td>
|
||||
<td title="Any cost linked to the Analytic Account of the Project.">
|
||||
Other costs
|
||||
</td>
|
||||
</tr>
|
||||
<tr t-if="display_cost & (dashboard['profit']['expense_amount_untaxed_invoiced'] != 0)" name="row_reinvoiced_costs">
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['profit']['expense_amount_untaxed_invoiced']" t-options='{"widget": "monetary", "display_currency": currency}'/>
|
||||
</td>
|
||||
<td title="Costs from expenses that were reinvoiced to your customer (provided that the Analytic Account of the Project was set on the Expense).">
|
||||
Re-invoiced costs
|
||||
</td>
|
||||
</tr>
|
||||
<tr name="row_profitability_total">
|
||||
<td class="o_timesheet_plan_dashboard_total">
|
||||
<b>
|
||||
<t t-esc="dashboard['profit']['total']" t-options='{"widget": "monetary", "display_currency": currency}'/>
|
||||
</b>
|
||||
</td>
|
||||
<td><b>Total</b></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_title" name="time_by_people_title_block">
|
||||
<h2>Time by people</h2>
|
||||
</div>
|
||||
|
||||
<div class="o_timesheet_plan_sale_timesheet_people_time">
|
||||
<t t-if="not repartition_employee">
|
||||
<p>There are no timesheets for now.</p>
|
||||
</t>
|
||||
<t t-if="repartition_employee">
|
||||
<div class="table-responsive">
|
||||
<table class="table" name="table_time_overview">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Employee</th>
|
||||
<th t-if="is_uom_day" class="text-nowrap text-right pr-5">Days Spent</th>
|
||||
<th t-else="" class="text-nowrap text-right pr-5">Hours Spent</th>
|
||||
<td>
|
||||
<div class="float-right o_timesheet_plan_badge h3 mb-3">
|
||||
<span class="badge badge-pill border-0 px-3 py-2 o_progress_billable_time">
|
||||
<a type="action" data-model="account.analytic.line" t-att-data-domain="json.dumps(timesheet_domain + [('timesheet_invoice_type','=','billable_time')])" tabindex="-1">Billed on Timesheets</a>
|
||||
</span>
|
||||
<span class="badge badge-pill border-0 px-3 py-2 o_progress_billable_fixed">
|
||||
<a type="action" data-model="account.analytic.line" t-att-data-domain="json.dumps(timesheet_domain + [('timesheet_invoice_type','=','billable_fixed')])" tabindex="-1">Billed at a Fixed price</a>
|
||||
</span>
|
||||
<span t-if="dashboard['time']['non_billable_project'] != 0" class="badge badge-pill border-0 px-3 py-2 o_progress_non_billable_project">
|
||||
<a type="action" data-model="account.analytic.line" t-att-data-domain="json.dumps(timesheet_domain + [('timesheet_invoice_type','=','non_billable_project')])" tabindex="-1">No task found</a>
|
||||
</span>
|
||||
<span t-if="dashboard['time']['non_billable_timesheet'] != 0" class="badge badge-pill border-0 px-3 py-2 o_progress_non_billable_timesheet">
|
||||
<a type="action" data-model="account.analytic.line" t-att-data-domain="json.dumps(timesheet_domain + [('timesheet_invoice_type','=','non_billable_timesheet')])" tabindex="-1">Non billable timesheets</a>
|
||||
</span>
|
||||
<span class="badge badge-pill border-0 px-3 py-2 o_progress_non_billable">
|
||||
<a type="action" data-model="account.analytic.line" t-att-data-domain="json.dumps(timesheet_domain + [('timesheet_invoice_type','=','non_billable')])" tabindex="-1">Non billable tasks</a>
|
||||
</span>
|
||||
<!-- only show the canceled pill if there were timesheets on canceled so -->
|
||||
<t t-if="sum([employee.get('canceled', 0.0) for employee in repartition_employee.values()]) > 0">
|
||||
<span class="badge badge-pill border-0 px-3 py-2 o_progress_canceled">
|
||||
<a type="action" data-model="account.analytic.line" t-att-data-domain="json.dumps(timesheet_domain + [('so_line.state', '=', 'cancel')])" tabindex="-1">Cancelled</a>
|
||||
</span>
|
||||
</t>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="repartition_employee" t-as="employee_id">
|
||||
<t t-set="employee" t-value="repartition_employee[employee_id]"/>
|
||||
<tr name="row_time_by_people">
|
||||
<td style="width: 3%; vertical-align: middle;">
|
||||
<img class="img rounded-circle mr-2 mb-2" t-attf-src="/web/image?model=hr.employee&field=avatar_128&id=#{employee['employee_id']}" t-att-title="employee['employee_name']" t-att-alt="employee['employee_name']" width="25" height="25"/>
|
||||
</td>
|
||||
<td style="width: 15%; vertical-align: middle;" >
|
||||
<a type="action" data-model="account.analytic.line" t-att-data-domain="json.dumps(timesheet_domain)" t-att-data-context="json.dumps({'search_default_employee_id': employee_id})" data-views="[[0, "list"]]" tabindex="-1">
|
||||
<t t-esc="employee['employee_name']"/>
|
||||
</a>
|
||||
</td>
|
||||
<td class="text-right pr-5" style="width: 10%; vertical-align: middle;">
|
||||
<t t-if="is_uom_day" t-esc="employee['total']" t-options="{'widget': 'timesheet_uom'}"/>
|
||||
<t t-else="" t-esc="employee['total']" t-options="{'widget': 'float_time'}"/>
|
||||
</td>
|
||||
<td style="vertical-align:middle">
|
||||
<div class="border rounded overflow-hidden">
|
||||
<div t-if="repartition_employee_max" class="progress" t-attf-style="width: {{max(0, employee['total'] / repartition_employee_max * 100)}}%; margin-bottom: 0em;">
|
||||
|
||||
<t t-set="total" t-value="employee['total'] or 1.0" />
|
||||
<t t-call="sale_timesheet.progressbar">
|
||||
<t t-set="label">Billed on Timesheets</t>
|
||||
<t t-set="key" t-translation="off">billable_time</t>
|
||||
</t>
|
||||
<t t-call="sale_timesheet.progressbar">
|
||||
<t t-set="label">Billed at a Fixed price</t>
|
||||
<t t-set="key" t-translation="off">billable_fixed</t>
|
||||
</t>
|
||||
<t t-call="sale_timesheet.progressbar">
|
||||
<t t-set="label">No task found</t>
|
||||
<t t-set="key" t-translation="off">non_billable_project</t>
|
||||
</t>
|
||||
<t t-call="sale_timesheet.progressbar">
|
||||
<t t-set="label">Non billable timesheets</t>
|
||||
<t t-set="key" t-translation="off">non_billable_timesheet</t>
|
||||
</t>
|
||||
<t t-call="sale_timesheet.progressbar">
|
||||
<t t-set="label">Non billable tasks</t>
|
||||
<t t-set="key" t-translation="off">non_billable</t>
|
||||
</t>
|
||||
<t t-call="sale_timesheet.progressbar">
|
||||
<t t-set="label">Cancelled</t>
|
||||
<t t-set="key" t-translation="off">canceled</t>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<div class="o_title" name="timesheets_title_block">
|
||||
<h2>Timesheets</h2>
|
||||
</div>
|
||||
|
||||
<!-- NOTE: this template to display a table works whatever the length of the rows, as project_timesheet_forecast_sale extends the table to add forecasts -->
|
||||
<div class="o_project_plan_project_timesheet_forecast">
|
||||
<t t-if="timesheet_forecast_table and timesheet_forecast_table['rows']">
|
||||
<div class="table-responsive">
|
||||
<table class="table" name="table_timesheets">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th colspan="5" id="table_plan_title" class="o_right_bordered"><h3>Timesheets</h3></th>
|
||||
<th colspan="2" id="table_plan_total"></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<t t-foreach="timesheet_forecast_table['header']" t-as="header_val">
|
||||
<th t-att-class="'o_right_bordered' if header_val_index in [5,10] else ''">
|
||||
<span t-att-title="header_val['tooltip']"><t t-esc="header_val['label']"/></span>
|
||||
</th>
|
||||
</t>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-set="row_is_milestone" t-value="False"/>
|
||||
<t t-set="current_order" t-value="False"/>
|
||||
<t t-set="current_order_line" t-value="False"/>
|
||||
<t t-foreach="timesheet_forecast_table['rows']" t-as="row">
|
||||
<t t-set="row_type" t-value="row[0].get('type')"/>
|
||||
<t t-if="row_type == 'sale_order_line'">
|
||||
<t t-set="row_is_milestone" t-value="row[0].get('is_milestone')"/>
|
||||
</t>
|
||||
<t t-if="row_type == 'sale_order'">
|
||||
<t t-set="current_order" t-value="False"/>
|
||||
<t t-set="current_order_line" t-value="False"/>
|
||||
</t>
|
||||
<t t-if="row_type == 'sale_order_line'">
|
||||
<t t-set="current_order_line" t-value="False"/>
|
||||
</t>
|
||||
<t t-set="foldable" t-value="row[0].get('has_children')"/>
|
||||
<tr t-att-class="'o_timesheet_forecast_' + row_type + ' sale_order_' + str(current_order) + ' sale_order_line_' + str(current_order_line)"
|
||||
t-att-style="'display: none;' if row_type not in ('sale_order', 'sale_order_line') else ''">
|
||||
<t t-foreach="row" t-as="row_value">
|
||||
<td t-att-class="'o_right_bordered' if row_value_index in [5,10] else '' + ' text-center' if row_value_index != 0 else ''">
|
||||
<t t-if="row_value_index == 0">
|
||||
<span t-if="foldable" t-att-class="('fa fa-caret-down' if row_type == 'sale_order' else 'fa fa-caret-right') + (' project_overview_foldable' if foldable else '')"
|
||||
style="cursor: pointer;" t-att-data-model="row[0].get('res_model')" t-att-data-res-id="row[0].get('res_id')"/>
|
||||
<t t-if="row_type == 'sale_order'">
|
||||
<t t-if="env.user.has_group('sales_team.group_sale_salesman')">
|
||||
<a type="action" t-att-data-model="row_value['res_model']" t-att-data-res-id="row_value['res_id']" t-att-class="'o_timesheet_plan_redirect' if row_value['res_id'] else ''">
|
||||
<t t-esc="row_value.get('label')"/>
|
||||
</a>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-esc="row_value.get('label')"/>
|
||||
</t>
|
||||
<span t-if="row_value.get('canceled')" class="badge badge-pill o_canceled_tag">
|
||||
Cancelled
|
||||
</span>
|
||||
</t>
|
||||
<t t-if="row_type != 'sale_order'">
|
||||
<t t-if="not row_is_milestone">
|
||||
<span><t t-esc="row_value.get('label')"/></span>
|
||||
</t>
|
||||
<t t-if="row_is_milestone">
|
||||
<span><i><t t-esc="row_value.get('label')"/></i></span>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
<t t-if="row_value_index != 0">
|
||||
<t t-if="row_value_index < len(row)-2">
|
||||
<t t-if="row_is_milestone">
|
||||
<i t-att-class="'text-muted' if not row_value else ''">
|
||||
<t t-if="is_uom_day" t-esc="row_value" t-options="{'widget': 'timesheet_uom'}"/>
|
||||
<t t-else="" t-esc="row_value" t-options="{'widget': 'float_time'}"/>
|
||||
</i>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span t-att-class="'text-muted' if not row_value else ''">
|
||||
<t t-if="is_uom_day" t-esc="row_value" t-options="{'widget': 'timesheet_uom'}"/>
|
||||
<t t-else="" t-esc="row_value" t-options="{'widget': 'float_time'}"/>
|
||||
</span>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-if="not row_is_milestone and not row[0].get('type') == 'hr_employee'">
|
||||
<span t-att-class="'text-muted' if not row_value else ''">
|
||||
<t t-if="is_uom_day" t-esc="row_value" t-options="{'widget': 'timesheet_uom'}"/>
|
||||
<t t-else="" t-esc="row_value" t-options="{'widget': 'float_time'}"/>
|
||||
</span>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</td>
|
||||
</t>
|
||||
</tr>
|
||||
<t t-if="row_type == 'sale_order_line'">
|
||||
<t t-set="current_order_line" t-value="row[0].get('res_id')"/>
|
||||
</t>
|
||||
<t t-if="row_type == 'sale_order'">
|
||||
<t t-set="current_order" t-value="row[0].get('res_id')"/>
|
||||
</t>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</qweb>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<template id="progressbar" name="project overview progressbar segments">
|
||||
<t t-set="amount" t-value="employee[key]"/>
|
||||
<t t-if="amount > 0">
|
||||
<t t-set="title">
|
||||
<t t-esc="label"/>: <t t-if="is_uom_day" t-esc="amount" t-options="{'widget': 'timesheet_uom'}"/><t t-else="" t-esc="amount" t-options="{'widget': 'float_time'}"/>
|
||||
</t>
|
||||
<a t-attf-class="progress-bar o_progress_{{key}}"
|
||||
t-attf-style="width: {{amount / total * 100}}%"
|
||||
type="action" data-model="account.analytic.line"
|
||||
t-att-data-domain="employee['__domain_' + key]"
|
||||
>
|
||||
<span t-att-title="title" style="font-size: 0px; width: 100%; height: 100%;">
|
||||
<t t-esc="label" />
|
||||
</span>
|
||||
</a>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -6,12 +6,23 @@
|
||||
<field name="model">project.project</field>
|
||||
<field name="inherit_id" ref="hr_timesheet.project_invoice_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//button[@name='%(project.action_project_task_burndown_chart_report)d']" position="after">
|
||||
<button string="Costs / Revenues" class="oe_stat_button" type="object" name="action_view_timesheet" icon="fa-puzzle-piece" attrs="{'invisible': [('allow_billable', '=', False)]}" groups="project.group_project_manager"/>
|
||||
</xpath>
|
||||
<xpath expr="//button[@name='action_view_so']" position="attributes">
|
||||
<attribute name="attrs">{'invisible': ['|', ('allow_billable', '=', False), ('sale_order_id', '=', False)]}</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//button[@name='%(project.action_project_task_burndown_chart_report)d']" position="after">
|
||||
<button name="action_billable_time_button" type="object" class="oe_stat_button" icon="fa-clock-o" attrs="{'invisible': ['|', ('allow_timesheets', '=', False), ('analytic_account_id', '=', False)]}" groups="hr_timesheet.group_hr_timesheet_approver">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<div class="oe_inline">
|
||||
<span class="o_stat_value mr-1">
|
||||
<field name="billable_percentage" widget="statinfo" nolabel="1"/>%
|
||||
</span>
|
||||
</div>
|
||||
<span class="o_stat_text">
|
||||
Billable Time
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</xpath>
|
||||
<xpath expr="//header" position="inside">
|
||||
<button name="action_make_billable" string="Create Sales Order" type="object" attrs="{'invisible': [('display_create_order', '=', False)]}" groups="sales_team.group_sale_salesman"/>
|
||||
</xpath>
|
||||
@@ -92,7 +103,7 @@
|
||||
<field name="pricing_type" invisible="1"/>
|
||||
</xpath>
|
||||
<xpath expr="//div[hasclass('o_kanban_manage_reporting')]" position="inside">
|
||||
<div role="menuitem" groups="project.group_project_manager">
|
||||
<div role="menuitem" t-if="record.rating_active.raw_value" groups="project.group_project_manager">
|
||||
<a name="action_view_all_rating" type="object" context="{'search_default_rating_last_30_days':1}">
|
||||
Customer Ratings
|
||||
</a>
|
||||
@@ -195,10 +206,4 @@
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="project_timesheet_action_client_timesheet_plan" model="ir.actions.act_window">
|
||||
<field name="name">Overview</field>
|
||||
<field name="res_model">project.project</field>
|
||||
<field name="view_mode">qweb</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@@ -1,19 +1,38 @@
|
||||
<?xml version="1.0"?>
|
||||
<odoo>
|
||||
<template id="project_update_default_description" inherit_id="hr_timesheet.project_update_default_description">
|
||||
<template id="project_update_default_description" inherit_id="project.project_update_default_description">
|
||||
<!--As this template is rendered in an html field, the spaces may be interpreted as nbsp while editing. -->
|
||||
<xpath expr="//div[@name='milestone']" position="after">
|
||||
<hr t-if="profitability"/>
|
||||
<xpath expr="//div[@name='milestone']" position="before">
|
||||
<br/>
|
||||
<div t-if="project.allow_billable and 'data' in services and len(services['data']) > 0">
|
||||
<h3><b><u>Sold</u></b></h3>
|
||||
<table class="table table-bordered">
|
||||
<tbody>
|
||||
<thead>
|
||||
<td>Service</td>
|
||||
<td>Sold</td>
|
||||
<td>Effective</td>
|
||||
<td>Remaining</td>
|
||||
</thead>
|
||||
<tr t-foreach="services['data']" t-as="service">
|
||||
<td><t t-esc="service['name']"/></td>
|
||||
<td><t t-esc="service['sold_value']"/> <t t-esc="service['uom']"/></td>
|
||||
<td><t t-esc="service['effective_value']"/> <t t-esc="service['uom']"/></td>
|
||||
<td><t t-esc="service['remaining_value']"/> <t t-esc="service['uom']"/></td>
|
||||
</tr>
|
||||
<tfoot>
|
||||
<td>Total</td>
|
||||
<td><t t-esc="services['total_sold']"/> Hours</td>
|
||||
<td><t t-esc="services['total_effective']"/> Hours</td>
|
||||
<td><t t-esc="services['total_remaining']"/> Hours</td>
|
||||
</tfoot>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div name="profitability" t-if="profitability">
|
||||
<h1>Profitability</h1>
|
||||
|
||||
<t t-if="profitability['allow_timesheets']">
|
||||
In <t t-esc="profitability['month']"/>, <b><t t-if="profitability['is_timesheet_uom_hour']" t-esc="profitability['timesheet_unit_amount']"/><t t-else="" t-esc="profitability['timesheet_unit_amount']"/> <t t-esc="profitability['timesheet_uom']"/></b> have been recorded on the project vs.
|
||||
<t t-if="profitability['is_timesheet_uom_hour']" t-esc="profitability['previous_timesheet_unit_amount']"/><t t-else="" t-esc="profitability['previous_timesheet_unit_amount']"/>
|
||||
<t t-esc="profitability['timesheet_uom']"/> in <t t-esc="profitability['previous_month']"/> (<t t-esc="profitability['timesheet_trend']"/> %).
|
||||
</t>
|
||||
|
||||
<t t-if="profitability['allow_costs_and_revenues']">
|
||||
<t t-if="project.analytic_account_id and project.allow_billable and user.has_group('project.group_project_manager')" name="costs">
|
||||
<h3><b><u>Profitability</u></b></h3>
|
||||
The cost of the project is now at <t t-esc="profitability['costs']"/>, for a revenue of <t t-esc="profitability['revenues']"/>, leading to a
|
||||
<span>
|
||||
<font t-if="profitability['margin'] > 0" style="color: rgb(0, 128, 0)">
|
||||
@@ -23,19 +42,10 @@ The cost of the project is now at <t t-esc="profitability['costs']"/>, for a rev
|
||||
<b><t t-esc="profitability['margin_formatted']"/></b>
|
||||
</font>
|
||||
<t t-else="" t-esc="profitability['margin_formatted']"/>
|
||||
</span> margin (<t t-esc="profitability['margin_percentage']"/> %), and a <b><t t-esc="profitability['billing_rate']"/> % billing rate</b>.
|
||||
</span> margin (<t t-esc="profitability['margin_percentage']"/>%).
|
||||
</t>
|
||||
</div>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id="project_profitability_timesheet_panel" name="Timesheet Profitability">
|
||||
<t t-if="is_timesheet_uom_hour" t-esc="timesheet_unit_amount" t-options='{"widget": "float_time"}'/>
|
||||
<t t-else="" t-esc="timesheet_unit_amount"/>
|
||||
<t t-if="not is_timesheet_uom_hour">
|
||||
<t t-esc="timesheet_uom"/>
|
||||
</t>
|
||||
(<t t-esc="percentage_billable"/> % billable)
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
{
|
||||
'name': 'Sales Timesheet Purchase',
|
||||
'category': 'Hidden',
|
||||
'summary': 'Bridge module between Sales Timesheet and Purchase',
|
||||
'description': """
|
||||
Allows to access purchase orders from Project Overview
|
||||
======================================================
|
||||
""",
|
||||
'depends': ['sale_timesheet', 'purchase'],
|
||||
'data': [],
|
||||
'demo': [],
|
||||
'auto_install': True,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * sale_timesheet_purchase
|
||||
#
|
||||
# Translators:
|
||||
# Mustafa Rawi <mustafa@cubexco.com>, 2019
|
||||
# Osoul <baruni@osoul.ly>, 2019
|
||||
# Shaima Safar <shaima.safar@open-inside.com>, 2019
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~12.4\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-08-12 11:32+0000\n"
|
||||
"PO-Revision-Date: 2019-08-26 09:14+0000\n"
|
||||
"Last-Translator: Shaima Safar <shaima.safar@open-inside.com>, 2019\n"
|
||||
"Language-Team: Arabic (https://www.transifex.com/odoo/teams/41243/ar/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Language: ar\n"
|
||||
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: model:ir.model,name:sale_timesheet_purchase.model_project_project
|
||||
msgid "Project"
|
||||
msgstr "المشروع"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:17
|
||||
#, python-format
|
||||
msgid "Purchase Orders"
|
||||
msgstr "أوامر الشراء"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:29
|
||||
#, python-format
|
||||
msgid "Vendor Bills"
|
||||
msgstr "فواتير المورد"
|
||||
@@ -1,39 +0,0 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * sale_timesheet_purchase
|
||||
#
|
||||
# Translators:
|
||||
# Martin Trigaux, 2019
|
||||
# Martin N <nedbalm@gmail.com>, 2019
|
||||
# trendspotter <j.podhorecky@volny.cz>, 2019
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~12.4\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-08-12 11:32+0000\n"
|
||||
"PO-Revision-Date: 2019-08-26 09:14+0000\n"
|
||||
"Last-Translator: trendspotter <j.podhorecky@volny.cz>, 2019\n"
|
||||
"Language-Team: Czech (https://www.transifex.com/odoo/teams/41243/cs/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Language: cs\n"
|
||||
"Plural-Forms: nplurals=4; plural=(n == 1 && n % 1 == 0) ? 0 : (n >= 2 && n <= 4 && n % 1 == 0) ? 1: (n % 1 != 0 ) ? 2 : 3;\n"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: model:ir.model,name:sale_timesheet_purchase.model_project_project
|
||||
msgid "Project"
|
||||
msgstr "Projekt"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:17
|
||||
#, python-format
|
||||
msgid "Purchase Orders"
|
||||
msgstr "Objednávky"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:29
|
||||
#, python-format
|
||||
msgid "Vendor Bills"
|
||||
msgstr "Faktury dodavatele"
|
||||
@@ -1,38 +0,0 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * sale_timesheet_purchase
|
||||
#
|
||||
# Translators:
|
||||
# Martin Trigaux, 2019
|
||||
# Ejner Sønniksen <ejner@vkdata.dk>, 2019
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~12.4\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-08-12 11:32+0000\n"
|
||||
"PO-Revision-Date: 2019-08-26 09:14+0000\n"
|
||||
"Last-Translator: Ejner Sønniksen <ejner@vkdata.dk>, 2019\n"
|
||||
"Language-Team: Danish (https://www.transifex.com/odoo/teams/41243/da/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Language: da\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: model:ir.model,name:sale_timesheet_purchase.model_project_project
|
||||
msgid "Project"
|
||||
msgstr "Projekt"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:17
|
||||
#, python-format
|
||||
msgid "Purchase Orders"
|
||||
msgstr "Indkøbsordrer"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:29
|
||||
#, python-format
|
||||
msgid "Vendor Bills"
|
||||
msgstr "Leverandørfakturaer"
|
||||
@@ -1,37 +0,0 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * sale_timesheet_purchase
|
||||
#
|
||||
# Translators:
|
||||
# Martin Trigaux, 2019
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~12.4\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-08-12 11:32+0000\n"
|
||||
"PO-Revision-Date: 2019-08-26 09:14+0000\n"
|
||||
"Last-Translator: Martin Trigaux, 2019\n"
|
||||
"Language-Team: German (https://www.transifex.com/odoo/teams/41243/de/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Language: de\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: model:ir.model,name:sale_timesheet_purchase.model_project_project
|
||||
msgid "Project"
|
||||
msgstr "Projekt"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:17
|
||||
#, python-format
|
||||
msgid "Purchase Orders"
|
||||
msgstr "Bestellungen"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:29
|
||||
#, python-format
|
||||
msgid "Vendor Bills"
|
||||
msgstr "Eingangsrechnungen"
|
||||
@@ -1,37 +0,0 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * sale_timesheet_purchase
|
||||
#
|
||||
# Translators:
|
||||
# Martin Trigaux, 2019
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~12.4\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-08-12 11:32+0000\n"
|
||||
"PO-Revision-Date: 2019-08-26 09:14+0000\n"
|
||||
"Last-Translator: Martin Trigaux, 2019\n"
|
||||
"Language-Team: Spanish (https://www.transifex.com/odoo/teams/41243/es/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Language: es\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: model:ir.model,name:sale_timesheet_purchase.model_project_project
|
||||
msgid "Project"
|
||||
msgstr "Proyecto"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:17
|
||||
#, python-format
|
||||
msgid "Purchase Orders"
|
||||
msgstr "Pedidos de compra"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:29
|
||||
#, python-format
|
||||
msgid "Vendor Bills"
|
||||
msgstr "Facturas de proveedor"
|
||||
@@ -1,39 +0,0 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * sale_timesheet_purchase
|
||||
#
|
||||
# Translators:
|
||||
# Martin Trigaux, 2019
|
||||
# Aurélien Pillevesse <aurelienpillevesse@hotmail.fr>, 2019
|
||||
# Laura Piraux <lap@odoo.com>, 2019
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~12.4\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-08-12 11:32+0000\n"
|
||||
"PO-Revision-Date: 2019-08-26 09:14+0000\n"
|
||||
"Last-Translator: Laura Piraux <lap@odoo.com>, 2019\n"
|
||||
"Language-Team: French (https://www.transifex.com/odoo/teams/41243/fr/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Language: fr\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: model:ir.model,name:sale_timesheet_purchase.model_project_project
|
||||
msgid "Project"
|
||||
msgstr "Projet"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:17
|
||||
#, python-format
|
||||
msgid "Purchase Orders"
|
||||
msgstr "Bons de commande"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:29
|
||||
#, python-format
|
||||
msgid "Vendor Bills"
|
||||
msgstr "Factures fournisseurs"
|
||||
@@ -1,39 +0,0 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * sale_timesheet_purchase
|
||||
#
|
||||
# Translators:
|
||||
# Yihya Hugirat <hugirat@gmail.com>, 2019
|
||||
# דודי מלכה <Dudimalka6@gmail.com>, 2019
|
||||
# Amit Spilman <amit@laylinetech.com>, 2019
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~12.4\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-08-12 11:32+0000\n"
|
||||
"PO-Revision-Date: 2019-08-26 09:14+0000\n"
|
||||
"Last-Translator: Amit Spilman <amit@laylinetech.com>, 2019\n"
|
||||
"Language-Team: Hebrew (https://www.transifex.com/odoo/teams/41243/he/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Language: he\n"
|
||||
"Plural-Forms: nplurals=4; plural=(n == 1 && n % 1 == 0) ? 0 : (n == 2 && n % 1 == 0) ? 1: (n % 10 == 0 && n % 1 == 0 && n > 10) ? 2 : 3;\n"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: model:ir.model,name:sale_timesheet_purchase.model_project_project
|
||||
msgid "Project"
|
||||
msgstr "פרוייקט"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:17
|
||||
#, python-format
|
||||
msgid "Purchase Orders"
|
||||
msgstr "הזמנות רכש"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:29
|
||||
#, python-format
|
||||
msgid "Vendor Bills"
|
||||
msgstr "חשבונית ספק"
|
||||
@@ -1,39 +0,0 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * sale_timesheet_purchase
|
||||
#
|
||||
# Translators:
|
||||
# Martin Trigaux, 2019
|
||||
# Vladimir Olujić <olujic.vladimir@storm.hr>, 2019
|
||||
# Karolina Tonković <karolina.tonkovic@storm.hr>, 2019
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~12.4\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-08-12 11:32+0000\n"
|
||||
"PO-Revision-Date: 2019-08-26 09:14+0000\n"
|
||||
"Last-Translator: Karolina Tonković <karolina.tonkovic@storm.hr>, 2019\n"
|
||||
"Language-Team: Croatian (https://www.transifex.com/odoo/teams/41243/hr/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Language: hr\n"
|
||||
"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: model:ir.model,name:sale_timesheet_purchase.model_project_project
|
||||
msgid "Project"
|
||||
msgstr "Projekt"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:17
|
||||
#, python-format
|
||||
msgid "Purchase Orders"
|
||||
msgstr "Nalozi u nabavi"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:29
|
||||
#, python-format
|
||||
msgid "Vendor Bills"
|
||||
msgstr "Ulazni računi"
|
||||
@@ -1,39 +0,0 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * sale_timesheet_purchase
|
||||
#
|
||||
# Translators:
|
||||
# Martin Trigaux, 2019
|
||||
# gezza <geza.nagy@oregional.hu>, 2019
|
||||
# Ákos Nagy <akos.nagy@oregional.hu>, 2019
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~12.4\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-08-12 11:32+0000\n"
|
||||
"PO-Revision-Date: 2019-08-26 09:14+0000\n"
|
||||
"Last-Translator: Ákos Nagy <akos.nagy@oregional.hu>, 2019\n"
|
||||
"Language-Team: Hungarian (https://www.transifex.com/odoo/teams/41243/hu/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Language: hu\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: model:ir.model,name:sale_timesheet_purchase.model_project_project
|
||||
msgid "Project"
|
||||
msgstr "Projekt"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:17
|
||||
#, python-format
|
||||
msgid "Purchase Orders"
|
||||
msgstr "Szállítói megrendelések"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:29
|
||||
#, python-format
|
||||
msgid "Vendor Bills"
|
||||
msgstr "Szállítói számlák"
|
||||
@@ -1,37 +0,0 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * sale_timesheet_purchase
|
||||
#
|
||||
# Translators:
|
||||
# Sergio Zanchetta <primes2h@gmail.com>, 2019
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~12.4\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-08-12 11:32+0000\n"
|
||||
"PO-Revision-Date: 2019-08-26 09:14+0000\n"
|
||||
"Last-Translator: Sergio Zanchetta <primes2h@gmail.com>, 2019\n"
|
||||
"Language-Team: Italian (https://www.transifex.com/odoo/teams/41243/it/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Language: it\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: model:ir.model,name:sale_timesheet_purchase.model_project_project
|
||||
msgid "Project"
|
||||
msgstr "Progetto"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:17
|
||||
#, python-format
|
||||
msgid "Purchase Orders"
|
||||
msgstr "Ordini di acquisto"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:29
|
||||
#, python-format
|
||||
msgid "Vendor Bills"
|
||||
msgstr "Fatture fornitore"
|
||||
@@ -1,33 +0,0 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * sale_timesheet_purchase
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~12.4\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-08-12 11:32+0000\n"
|
||||
"PO-Revision-Date: 2019-08-26 09:14+0000\n"
|
||||
"Language-Team: Luxembourgish (https://www.transifex.com/odoo/teams/41243/lb/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Language: lb\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: model:ir.model,name:sale_timesheet_purchase.model_project_project
|
||||
msgid "Project"
|
||||
msgstr ""
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:17
|
||||
#, python-format
|
||||
msgid "Purchase Orders"
|
||||
msgstr ""
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:29
|
||||
#, python-format
|
||||
msgid "Vendor Bills"
|
||||
msgstr ""
|
||||
@@ -1,38 +0,0 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * sale_timesheet_purchase
|
||||
#
|
||||
# Translators:
|
||||
# Martin Trigaux, 2019
|
||||
# Linas Versada <linaskrisiukenas@gmail.com>, 2019
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~12.4\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-08-12 11:32+0000\n"
|
||||
"PO-Revision-Date: 2019-08-26 09:14+0000\n"
|
||||
"Last-Translator: Linas Versada <linaskrisiukenas@gmail.com>, 2019\n"
|
||||
"Language-Team: Lithuanian (https://www.transifex.com/odoo/teams/41243/lt/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Language: lt\n"
|
||||
"Plural-Forms: nplurals=4; plural=(n % 10 == 1 && (n % 100 > 19 || n % 100 < 11) ? 0 : (n % 10 >= 2 && n % 10 <=9) && (n % 100 > 19 || n % 100 < 11) ? 1 : n % 1 != 0 ? 2: 3);\n"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: model:ir.model,name:sale_timesheet_purchase.model_project_project
|
||||
msgid "Project"
|
||||
msgstr "Projektas"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:17
|
||||
#, python-format
|
||||
msgid "Purchase Orders"
|
||||
msgstr "Pirkimų užsakymai"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:29
|
||||
#, python-format
|
||||
msgid "Vendor Bills"
|
||||
msgstr "Tiekėjų sąskaitos"
|
||||
@@ -1,39 +0,0 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * sale_timesheet_purchase
|
||||
#
|
||||
# Translators:
|
||||
# Baskhuu Lodoikhuu <baskhuujacara@gmail.com>, 2019
|
||||
# Martin Trigaux, 2019
|
||||
# Khishigbat Ganbold <khishigbat@asterisk-tech.mn>, 2019
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~12.4\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-08-12 11:32+0000\n"
|
||||
"PO-Revision-Date: 2019-08-26 09:14+0000\n"
|
||||
"Last-Translator: Khishigbat Ganbold <khishigbat@asterisk-tech.mn>, 2019\n"
|
||||
"Language-Team: Mongolian (https://www.transifex.com/odoo/teams/41243/mn/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Language: mn\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: model:ir.model,name:sale_timesheet_purchase.model_project_project
|
||||
msgid "Project"
|
||||
msgstr "Төсөл"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:17
|
||||
#, python-format
|
||||
msgid "Purchase Orders"
|
||||
msgstr "Худалдан авалт"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:29
|
||||
#, python-format
|
||||
msgid "Vendor Bills"
|
||||
msgstr "Нийлүүлэгчийн нэхэмжлэл"
|
||||
@@ -1,37 +0,0 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * sale_timesheet_purchase
|
||||
#
|
||||
# Translators:
|
||||
# Martin Trigaux, 2019
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~12.4\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-08-12 11:32+0000\n"
|
||||
"PO-Revision-Date: 2019-08-26 09:14+0000\n"
|
||||
"Last-Translator: Martin Trigaux, 2019\n"
|
||||
"Language-Team: Norwegian Bokmål (https://www.transifex.com/odoo/teams/41243/nb/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Language: nb\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: model:ir.model,name:sale_timesheet_purchase.model_project_project
|
||||
msgid "Project"
|
||||
msgstr "Prosjekt"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:17
|
||||
#, python-format
|
||||
msgid "Purchase Orders"
|
||||
msgstr "Innkjøpsordrer"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:29
|
||||
#, python-format
|
||||
msgid "Vendor Bills"
|
||||
msgstr "Leverandør faktura"
|
||||
@@ -1,37 +0,0 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * sale_timesheet_purchase
|
||||
#
|
||||
# Translators:
|
||||
# Martin Trigaux, 2019
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~12.4\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-08-12 11:32+0000\n"
|
||||
"PO-Revision-Date: 2019-08-26 09:14+0000\n"
|
||||
"Last-Translator: Martin Trigaux, 2019\n"
|
||||
"Language-Team: Dutch (https://www.transifex.com/odoo/teams/41243/nl/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Language: nl\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: model:ir.model,name:sale_timesheet_purchase.model_project_project
|
||||
msgid "Project"
|
||||
msgstr "Project"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:17
|
||||
#, python-format
|
||||
msgid "Purchase Orders"
|
||||
msgstr "Inkooporders"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:29
|
||||
#, python-format
|
||||
msgid "Vendor Bills"
|
||||
msgstr "Leveranciersfacturen"
|
||||
@@ -1,39 +0,0 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * sale_timesheet_purchase
|
||||
#
|
||||
# Translators:
|
||||
# Martin Trigaux, 2019
|
||||
# Dariusz Żbikowski <darek@krokus.com.pl>, 2019
|
||||
# Piotr Szlązak <szlazakpiotr@gmail.com>, 2019
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~12.4\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-08-12 11:32+0000\n"
|
||||
"PO-Revision-Date: 2019-08-26 09:14+0000\n"
|
||||
"Last-Translator: Piotr Szlązak <szlazakpiotr@gmail.com>, 2019\n"
|
||||
"Language-Team: Polish (https://www.transifex.com/odoo/teams/41243/pl/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Language: pl\n"
|
||||
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: model:ir.model,name:sale_timesheet_purchase.model_project_project
|
||||
msgid "Project"
|
||||
msgstr "Projekt"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:17
|
||||
#, python-format
|
||||
msgid "Purchase Orders"
|
||||
msgstr "Zamówienia Zakupu"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:29
|
||||
#, python-format
|
||||
msgid "Vendor Bills"
|
||||
msgstr "Faktury zakupu"
|
||||
@@ -1,33 +0,0 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * sale_timesheet_purchase
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~12.4\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-08-12 11:32+0000\n"
|
||||
"PO-Revision-Date: 2019-08-26 09:14+0000\n"
|
||||
"Language-Team: Portuguese (https://www.transifex.com/odoo/teams/41243/pt/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Language: pt\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: model:ir.model,name:sale_timesheet_purchase.model_project_project
|
||||
msgid "Project"
|
||||
msgstr ""
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:17
|
||||
#, python-format
|
||||
msgid "Purchase Orders"
|
||||
msgstr ""
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:29
|
||||
#, python-format
|
||||
msgid "Vendor Bills"
|
||||
msgstr ""
|
||||
@@ -1,39 +0,0 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * sale_timesheet_purchase
|
||||
#
|
||||
# Translators:
|
||||
# danimaribeiro <danimaribeiro@gmail.com>, 2019
|
||||
# Martin Trigaux, 2019
|
||||
# Mateus Lopes <mateus1@gmail.com>, 2019
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~12.4\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-08-12 11:32+0000\n"
|
||||
"PO-Revision-Date: 2019-08-26 09:14+0000\n"
|
||||
"Last-Translator: Mateus Lopes <mateus1@gmail.com>, 2019\n"
|
||||
"Language-Team: Portuguese (Brazil) (https://www.transifex.com/odoo/teams/41243/pt_BR/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Language: pt_BR\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: model:ir.model,name:sale_timesheet_purchase.model_project_project
|
||||
msgid "Project"
|
||||
msgstr "Projeto"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:17
|
||||
#, python-format
|
||||
msgid "Purchase Orders"
|
||||
msgstr "Pedidos de Compra"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:29
|
||||
#, python-format
|
||||
msgid "Vendor Bills"
|
||||
msgstr "Faturas de Fornecedor"
|
||||
@@ -1,33 +0,0 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * sale_timesheet_purchase
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~12.4\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-08-12 11:32+0000\n"
|
||||
"PO-Revision-Date: 2019-08-26 09:14+0000\n"
|
||||
"Language-Team: Romanian (https://www.transifex.com/odoo/teams/41243/ro/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Language: ro\n"
|
||||
"Plural-Forms: nplurals=3; plural=(n==1?0:(((n%100>19)||((n%100==0)&&(n!=0)))?2:1));\n"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: model:ir.model,name:sale_timesheet_purchase.model_project_project
|
||||
msgid "Project"
|
||||
msgstr ""
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:17
|
||||
#, python-format
|
||||
msgid "Purchase Orders"
|
||||
msgstr ""
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:29
|
||||
#, python-format
|
||||
msgid "Vendor Bills"
|
||||
msgstr ""
|
||||
@@ -1,37 +0,0 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * sale_timesheet_purchase
|
||||
#
|
||||
# Translators:
|
||||
# Martin Trigaux, 2019
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~12.4\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-08-12 11:32+0000\n"
|
||||
"PO-Revision-Date: 2019-08-26 09:14+0000\n"
|
||||
"Last-Translator: Martin Trigaux, 2019\n"
|
||||
"Language-Team: Russian (https://www.transifex.com/odoo/teams/41243/ru/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Language: ru\n"
|
||||
"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n%100>=11 && n%100<=14)? 2 : 3);\n"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: model:ir.model,name:sale_timesheet_purchase.model_project_project
|
||||
msgid "Project"
|
||||
msgstr "Проект"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:17
|
||||
#, python-format
|
||||
msgid "Purchase Orders"
|
||||
msgstr "Заказы закупки"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:29
|
||||
#, python-format
|
||||
msgid "Vendor Bills"
|
||||
msgstr "Счета от поставщиков"
|
||||
@@ -1,33 +0,0 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * sale_timesheet_purchase
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~14.4\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2021-07-12 07:50+0000\n"
|
||||
"PO-Revision-Date: 2021-07-12 07:50+0000\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: model:ir.model,name:sale_timesheet_purchase.model_project_project
|
||||
msgid "Project"
|
||||
msgstr ""
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:0
|
||||
#, python-format
|
||||
msgid "Purchase Orders"
|
||||
msgstr ""
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:0
|
||||
#, python-format
|
||||
msgid "Vendor Bills"
|
||||
msgstr ""
|
||||
@@ -1,38 +0,0 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * sale_timesheet_purchase
|
||||
#
|
||||
# Translators:
|
||||
# Martin Trigaux, 2019
|
||||
# Murat Kaplan <muratk@projetgrup.com>, 2019
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~12.4\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-08-12 11:32+0000\n"
|
||||
"PO-Revision-Date: 2019-08-26 09:14+0000\n"
|
||||
"Last-Translator: Murat Kaplan <muratk@projetgrup.com>, 2019\n"
|
||||
"Language-Team: Turkish (https://www.transifex.com/odoo/teams/41243/tr/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Language: tr\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: model:ir.model,name:sale_timesheet_purchase.model_project_project
|
||||
msgid "Project"
|
||||
msgstr "Proje"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:17
|
||||
#, python-format
|
||||
msgid "Purchase Orders"
|
||||
msgstr "Satınalma Siparişleri"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:29
|
||||
#, python-format
|
||||
msgid "Vendor Bills"
|
||||
msgstr "Tedarikçi Faturaları"
|
||||
@@ -1,38 +0,0 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * sale_timesheet_purchase
|
||||
#
|
||||
# Translators:
|
||||
# Martin Trigaux, 2019
|
||||
# Alina Lisnenko <alinasemeniuk1@gmail.com>, 2019
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~12.4\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-08-12 11:32+0000\n"
|
||||
"PO-Revision-Date: 2019-08-26 09:14+0000\n"
|
||||
"Last-Translator: Alina Lisnenko <alinasemeniuk1@gmail.com>, 2019\n"
|
||||
"Language-Team: Ukrainian (https://www.transifex.com/odoo/teams/41243/uk/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Language: uk\n"
|
||||
"Plural-Forms: nplurals=4; plural=(n % 1 == 0 && n % 10 == 1 && n % 100 != 11 ? 0 : n % 1 == 0 && n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14) ? 1 : n % 1 == 0 && (n % 10 ==0 || (n % 10 >=5 && n % 10 <=9) || (n % 100 >=11 && n % 100 <=14 )) ? 2: 3);\n"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: model:ir.model,name:sale_timesheet_purchase.model_project_project
|
||||
msgid "Project"
|
||||
msgstr "Проект"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:17
|
||||
#, python-format
|
||||
msgid "Purchase Orders"
|
||||
msgstr "Замовлення на купівлю"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:29
|
||||
#, python-format
|
||||
msgid "Vendor Bills"
|
||||
msgstr "Рахунки постачальників"
|
||||
@@ -1,37 +0,0 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * sale_timesheet_purchase
|
||||
#
|
||||
# Translators:
|
||||
# Nancy Momoland <thanhnguyen.icsc@gmail.com>, 2019
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~12.4\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-08-12 11:32+0000\n"
|
||||
"PO-Revision-Date: 2019-08-26 09:14+0000\n"
|
||||
"Last-Translator: Nancy Momoland <thanhnguyen.icsc@gmail.com>, 2019\n"
|
||||
"Language-Team: Vietnamese (https://www.transifex.com/odoo/teams/41243/vi/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Language: vi\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: model:ir.model,name:sale_timesheet_purchase.model_project_project
|
||||
msgid "Project"
|
||||
msgstr "Dự án"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:17
|
||||
#, python-format
|
||||
msgid "Purchase Orders"
|
||||
msgstr "Đơn mua hàng"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:29
|
||||
#, python-format
|
||||
msgid "Vendor Bills"
|
||||
msgstr "Công nợ nhà cung cấp"
|
||||
@@ -1,37 +0,0 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * sale_timesheet_purchase
|
||||
#
|
||||
# Translators:
|
||||
# Martin Trigaux, 2019
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~12.4\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-08-12 11:32+0000\n"
|
||||
"PO-Revision-Date: 2019-08-26 09:14+0000\n"
|
||||
"Last-Translator: Martin Trigaux, 2019\n"
|
||||
"Language-Team: Chinese (China) (https://www.transifex.com/odoo/teams/41243/zh_CN/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Language: zh_CN\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: model:ir.model,name:sale_timesheet_purchase.model_project_project
|
||||
msgid "Project"
|
||||
msgstr "项目"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:17
|
||||
#, python-format
|
||||
msgid "Purchase Orders"
|
||||
msgstr "采购订单"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:29
|
||||
#, python-format
|
||||
msgid "Vendor Bills"
|
||||
msgstr "供应商帐单"
|
||||
@@ -1,38 +0,0 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * sale_timesheet_purchase
|
||||
#
|
||||
# Translators:
|
||||
# Martin Trigaux, 2019
|
||||
# 敬雲 林 <chingyun@yuanchih-consult.com>, 2019
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~12.4\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-08-12 11:32+0000\n"
|
||||
"PO-Revision-Date: 2019-08-26 09:14+0000\n"
|
||||
"Last-Translator: 敬雲 林 <chingyun@yuanchih-consult.com>, 2019\n"
|
||||
"Language-Team: Chinese (Taiwan) (https://www.transifex.com/odoo/teams/41243/zh_TW/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Language: zh_TW\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: model:ir.model,name:sale_timesheet_purchase.model_project_project
|
||||
msgid "Project"
|
||||
msgstr "專案"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:17
|
||||
#, python-format
|
||||
msgid "Purchase Orders"
|
||||
msgstr "採購訂單"
|
||||
|
||||
#. module: sale_timesheet_purchase
|
||||
#: code:addons/sale_timesheet_purchase/models/project_overview.py:29
|
||||
#, python-format
|
||||
msgid "Vendor Bills"
|
||||
msgstr "供應商應付憑單"
|
||||
@@ -1,40 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import _, models
|
||||
from odoo.addons.sale_timesheet.models.project_overview import _to_action_data
|
||||
|
||||
|
||||
class Project(models.Model):
|
||||
_inherit = 'project.project'
|
||||
|
||||
def _plan_get_stat_button(self):
|
||||
stat_buttons = super(Project, self)._plan_get_stat_button()
|
||||
if self.env.user.has_group('purchase.group_purchase_user'):
|
||||
accounts = self.mapped('analytic_account_id.id')
|
||||
purchase_order_lines = self.env['purchase.order.line'].search([('account_analytic_id', 'in', accounts)])
|
||||
purchase_orders = purchase_order_lines.mapped('order_id')
|
||||
if purchase_orders:
|
||||
stat_buttons.append({
|
||||
'name': _('Purchase Orders'),
|
||||
'count': len(purchase_orders),
|
||||
'icon': 'fa fa-shopping-cart',
|
||||
'action': _to_action_data('purchase.order',
|
||||
domain=[('id', 'in', purchase_orders.ids)],
|
||||
context={'create': False, 'edit': False, 'delete': False}
|
||||
)
|
||||
})
|
||||
account_invoice_lines = self.env['account.move.line'].search(
|
||||
[('analytic_account_id', 'in', accounts),
|
||||
('move_id.move_type', 'in', ['in_invoice', 'in_refund'])])
|
||||
account_invoices = account_invoice_lines.mapped('move_id')
|
||||
if account_invoices:
|
||||
stat_buttons.append({
|
||||
'name': _('Vendor Bills'),
|
||||
'count': len(account_invoices),
|
||||
'icon': 'fa fa-pencil-square-o',
|
||||
'action': _to_action_data(
|
||||
action=self.env.ref('account.action_move_in_invoice_type'),
|
||||
domain=[('id', 'in', account_invoices.ids)],
|
||||
context={'create': False, 'edit': False, 'delete': False}
|
||||
)})
|
||||
|
||||
return stat_buttons
|
||||
@@ -895,6 +895,44 @@ tour.stepUtils.mobileModifier(tour.stepUtils.autoExpandMoreButtons('.o_control_p
|
||||
extra_trigger: '.o_form_readonly',
|
||||
content: _t('See Timesheets'),
|
||||
position: 'bottom',
|
||||
}, {
|
||||
mobile: true,
|
||||
trigger: '.o_enable_searchview',
|
||||
content: _t('Open the search field'),
|
||||
position: 'bottom',
|
||||
}, {
|
||||
mobile: true,
|
||||
trigger: '.o_toggle_searchview_full',
|
||||
content: _t('Open the full search field'),
|
||||
position: 'bottom',
|
||||
}, {
|
||||
trigger: '.o_group_by_menu .o_dropdown_toggler',
|
||||
content: _t('Click on the group by button'),
|
||||
position: 'bottom',
|
||||
}, {
|
||||
trigger: '.dropdown-item.selected:contains(Employee)',
|
||||
extra_trigger: '.dropdown-menu.show',
|
||||
content: _t('Remove group by employee'),
|
||||
position: 'bottom',
|
||||
}, {
|
||||
trigger: '.dropdown-item.selected:contains(Billable Type)',
|
||||
extra_trigger: '.dropdown-menu.show',
|
||||
content: _t('Remove group by billable type'),
|
||||
position: 'bottom',
|
||||
}, {
|
||||
mobile: true,
|
||||
trigger: '.o_mobile_search_footer',
|
||||
content: _t('Close the search dropdown on mobile'),
|
||||
position: 'bottom',
|
||||
}, {
|
||||
mobile: true,
|
||||
trigger: '.o_cp_bottom_right .o_cp_switch_buttons .btn-link',
|
||||
content: _t('Prepare to change the view on mobile'),
|
||||
position: 'bottom',
|
||||
}, {
|
||||
trigger: '.o_switch_view.o_list',
|
||||
content: _t('Activate the list view'),
|
||||
position: 'bottom',
|
||||
}, {
|
||||
trigger: '.o_list_button_add',
|
||||
content: _t('Add a Timesheet'),
|
||||
|
||||
Reference in New Issue
Block a user