[IMP] project: make the field company_id of projects non required

This commit's purpose is to allow the user to set the company_id of a project to False, meaning the project is no longer restricted for the user who does not have access to the company of the project. This change induces a lot of other small behavior changes/approximation. Since some fields (currency_id, resource_calendar_id, etc) were company dependent, we had to updates some use cases.

task-3084819

closes odoo/odoo#122144

Related: odoo/enterprise#41363
Related: odoo/upgrade#4947
Signed-off-by: Xavier Bol (xbo) <xbo@odoo.com>
This commit is contained in:
damr
2023-07-19 23:17:53 +02:00
parent d21f6c29d9
commit 16a07ed1bf
48 changed files with 2117 additions and 387 deletions
@@ -5,6 +5,7 @@
<record id="analytic_plan_projects" model="account.analytic.plan">
<field name="name">Projects</field>
<field name="default_applicability">optional</field>
<field name="company_id" eval="None"/>
</record>
<record id="analytic_plan_departments" model="account.analytic.plan">
<field name="name">Departments</field>
@@ -88,6 +89,11 @@
<field name="partner_id" ref="base.res_partner_12"/>
<field name="plan_id" ref="analytic.analytic_plan_projects"/>
</record>
<record id="analytic_active_account" model="account.analytic.account">
<field name="name">Active account</field>
<field name="active" eval="True"/>
<field name="plan_id" ref="analytic.analytic_plan_projects"/>
</record>
<record id="analytic_administratif" model="account.analytic.account">
<field name="name">Administrative</field>
<field name="plan_id" ref="analytic.analytic_plan_departments"/>
+2 -3
View File
@@ -176,14 +176,13 @@ class AccountAnalyticPlan(models.Model):
def _get_default(self):
plan = self.env['account.analytic.plan'].sudo().search(
['|', ('company_id', '=', False), ('company_id', '=', self.env.company.id)],
limit=1)
[('company_id', '=', False)], limit=1)
if plan:
return plan
else:
return self.env['account.analytic.plan'].create({
'name': 'Default',
'company_id': self.env.company.id,
'company_id': False,
})
@@ -12,6 +12,7 @@ class TestAnalyticAccount(TransactionCase):
def setUpClass(cls):
super().setUpClass()
cls.analytic_plan_offset = cls.env['account.analytic.plan'].search_count([('company_id', '=', False)])
cls.analytic_plan_1 = cls.env['account.analytic.plan'].create({
'name': 'Plan 1',
'default_applicability': 'unavailable',
@@ -75,17 +76,17 @@ class TestAnalyticAccount(TransactionCase):
""" Test that the plans with the good appliability are returned without if no options are given """
kwargs = {}
plans_json = self.env['account.analytic.plan'].get_relevant_plans(**kwargs)
self.assertEqual(1, len(plans_json), "Only the Default plan should be available")
self.assertEqual(1, len(plans_json) - self.analytic_plan_offset, "Only the Default plan and the demo data plans should be available")
self.analytic_plan_1.write({'default_applicability': 'mandatory'})
plans_json = self.env['account.analytic.plan'].get_relevant_plans(**kwargs)
self.assertEqual(2, len(plans_json), "All root plans should be available")
self.assertEqual(2, len(plans_json) - self.analytic_plan_offset, "All root plans should be available")
def test_get_plans_with_option(self):
""" Test the plans returned with applicability rules and options """
kwargs = {'business_domain': 'general'}
plans_json = self.env['account.analytic.plan'].get_relevant_plans(**kwargs)
self.assertEqual(1, len(plans_json), "Only the Default plan should be available")
self.assertEqual(1, len(plans_json) - self.analytic_plan_offset, "Only the Default plan and the demo data plans should be available")
applicability = self.env['account.analytic.applicability'].create({
'business_domain': 'general',
@@ -93,20 +94,20 @@ class TestAnalyticAccount(TransactionCase):
'applicability': 'mandatory'
})
plans_json = self.env['account.analytic.plan'].get_relevant_plans(**kwargs)
self.assertEqual(2, len(plans_json), "All root plans should be available")
self.assertEqual(2, len(plans_json) - self.analytic_plan_offset, "All root plans should be available")
self.analytic_plan_1.write({'default_applicability': 'mandatory'})
applicability.write({'applicability': 'unavailable'})
plans_json = self.env['account.analytic.plan'].get_relevant_plans(**kwargs)
self.assertEqual(1, len(plans_json), "Plan 1 should be unavailable")
self.assertEqual(1, len(plans_json) - self.analytic_plan_offset, "Plan 1 should be unavailable")
kwargs = {'business_domain': 'purchase_order'}
plans_json = self.env['account.analytic.plan'].get_relevant_plans(**kwargs)
self.assertEqual(2, len(plans_json), "Both plans should be available")
self.assertEqual(2, len(plans_json) - self.analytic_plan_offset, "Both plans should be available")
kwargs = {'applicability': 'optional'}
plans_json = self.env['account.analytic.plan'].get_relevant_plans(**kwargs)
self.assertEqual(2, len(plans_json), "All root plans should be available")
self.assertEqual(2, len(plans_json) - self.analytic_plan_offset, "All root plans should be available")
def test_analytic_distribution_model(self):
""" Test the distribution returned from the distribution model """
@@ -187,5 +188,5 @@ class TestAnalyticAccount(TransactionCase):
})
self.analytic_account_1 = self.env['account.analytic.account'].create({'name': 'Child Account', 'plan_id': self.analytic_sub_sub_plan.id})
plans_json = self.env['account.analytic.plan'].get_relevant_plans()
self.assertEqual(2, len(plans_json),
self.assertEqual(2, len(plans_json) - self.analytic_plan_offset,
"The parent plan should be available even if the analytic account is set on child of third generation")
+9 -2
View File
@@ -10,12 +10,19 @@ from odoo.addons.project.controllers.portal import CustomerPortal
class ProjectCustomerPortal(CustomerPortal):
def _get_project_sharing_company(self, project):
company = project.company_id
if not company:
timesheet = request.env['account.analytic.line'].sudo().search([('project_id', '=', project.id)], limit=1)
company = timesheet.company_id or request.env.user.company_id
return company
def _prepare_project_sharing_session_info(self, project, task=None):
session_info = super()._prepare_project_sharing_session_info(project, task)
company = project.company_id
company = request.env['res.company'].sudo().browse(session_info['user_companies']['current_company'])
timesheet_encode_uom = company.timesheet_encode_uom_id
project_time_mode_uom = company.project_time_mode_id
session_info['user_companies']['allowed_companies'][company.id].update(
timesheet_uom_id=timesheet_encode_uom.id,
timesheet_uom_factor=project_time_mode_uom._compute_quantity(
@@ -181,6 +181,12 @@ class AccountAnalyticLine(models.Model):
continue
employee_in_id = vals.get('employee_id', self._context.get('default_employee_id', False))
if employee_in_id:
company = False
if not vals.get('company_id'):
company = self.env['hr.employee'].browse(employee_in_id).company_id
vals['company_id'] = company.id
if not vals.get('product_uom_id'):
vals['product_uom_id'] = company.project_time_mode_id.id if company else self.env['res.company'].browse(vals.get('company_id', self.env.company.id)).project_time_mode_id.id
if employee_in_id in valid_employee_per_id:
vals['user_id'] = valid_employee_per_id[employee_in_id].sudo().user_id.id # (A) OK
continue
@@ -200,6 +206,12 @@ class AccountAnalyticLine(models.Model):
if employee_out_id:
vals['employee_id'] = employee_out_id
vals['user_id'] = user_id
company = False
if not vals.get('company_id'):
company = self.env['hr.employee'].browse(employee_out_id).company_id
vals['company_id'] = company.id
if not vals.get('product_uom_id'):
vals['product_uom_id'] = company.project_time_mode_id.id if company else self.env['res.company'].browse(vals.get('company_id', self.env.company.id)).project_time_mode_id.id
else: # ...and raise an error if they fail
raise ValidationError(error_msg)
@@ -221,6 +233,8 @@ class AccountAnalyticLine(models.Model):
raise UserError(_('You cannot set an archived employee to the existing timesheets.'))
if 'name' in values and not values.get('name'):
values['name'] = '/'
if 'company_id' in values and not values.get('company_id'):
del values['company_id']
result = super(AccountAnalyticLine, self).write(values)
# applied only for timesheet
self.filtered(lambda t: t.project_id)._timesheet_postprocess(values)
@@ -14,13 +14,13 @@ class Project(models.Model):
analytic_account_id = fields.Many2one(
# note: replaces ['|', ('company_id', '=', False), ('company_id', '=', company_id)]
domain="""[
'|', ('company_id', '=', False), ('company_id', '=', company_id),
'|', ('company_id', '=', False), ('company_id', '=?', company_id),
('partner_id', '=?', partner_id),
]"""
)
timesheet_ids = fields.One2many('account.analytic.line', 'project_id', 'Associated Timesheets')
timesheet_encode_uom_id = fields.Many2one('uom.uom', related='company_id.timesheet_encode_uom_id')
timesheet_encode_uom_id = fields.Many2one('uom.uom', compute='_compute_timesheet_encode_uom_id')
total_timesheet_time = fields.Integer(
compute='_compute_total_timesheet_time', groups='hr_timesheet.group_hr_timesheet_user',
help="Total number of time (in the proper UoM) recorded in the project, rounded to the unit.", compute_sudo=True)
@@ -33,6 +33,12 @@ class Project(models.Model):
def _compute_encode_uom_in_days(self):
self.encode_uom_in_days = self.env.company.timesheet_encode_uom_id == self.env.ref('uom.product_uom_day')
@api.depends('company_id', 'company_id.timesheet_encode_uom_id')
@api.depends_context('company')
def _compute_timesheet_encode_uom_id(self):
for project in self:
project.timesheet_encode_uom_id = project.company_id.timesheet_encode_uom_id or self.env.company.timesheet_encode_uom_id
@api.depends('analytic_account_id')
def _compute_allow_timesheets(self):
without_account = self.filtered(lambda t: not t.analytic_account_id and t._origin)
@@ -118,14 +124,13 @@ class Project(models.Model):
if project.allow_timesheets and not project.analytic_account_id:
raise ValidationError(_('You cannot use timesheets without an analytic account.'))
@api.depends('timesheet_ids')
@api.depends('timesheet_ids', 'timesheet_encode_uom_id')
def _compute_total_timesheet_time(self):
timesheets_read_group = self.env['account.analytic.line']._read_group(
[('project_id', 'in', self.ids)],
['project_id', 'product_uom_id'],
['unit_amount:sum'],
)
timesheet_time_dict = defaultdict(list)
for project, product_uom, unit_amount_sum in timesheets_read_group:
timesheet_time_dict[project.id].append((product_uom, unit_amount_sum))
+1 -1
View File
@@ -25,7 +25,7 @@ class Task(models.Model):
_name = "project.task"
_inherit = "project.task"
project_id = fields.Many2one(domain=[('is_internal_project', '=', False)])
project_id = fields.Many2one(domain="['|', ('company_id', '=', False), ('company_id', '=?', company_id), ('is_internal_project', '=', False)]")
project_root_id = fields.Many2one(domain=[('is_internal_project', '=', False)])
analytic_account_active = fields.Boolean("Active Analytic Account", compute='_compute_analytic_account_active', compute_sudo=True, recursive=True)
allow_timesheets = fields.Boolean("Allow timesheets", related="project_root_id.allow_timesheets", help="Timesheets can be logged on this task.", readonly=True, recursive=True)
+1 -1
View File
@@ -630,7 +630,7 @@ class TestTimesheet(TestCommonTimesheet):
})
self.env.company.timesheet_encode_uom_id = self.env.ref('uom.product_uom_day')
self.assertEqual(project.total_timesheet_time, 8, "Total timesheet time should be 8 hours")
self.assertEqual(project.timesheet_encode_uom_id.name, 'Days', "Timesheet encode uom should be 'Days'")
self.assertEqual(project.timesheet_encode_uom_id, self.env.company.timesheet_encode_uom_id, "Timesheet encode uom should be the one from the company of the env, since the project has no company.")
def test_unlink_task_with_timesheet(self):
self.env['account.analytic.line'].create({
+5 -1
View File
@@ -135,6 +135,9 @@ class ProjectCustomerPortal(CustomerPortal):
values = self._project_get_page_view_values(project_sudo, access_token, page, date_begin, date_end, sortby, search, search_in, groupby, **kw)
return request.render("project.portal_my_project", values)
def _get_project_sharing_company(self, project):
return project.company_id or request.env.user.company_id
def _prepare_project_sharing_session_info(self, project, task=None):
session_info = request.env['ir.http'].session_info()
user_context = dict(request.env.context) if request.session.uid else {}
@@ -150,7 +153,8 @@ class ProjectCustomerPortal(CustomerPortal):
"translations": translation_hash,
}
project_company = project.company_id
project_company = self._get_project_sharing_company(project)
session_info.update(
cache_hashes=cache_hashes,
action_name=project.action_project_sharing(),
+3
View File
@@ -49,16 +49,19 @@
<record id="analytic_office_design" model="account.analytic.account">
<field name="name">Office Design</field>
<field name="plan_id" ref="analytic.analytic_plan_projects"/>
<field name="company_id" eval="None"/>
</record>
<record id="analytic_research_development" model="account.analytic.account">
<field name="name">Research &amp; Development</field>
<field name="plan_id" ref="analytic.analytic_plan_projects"/>
<field name="company_id" eval="None"/>
</record>
<record id="analytic_renovations" model="account.analytic.account">
<field name="name">Renovations</field>
<field name="plan_id" ref="analytic.analytic_plan_projects"/>
<field name="company_id" eval="None"/>
</record>
<!-- Stage templates -->
@@ -19,12 +19,6 @@ class AccountAnalyticAccount(models.Model):
for account in self:
account.project_count = mapping.get(account.id, 0)
@api.constrains('company_id')
def _check_company_id(self):
for record in self:
if record.company_id and not all(record.company_id == c for c in record.project_ids.mapped('company_id')):
raise UserError(_('You cannot change the company of an analytic account if it is related to a project.'))
@api.ondelete(at_uninstall=False)
def _unlink_except_existing_tasks(self):
projects = self.env['project.project'].search([('analytic_account_id', 'in', self.ids)])
+67 -12
View File
@@ -9,7 +9,7 @@ from datetime import timedelta
from odoo import api, Command, fields, models, _, _lt
from odoo.addons.rating.models import rating_data
from odoo.tools.misc import get_lang
from odoo.exceptions import UserError
from .project_update import STATUS_COLOR
from .project_task import CLOSED_STATES
@@ -19,7 +19,6 @@ class Project(models.Model):
_inherit = ['portal.mixin', 'mail.alias.mixin', 'rating.parent.mixin', 'mail.thread', 'mail.activity.mixin']
_order = "sequence, name, id"
_rating_satisfaction_days = 30 # takes 30 days by default
_check_company_auto = True
def _compute_attached_docs_count(self):
self.env.cr.execute(
@@ -102,11 +101,11 @@ class Project(models.Model):
active = fields.Boolean(default=True,
help="If the active field is set to False, it will allow you to hide the project without removing it.")
sequence = fields.Integer(default=10)
partner_id = fields.Many2one('res.partner', string='Customer', auto_join=True, tracking=True, check_company=True)
company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.company)
currency_id = fields.Many2one('res.currency', related="company_id.currency_id", string="Currency", readonly=True)
partner_id = fields.Many2one('res.partner', string='Customer', auto_join=True, tracking=True, domain="['|', ('company_id', '=?', company_id), ('company_id', '=', False)]")
company_id = fields.Many2one('res.company', string='Company', compute="_compute_company_id", inverse="_inverse_company_id", store=True, readonly=False)
currency_id = fields.Many2one('res.currency', compute="_compute_currency_id", string="Currency", readonly=True)
analytic_account_id = fields.Many2one('account.analytic.account', string="Analytic Account", copy=False, ondelete='set null',
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", check_company=True,
domain="['|', ('company_id', '=', False), ('company_id', '=?', company_id)]", check_company=True,
help="Analytic account to which this project, its tasks and its timesheets are linked. \n"
"Track the costs and revenues of your project by setting this analytic account on your related documents (e.g. sales orders, invoices, purchase orders, vendor bills, expenses etc.).\n"
"This analytic account can be changed on each task individually if necessary.\n"
@@ -123,8 +122,7 @@ class Project(models.Model):
help="Name used to refer to the tasks of your project e.g. tasks, tickets, sprints, etc...")
tasks = fields.One2many('project.task', 'project_id', string="Task Activities")
resource_calendar_id = fields.Many2one(
'resource.calendar', string='Working Time',
related='company_id.resource_calendar_id')
'resource.calendar', string='Working Time', compute='_compute_resource_calendar_id')
type_ids = fields.Many2many('project.task.type', 'project_task_type_rel', 'project_id', 'type_id', string='Tasks Stages')
task_count = fields.Integer(compute='_compute_task_count', string="Task Count")
task_ids = fields.One2many('project.task', 'project_id', string='Tasks',
@@ -236,6 +234,36 @@ class Project(models.Model):
def _compute_allow_rating(self):
self.allow_rating = self.env.user.has_group('project.group_project_rating')
@api.depends('analytic_account_id.company_id')
def _compute_company_id(self):
for project in self:
# if a new restriction is put on the account, the restriction on the project is updated.
if project.analytic_account_id.company_id:
project.company_id = project.analytic_account_id.company_id
@api.depends_context('company')
@api.depends('company_id', 'company_id.resource_calendar_id')
def _compute_resource_calendar_id(self):
for project in self:
project.resource_calendar_id = project.company_id.resource_calendar_id or self.env.company.resource_calendar_id
def _inverse_company_id(self):
"""
Ensures that the new company of the project is valid for the account. If not set back the previous company, and raise a user Error.
Ensures that the new company of the project is valid for the partner
"""
for project in self:
account = project.analytic_account_id
if project.partner_id and project.partner_id.company_id and project.company_id and project.company_id != project.partner_id.company_id:
raise UserError(_('The project and the associated partner must be linked to the same company.'))
if not account or not account.company_id:
continue
# if the account of the project has more than one company linked to it, or if it has aal, do not update the account, and set back the old company on the project.
if (account.project_count > 1 or account.line_ids) and project.company_id != account.company_id:
raise UserError(
_("The project's company cannot be changed if its analytic account has analytic lines or if more than one project is linked to it."))
account.company_id = project.company_id
@api.depends('rating_status', 'rating_status_period')
def _compute_rating_request_deadline(self):
periods = {'daily': 1, 'weekly': 7, 'bimonthly': 15, 'monthly': 30, 'quarterly': 90, 'yearly': 365}
@@ -281,6 +309,13 @@ class Project(models.Model):
for project in self:
project.is_milestone_exceeded = bool(mapped_count.get(project.id, 0))
@api.depends_context('company')
@api.depends('company_id')
def _compute_currency_id(self):
default_currency_id = self.env.company.currency_id
for project in self:
project.currency_id = project.company_id.currency_id or default_currency_id
@api.model
def _search_is_milestone_exceeded(self, operator, value):
if not isinstance(value, bool):
@@ -790,22 +825,42 @@ class Project(models.Model):
@api.model
def _create_analytic_account_from_values(self, values):
company = self.env['res.company'].browse(values.get('company_id')) if values.get('company_id') else self.env.company
company = self.env['res.company'].browse(values.get('company_id', False))
plan = company.analytic_plan_id
if not plan:
plan = self.env['account.analytic.plan'].sudo().search([('company_id', '=', False)], limit=1)
if not plan:
plan = self.env['account.analytic.plan'].sudo().create({
'name': _('Default'),
'company_id': False,
})
analytic_account = self.env['account.analytic.account'].create({
'name': values.get('name', _('Unknown Analytic Account')),
'company_id': company.id,
'partner_id': values.get('partner_id'),
'plan_id': company.analytic_plan_id.id,
'plan_id': plan.id,
})
return analytic_account
def _create_analytic_account(self):
default_plan = self.env['account.analytic.plan'].sudo().search([('company_id', '=', False)], limit=1)
for project in self:
company_id = False
if project.company_id:
plan = project.company_id.analytic_plan_id
company_id = project.company_id.id
elif default_plan:
plan = default_plan
else:
plan = self.env['account.analytic.plan'].create({
'name': _('Default'),
'company_id': False,
})
analytic_account = self.env['account.analytic.account'].create({
'name': project.name,
'company_id': project.company_id.id,
'company_id': company_id,
'partner_id': project.partner_id.id,
'plan_id': project.company_id.analytic_plan_id.id,
'plan_id': plan.id,
'active': True,
})
project.write({'analytic_account_id': analytic_account.id})
+15 -13
View File
@@ -77,7 +77,6 @@ class Task(models.Model):
_mail_post_access = 'read'
_order = "priority desc, sequence, date_deadline asc, id desc"
_primary_email = 'email_from'
_check_company_auto = True
_track_duration_field = 'stage_id'
@api.model
@@ -104,7 +103,7 @@ class Task(models.Model):
def _default_company_id(self):
if self._context.get('default_project_id'):
return self.env['project.project'].browse(self._context['default_project_id']).company_id
return self.env.company
return False
@api.model
def _read_group_stage_ids(self, stages, domain, order):
@@ -155,8 +154,7 @@ class Task(models.Model):
help="Date on which the state of your task has last been modified.\n"
"Based on this information you can identify tasks that are stalling and get statistics on the time it usually takes to move tasks from one stage/state to another.")
project_id = fields.Many2one('project.project', string='Project',
index=True, tracking=True, check_company=True, change_default=True)
project_id = fields.Many2one('project.project', string='Project', domain="['|', ('company_id', '=', False), ('company_id', '=?', company_id)]", index=True, tracking=True, change_default=True)
project_root_id = fields.Many2one('project.project', compute='_compute_project_root_id', search='_search_project_root_id', recursive=True)
task_properties = fields.Properties('Properties', definition='project_id.task_properties_definition', copy=True)
planned_hours = fields.Float("Allocated Time", tracking=True)
@@ -182,13 +180,10 @@ class Task(models.Model):
search='_search_personal_stage_type_id', default=_default_personal_stage_type_id,
help="The current user's personal task stage.")
partner_id = fields.Many2one('res.partner',
string='Customer', recursive=True, tracking=True,
compute='_compute_partner_id', store=True, readonly=False,
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
string='Customer', recursive=True, tracking=True, compute='_compute_partner_id', store=True, readonly=False,
domain="['|', ('company_id', '=?', company_id), ('company_id', '=', False)]", )
email_cc = fields.Char(help='Email addresses that were in the CC of the incoming emails from this task and that are not currently linked to an existing customer.')
company_id = fields.Many2one(
'res.company', string='Company', compute='_compute_company_id', store=True, readonly=False, recursive=True,
required=True, copy=True, default=_default_company_id)
company_id = fields.Many2one('res.company', string='Company', compute='_compute_company_id', store=True, readonly=False, recursive=True, copy=True, default=_default_company_id)
color = fields.Integer(string='Color Index')
rating_active = fields.Boolean(string='Project Rating Status', related="project_id.rating_active")
attachment_ids = fields.One2many('ir.attachment', compute='_compute_attachment_ids', string="Main Attachments",
@@ -258,7 +253,7 @@ class Task(models.Model):
# Account analytic
analytic_account_id = fields.Many2one('account.analytic.account', ondelete='set null', compute='_compute_analytic_account_id', store=True, readonly=False,
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", check_company=True,
domain="[('company_id', '=?', company_id)]",
help="Analytic account to which this task and its timesheets are linked.\n"
"Track the costs and revenues of your task by setting its analytic account on your related documents (e.g. sales orders, invoices, purchase orders, vendor bills, expenses etc.).\n"
"By default, the analytic account of the project is set. However, it can be changed on each task individually if necessary.")
@@ -276,6 +271,13 @@ class Task(models.Model):
('recurring_task_has_no_parent', 'CHECK (NOT (recurring_task IS TRUE AND parent_id IS NOT NULL))', "A subtask cannot be recurrent.")
]
@api.constrains('company_id', 'partner_id')
def _ensure_company_consistency_with_partner(self):
""" Ensures that the company of the task is valid for the partner. """
for task in self:
if task.partner_id and task.partner_id.company_id and task.company_id and task.company_id != task.partner_id.company_id:
raise ValidationError(_('The task and the associated partner must be linked to the same company.'))
@property
def SELF_READABLE_FIELDS(self):
return PROJECT_TASK_READABLE_FIELDS | self.SELF_WRITABLE_FIELDS
@@ -608,7 +610,7 @@ class Task(models.Model):
@api.onchange('company_id')
def _onchange_task_company(self):
if self.project_id.company_id != self.company_id:
if self.project_id.company_id and self.project_id.company_id != self.company_id:
self.project_id = False
@api.depends('project_id.company_id', 'parent_id.company_id')
@@ -957,7 +959,7 @@ class Task(models.Model):
if project_id and not "company_id" in vals:
vals["company_id"] = self.env["project.project"].browse(
project_id
).company_id.id or self.env.company.id
).company_id.id
if not project_id and ("stage_id" in vals or self.env.context.get('default_stage_id')):
vals["stage_id"] = False
+1 -1
View File
@@ -19,7 +19,7 @@ class ResCompany(models.Model):
default_plan = self.env['ir.config_parameter'].with_company(company).sudo().get_param("default_analytic_plan_id_%s" % company.id)
company.analytic_plan_id = int(default_plan) if default_plan else False
if not company.analytic_plan_id:
company.analytic_plan_id = self.env['account.analytic.plan'].with_company(company)._get_default()
company.analytic_plan_id = self.env['account.analytic.plan']._get_default()
def write(self, values):
for company in self:
+1 -1
View File
@@ -11,7 +11,7 @@ class ResPartner(models.Model):
_check_company_auto = True
project_ids = fields.One2many('project.project', 'partner_id', string='Projects', check_company=True)
task_ids = fields.One2many('project.task', 'partner_id', string='Tasks')
task_ids = fields.One2many('project.task', 'partner_id', string='Tasks', check_company=True)
task_count = fields.Integer(compute='_compute_task_count', string='# Tasks')
def _compute_task_count(self):
+5 -5
View File
@@ -52,7 +52,7 @@
<record model="ir.rule" id="project_comp_rule">
<field name="name">Project: multi-company</field>
<field name="model_id" ref="model_project_project"/>
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
<field name="domain_force">['|', ('company_id', 'in', company_ids), ('company_id', '=', False)]</field>
</record>
<record model="ir.rule" id="project_project_manager_rule">
@@ -75,7 +75,7 @@
<record model="ir.rule" id="task_comp_rule">
<field name="name">Project/Task: multi-company</field>
<field name="model_id" ref="model_project_task"/>
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
<field name="domain_force">['|', ('company_id', 'in', company_ids), ('company_id', '=', False)]</field>
</record>
<record model="ir.rule" id="task_visibility_rule">
@@ -133,7 +133,7 @@
<record model="ir.rule" id="report_project_task_user_report_comp_rule">
<field name="name">Task Analysis multi-company</field>
<field name="model_id" ref="model_report_project_task_user"/>
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
<field name="domain_force">['|', ('company_id', 'in', company_ids), ('company_id', '=', False)]</field>
</record>
<record id="ir_rule_project_personal_stage_my" model="ir.rule">
@@ -215,7 +215,7 @@
<record model="ir.rule" id="update_comp_rule">
<field name="name">Project/Updates: multi-company</field>
<field name="model_id" ref="model_project_update"/>
<field name="domain_force">[('project_id.company_id', 'in', company_ids)]</field>
<field name="domain_force">['|', ('project_id.company_id', 'in', company_ids), ('project_id.company_id', '=', False)]</field>
</record>
<record model="ir.rule" id="update_visibility_rule">
@@ -285,7 +285,7 @@
<record model="ir.rule" id="milestone_comp_rule">
<field name="name">Project/Milestone: multi-company</field>
<field name="model_id" ref="model_project_milestone"/>
<field name="domain_force">[('project_id.company_id', 'in', company_ids)]</field>
<field name="domain_force">['|', ('project_id.company_id', 'in', company_ids), ('project_id.company_id', '=', False)]</field>
</record>
<record model="ir.rule" id="milestone_visibility_rule">
+181 -5
View File
@@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from contextlib import contextmanager
from lxml import etree
@@ -143,8 +144,8 @@ class TestMultiCompanyProject(TestMultiCompanyCommon):
})
# create project in both companies
Project = cls.env['project.project'].with_context({'mail_create_nolog': True, 'tracking_disable': True})
cls.project_company_a = Project.create({
cls.Project = cls.env['project.project'].with_context({'mail_create_nolog': True, 'tracking_disable': True})
cls.project_company_a = cls.Project.create({
'name': 'Project Company A',
'alias_name': 'project+companya',
'partner_id': cls.partner_1.id,
@@ -160,7 +161,7 @@ class TestMultiCompanyProject(TestMultiCompanyCommon):
})
]
})
cls.project_company_b = Project.create({
cls.project_company_b = cls.Project.create({
'name': 'Project Company B',
'alias_name': 'project+companyb',
'partner_id': cls.partner_1.id,
@@ -196,7 +197,7 @@ class TestMultiCompanyProject(TestMultiCompanyCommon):
'name': 'Project Company A',
'partner_id': self.partner_1.id,
})
self.assertEqual(project.company_id, self.env.user.company_id, "A newly created project should be in the current user company")
self.assertFalse(project.company_id, "A newly created project should have a company set to False by default")
with self.switch_company(self.company_b):
with self.assertRaises(AccessError, msg="Manager can not create project in a company in which he is not allowed"):
@@ -220,7 +221,124 @@ class TestMultiCompanyProject(TestMultiCompanyCommon):
with self.allow_companies([self.company_a.id, self.company_b.id]):
self.project_company_a._create_analytic_account()
self.assertEqual(self.project_company_a.company_id, self.project_company_a.analytic_account_id.company_id, "The analytic account created from a project should be in the same company")
self.assertEqual(self.project_company_a.company_id, self.project_company_a.analytic_account_id.company_id, "The analytic account created from a project should be in the same company.")
project_no_company = self.Project.create({'name': 'Project no company'})
plans = self.env['account.analytic.plan'].sudo().search([('company_id', '=', False)])
#ensures that all the existing plan have a company_id
plans.company_id = self.env.company
project_no_company._create_analytic_account()
self.assertFalse(project_no_company.analytic_account_id.company_id, "The analytic account created from a project without company_id should have its company_id field set to False.")
self.assertFalse(project_no_company.analytic_account_id.plan_id.company_id, "The analytic plan created from a project without company_id should have its company_id field set to False.")
plan = self.env['account.analytic.plan'].sudo().search([('company_id', '=', False)])
self.assertEqual(1, len(plan), "Only one analytic plan should have been created.")
project_no_company_2 = self.Project.create({'name': 'Project no company 2'})
project_no_company_2._create_analytic_account()
self.assertNotEqual(project_no_company_2.analytic_account_id, project_no_company.analytic_account_id, "The analytic account created should be different from the account created for the 1st project.")
self.assertEqual(project_no_company_2.analytic_account_id.plan_id, project_no_company.analytic_account_id.plan_id, "No new analytic should have been created.")
def test_analytic_account_company_consistency(self):
"""
This test ensures that the following invariant is kept:
If the company of an analytic account is set, all of its project must have the same company.
If the company of an analytic account is not set, its project can either have a company set, or none.
"""
project_no_company = self.Project.create({'name': 'Project no company'})
project_no_company._create_analytic_account()
account_no_company = project_no_company.analytic_account_id
self.project_company_a._create_analytic_account()
account_a = self.project_company_a.analytic_account_id
# Set the account of the project to a new account without company_id
self.project_company_a.analytic_account_id = account_no_company
self.assertEqual(self.project_company_a.analytic_account_id, account_no_company, "The new account should be set on the project.")
self.assertFalse(account_no_company.company_id, "The company of the account should not have been updated.")
self.project_company_a.analytic_account_id = account_a
# Set the account of the project to a new account with a company_id
project_no_company.analytic_account_id = account_a
self.assertEqual(project_no_company.company_id, self.company_a, "The company of the project should have been updated to the company of its new account.")
self.assertEqual(project_no_company.analytic_account_id, account_a, "The account of the project should have been updated.")
project_no_company.analytic_account_id = account_no_company
project_no_company.company_id = False
# Neither the project nor its account have a company_id
# set the company of the project
project_no_company.company_id = self.company_a
self.assertEqual(project_no_company.company_id, self.company_a, "The company of the project should have been updated.")
self.assertFalse(account_no_company.company_id, "The company of the account should not have been updated for the company of the project was False before its update.")
project_no_company.company_id = False
# set the company of the account
account_no_company.company_id = self.company_a
self.assertEqual(project_no_company.company_id, self.company_a, "The company of the project should have been updated to the company of its new account.")
self.assertEqual(account_no_company.company_id, self.company_a, "The company of the account should have been updated.")
# The project and its account have the same company (company A)
account_a.plan_id.company_id = False
# set the company of the project to False
self.project_company_a.company_id = False
self.assertFalse(self.project_company_a.company_id, "The company of the project should have been updated.")
self.assertFalse(account_a.company_id, "The company of the account should be set to False, as it only has one project linked to it and the company of the project was set from company A to False")
account_a.company_id = self.company_a
# set the company of the project to company B
self.project_company_a.company_id = self.company_b
self.assertEqual(self.project_company_a.company_id, self.company_b, "The company of the project should have been updated.")
self.assertEqual(account_a.company_id, self.company_b, "The company of the account should have been updated, for its company was the same as the one of its project and the company of the project was set before the update.")
# set the company of the account to company A
account_a.company_id = self.company_a
self.assertEqual(self.project_company_a.company_id, self.company_a, "The company of the project should have been updated to the company of its account.")
self.assertEqual(account_a.company_id, self.company_a, "The company of the account should have been updated.")
# set the company of the account to False
account_a.company_id = False
self.assertEqual(self.project_company_a.company_id, self.company_a, "The company of the project should not have been updated for the company of its account has been set to False.")
self.assertFalse(account_a.company_id, "The company of the account should have been updated.")
# The project has a company_id set, but not its account
# set the company of the account to company B (!= project.company_id)
account_a.company_id = self.company_b
self.assertEqual(self.project_company_a.company_id, self.company_b, "The company of the project should have been updated to the company of its account even if the new company set on the account is a different one than the one the project.")
self.assertEqual(account_a.company_id, self.company_b, "The company of the account should have been updated.")
account_a.company_id = False
self.project_company_a.company_id = self.company_a
# set the company of the account to company A (== project.company_id)
account_a.company_id = self.company_a
self.assertEqual(self.project_company_a.company_id, self.company_a, "The company of the project should have been updated to the company of its account.")
self.assertEqual(account_a.company_id, self.company_a, "The company of the account should have been updated.")
account_a.company_id = False
# set the company of the project to company B
self.project_company_a.company_id = self.company_b
self.assertEqual(self.project_company_a.company_id, self.company_b, "The company of the project should have been updated.")
self.assertFalse(account_a.company_id, "The company of the account should not have been updated for it is was set to False.")
# set the company of the project to False
self.project_company_a.company_id = False
self.assertFalse(self.project_company_a.company_id, "The company of the project should have been updated.")
self.assertFalse(account_a.company_id, "The company of the account should not have been updated for it was set to False.")
account_a.plan_id.company_id = self.company_b
account_a.company_id = self.company_b
with self.assertRaises(UserError):
self.project_company_a.company_id = self.company_a
self.assertEqual(self.project_company_a.company_id, self.company_b, "The company of the project is set to a different company from the company of the plan of its account, an error should be raised.")
account_a.plan_id.company_id = False
# creates an AAL for the account_a
aal = self.env['account.analytic.line'].create({
'name': 'other revenues line',
'account_id': account_a.id,
'company_id': self.company_b.id,
'amount': 100,
})
with self.assertRaises(UserError):
self.project_company_a.company_id = self.company_a
self.assertEqual(self.project_company_a.company_id, self.company_b, "The account of the project contains AAL, its company can not be updated.")
aal.unlink()
project_no_company.analytic_account_id = account_a
self.assertEqual(project_no_company.company_id, account_a.company_id)
with self.assertRaises(UserError):
self.project_company_a.company_id = self.company_a
self.assertEqual(self.project_company_a.company_id, self.company_b, "The account of the project is linked to more than one project, its company can not be updated.")
def test_create_task(self):
with self.sudo('employee-a'):
@@ -232,6 +350,64 @@ class TestMultiCompanyProject(TestMultiCompanyCommon):
self.assertEqual(task.company_id, self.project_company_a.company_id, "The company of the task should be the one from its project.")
def test_update_company_id(self):
""" this test ensures that:
- All the tasks of a project with a company set have the same company as their project. Updating the task set the task to a private state.
Updating the company the project update all the tasks even if the company of the project is set to False.
- The tasks of a project without company can have any company set. Updating a task does not update the company of its project. Updating the project update
all the tasks even if these tasks had a company set.
"""
project = self.Project.create({'name': 'Project'})
task = self.env['project.task'].create({
'name': 'task no company',
'project_id': project.id,
})
self.assertFalse(task.company_id, "Creating a task in a project without company set its company_id to False.")
with self.debug_mode():
task_form = Form(task)
task_form.company_id = self.company_a
task = task_form.save()
self.assertFalse(project.company_id, "Setting a new company on a task should not update the company of its project.")
self.assertEqual(task.company_id, self.company_a, "The company of the task should have been updated.")
project.company_id = self.company_b
self.assertEqual(project.company_id, self.company_b, "The company of the project should have been updated.")
self.assertEqual(task.company_id, self.company_b, "The company of the task should have been updated.")
with self.debug_mode():
task_form = Form(task)
task_form.company_id = self.company_a
task = task_form.save()
self.assertEqual(task.company_id, self.company_a, "The company of the task should have been updated.")
self.assertFalse(task.project_id, "The task should now be a private task.")
self.assertEqual(project.company_id, self.company_b, "the company of the project should not have been updated.")
task_1, task_2, task_3 = self.env['project.task'].create([{
'name': 'task 1',
'project_id': project.id,
}, {
'name': 'task 2',
'project_id': project.id,
}, {
'name': 'task 3',
'project_id': project.id,
}])
project.company_id = False
for task in project.task_ids:
self.assertFalse(task.company_id, "The tasks should not have a company_id set.")
task_1.company_id = self.company_a
task_2.company_id = self.company_b
self.assertEqual(task_1.company_id, self.company_a, "The company of task_1 should have been set to company A.")
self.assertEqual(task_2.company_id, self.company_b, "The company of task_2 should have been set to company B.")
self.assertFalse(task_3.company_id, "The company of the task_3 should not have been updated.")
self.assertFalse(project.company_id, "The company of the project should not have been updated.")
company_c = self.env['res.company'].create({'name': 'company C'})
project.company_id = company_c
for task in project.tasks:
self.assertEqual(task.company_id, company_c, "The company of the tasks should have been updated to company C.")
def test_move_task(self):
with self.sudo('employee-a'):
with self.allow_companies([self.company_a.id, self.company_b.id]):
+43 -9
View File
@@ -178,7 +178,8 @@ class TestProjectBase(TestProjectCommon):
def test_change_project_or_partner_company(self):
""" Tests that it is impossible to change the company of a project
if the company of the partner is different and vice versa.
if the company of the partner is different and vice versa if the company of the project is set.
If the company of the project is not set, there are no restriction on its partner company-wise.
"""
company_1 = self.env.company
company_2 = self.env['res.company'].create({'name': 'Company 2'})
@@ -186,18 +187,51 @@ class TestProjectBase(TestProjectCommon):
'name': 'Partner',
})
self.project_pigs.partner_id = partner
# Can change the company of a project if the company of the partner is not set
# Neither the partner nor the project have a company. Their companies can be updated.
self.assertFalse(partner.company_id)
self.project_pigs.company_id = company_2
self.project_pigs.partner_id.company_id = company_2
with self.assertRaises(UserError):
# Cannot change the company of a partner if the company of the project is different
partner.company_id = company_1
self.assertFalse(self.project_pigs.company_id)
self.project_pigs.company_id = company_1
self.assertEqual(self.project_pigs.company_id, company_1, "The company of the project should have been updated.")
self.project_pigs.company_id = False
partner.company_id = company_1
# The partner has a company, but the project has none. The partner can have any new company, but the project can only be set to False/partner.company
with self.assertRaises(UserError):
# Cannot change the company of a project if the company of the partner is different
self.project_pigs.company_id = company_1
self.project_pigs.company_id = company_2
partner.company_id = company_2
partner.company_id = False
partner.company_id = company_1
self.project_pigs.company_id = company_1
self.assertEqual(self.project_pigs.company_id, company_1, "The company of the project should have been updated.")
# The partner has a company and the project has a company. The project can only be set to False, the partner can not be changed
with self.assertRaises(UserError):
# Cannot change the company of a project if both the project and its partner have a company
self.project_pigs.company_id = company_2
with self.assertRaises(UserError):
# Cannot change the company of a partner if both the project and its partner have a company
partner.company_id = company_2
with self.assertRaises(UserError):
# Cannot set the company of a partner to False if both the project and its partner have a company
partner.company_id = False
self.project_pigs.company_id = False
self.assertFalse(self.project_pigs.company_id, "The company of the project should have been set to False.")
partner.company_id = False
self.project_pigs.company_id = company_1
# The project has a company, but the partner has none. The partner can only be set to False/project.company but the project can have any new company.
with self.assertRaises(UserError):
# Cannot change the company of a partner if both the project and its partner have a company
partner.company_id = company_2
self.project_pigs.company_id = company_2
self.assertEqual(self.project_pigs.company_id, company_2, "The company of the project should have been updated.")
self.project_pigs.company_id = False
self.assertFalse(self.project_pigs.company_id, "The company of the project should have been set to False.")
self.project_pigs.company_id = company_1
partner.company_id = company_1
self.assertEqual(partner.company_id, company_1, "The company of the partner should have been updated.")
def test_search_project_root_id(self):
project = self.env['project.project'].create({
@@ -36,7 +36,19 @@ class TestProjectProfitabilityCommon(TransactionCase):
'revenues': {'data': [], 'total': {'invoiced': 0.0, 'to_invoice': 0.0}},
'costs': {'data': [], 'total': {'billed': 0.0, 'to_bill': 0.0}},
}
cls.foreign_currency = cls.env['res.currency'].create({
'name': 'Chaos orb',
'symbol': '☺',
'rounding': 0.001,
'position': 'after',
'currency_unit_label': 'Chaos',
'currency_subunit_label': 'orb',
})
cls.env['res.currency.rate'].create({
'name': '2016-01-01',
'rate': '5.0',
'currency_id': cls.foreign_currency.id,
})
class TestProfitability(TestProjectProfitabilityCommon):
def test_project_profitability(self):
@@ -20,28 +20,41 @@ class TestResConfigSettings(TransactionCase):
'company_id': False,
},
])
cls.company_1, cls.company_2 = cls.env['res.company'].create([
cls.company_1, cls.company_2, cls.company_3 = cls.env['res.company'].create([
{'name': 'Test Company 1'},
{'name': 'Test Company 2'},
{'name': 'Test Company 3'},
])
cls.company_1.analytic_plan_id = cls.analytic_plan_1
(cls.analytic_plan_1 + cls.analytic_plan_2).write({
'company_id': cls.company_1.id,
})
def test_set_default_analytic_plan(self):
"""
Test that we can set the default analytic plan in the settings per company.
This test ensures that :
We can set the default analytic plan in the settings per company.
When there are no analytic plans for the company, a new one named "Default" should be created.
"""
self.env.user.groups_id += self.env.ref('analytic.group_analytic_accounting')
settings_company_1 = self.env['res.config.settings'].with_company(self.company_1).create({})
# when opening the company setting, the company plan should be the plan_1. Changing the plan in the form should update the plan once the form is saved.
with Form(settings_company_1) as form:
self.assertEqual(form.analytic_plan_id, self.analytic_plan_1)
form.analytic_plan_id = self.analytic_plan_2
form.save()
self.assertEqual(settings_company_1.analytic_plan_id, self.analytic_plan_2)
settings_company_2 = self.env['res.config.settings'].with_company(self.company_2).create({})
# If a plan without company_id is available, that plan is used for the default plan of the company.
plans = self.env['account.analytic.plan'].sudo().search([('company_id', '=', False)])
with Form(settings_company_2) as form:
self.assertNotEqual(form.analytic_plan_id, self.analytic_plan_1)
self.assertNotEqual(form.analytic_plan_id, self.analytic_plan_2)
self.assertEqual(form.analytic_plan_id.name, plans[0].name)
plans.company_id = self.env.company
# If no plan without company_id is available, generate a new one name 'default'.
#settings_company_3 = self.env['res.config.settings'].with_company(self.company_3).create({})
with Form(self.env['res.config.settings'].with_company(self.company_3)) as form:
self.assertNotEqual(form.analytic_plan_id, self.analytic_plan_1)
self.assertNotEqual(form.analytic_plan_id, self.analytic_plan_2)
self.assertEqual(form.analytic_plan_id.name, "Default")
@@ -82,7 +82,7 @@
<page name="settings" string="Settings">
<group>
<group>
<field name="analytic_account_id" domain="['|', ('company_id', '=', company_id), ('company_id', '=', False)]" context="{'default_partner_id': partner_id}" groups="analytic.group_analytic_accounting"/>
<field name="analytic_account_id" domain="['|', ('company_id', '=?', company_id), ('company_id', '=', False)]" context="{'default_partner_id': partner_id, 'default_company_id': company_id} " groups="analytic.group_analytic_accounting"/>
<field name="privacy_visibility" widget="radio"/>
<span colspan="2" class="text-muted o_row ps-1" attrs="{'invisible':[('access_instruction_message', '=', '')]}">
<i class="fa fa-lightbulb-o"/>&amp;nbsp;<field class="d-inline" name="access_instruction_message" nolabel="1"/>
+2 -2
View File
@@ -313,7 +313,7 @@
<group>
<group>
<field name="project_id"
domain="[('active', '=', True), ('company_id', '=', company_id)]"
domain="[('active', '=', True), '|', ('company_id', '=', False), ('company_id', '=?', company_id)]"
widget="project_private_task"
/>
<field name="milestone_id"
@@ -438,7 +438,7 @@
<group>
<group>
<field name="parent_id" groups="base.group_no_one" context="{'search_view_ref' : 'project.view_task_search_form','search_default_project_id': project_id}"/>
<field name="analytic_account_id" groups="analytic.group_analytic_accounting" context="{'default_partner_id': partner_id}"/>
<field name="analytic_account_id" groups="analytic.group_analytic_accounting" context="{'default_partner_id': partner_id, 'default_company_id': company_id}"/>
<field name="company_id" groups="base.group_multi_company" options="{'no_create': True}"/>
<field name="sequence" groups="base.group_no_one"/>
<field name="email_cc" groups="base.group_no_one"/>
@@ -2,22 +2,25 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
from datetime import date
from collections import defaultdict
from odoo import models, _lt
from odoo import models, _lt, fields
class Project(models.Model):
_inherit = 'project.project'
def _add_purchase_items(self, profitability_items, with_action=True):
domain = [
domain = self._get_add_purchase_items_domain()
with_action = with_action and self.user_has_groups('account.group_account_invoice, account.group_account_readonly')
self._get_costs_items_from_purchase(domain, profitability_items, with_action=with_action)
def _get_add_purchase_items_domain(self):
return [
('move_type', 'in', ['in_invoice', 'in_refund']),
('parent_state', 'in', ['draft', 'posted']),
('price_subtotal', '>', 0)
]
with_action = with_action and self.user_has_groups('account.group_account_invoice, account.group_account_readonly')
self._get_costs_items_from_purchase(domain, profitability_items, with_action=with_action)
def _get_costs_items_from_purchase(self, domain, profitability_items, with_action=True):
""" This method is used in sale_project and project_purchase. Since project_account is the only common module (except project), we create the method here. """
@@ -31,10 +34,10 @@ class Project(models.Model):
self._cr.execute(query_string, query_param)
bills_move_line_read = self._cr.dictfetchall()
if bills_move_line_read:
# Get conversion rate from currencies to currency of the project
# Get conversion rate from currencies to currency of the current company
currency_ids = {bml['currency_id'] for bml in bills_move_line_read + [{'currency_id': self.currency_id.id}]}
rates = self.env['res.currency'].browse(list(currency_ids))._get_rates(self.company_id, date.today())
conversion_rates = {cid: rates[self.currency_id.id] / rate_from for cid, rate_from in rates.items()}
rates = self.env['res.currency'].browse(list(currency_ids))._get_rates(self.company_id or self.env.company, fields.Date.context_today(self))
conversion_rates = {cid: self.currency_id.rate / rate_from for cid, rate_from in rates.items()}
amount_invoiced = amount_to_invoice = 0.0
move_ids = set()
for moves_read in bills_move_line_read:
@@ -122,28 +125,43 @@ class Project(models.Model):
def _get_domain_aal_with_no_move_line(self):
""" this method is used in order to overwrite the domain in sale_timesheet module. Since the field 'project_id' is added to the "analytic line" model
in the hr_timesheet module, we can't add the condition ('project_id', '=', False) here. """
return [('account_id', '=', self.analytic_account_id.id), ('move_line_id', '=', False)]
return [('account_id', '=', self.analytic_account_id.id), ('move_line_id', '=', False), ('category', '!=', 'manufacturing_order')]
def _get_items_from_aal(self, with_action=True):
domain = self._get_domain_aal_with_no_move_line()
aal_other_search = self.env['account.analytic.line'].sudo().search_read(domain, ['id', 'amount'])
aal_other_search = self.env['account.analytic.line'].sudo().search_read(domain, ['id', 'amount', 'currency_id'])
if not aal_other_search:
return {
'revenues': {'data': [], 'total': {'invoiced': 0.0, 'to_invoice': 0.0}},
'costs': {'data': [], 'total': {'billed': 0.0, 'to_bill': 0.0}},
}
total_revenues = total_costs = 0.0
# dict of form { company : {costs : float, revenues: float}}
dict_amount_per_currency_id = defaultdict(lambda: {'costs': 0.0, 'revenues': 0.0})
set_currency_ids = {self.currency_id.id}
cost_ids = []
revenue_ids = []
for aal in aal_other_search:
set_currency_ids.add(aal['currency_id'][0])
aal_amount = aal['amount']
if aal_amount < 0.0:
total_costs += aal_amount
dict_amount_per_currency_id[aal['currency_id'][0]]['costs'] += aal_amount
cost_ids.append(aal['id'])
else:
total_revenues += aal_amount
dict_amount_per_currency_id[aal['currency_id'][0]]['revenues'] += aal_amount
revenue_ids.append(aal['id'])
total_revenues = total_costs = 0.0
rates_per_currency_id = self.env['res.currency'].browse(set_currency_ids)._get_rates(self.company_id or self.env.company, fields.Date.context_today(self))
project_currency_rate = rates_per_currency_id[self.currency_id.id]
for currency_id, dict_amounts in dict_amount_per_currency_id.items():
if currency_id == self.currency_id.id:
total_revenues += dict_amounts['revenues']
total_costs += dict_amounts['costs']
continue
rate = project_currency_rate / rates_per_currency_id[currency_id]
total_revenues += self.currency_id.round(dict_amounts['revenues'] * rate)
total_costs += self.currency_id.round(dict_amounts['costs'] * rate)
# we dont know what part of the numbers has already been billed or not, so we have no choice but to put everything under the billed/invoiced columns.
# The to bill/to invoice ones will simply remain 0
profitability_sequence_per_invoice_type = self._get_profitability_sequence_per_invoice_type()
@@ -7,43 +7,81 @@ from odoo.addons.project.tests.test_project_profitability import TestProjectProf
@tagged('-at_install', 'post_install')
class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon):
class TestProjectAccountProfitability(TestProjectProfitabilityCommon):
def test_project_profitability(self):
"""
In this module, the project profitability should be computed while checking the AAL data.
The Other Revenue and Other Cost sections should be displayed if some data are available.
"""
project = self.env['project.project'].create({'name': 'new project'})
project._create_analytic_account()
self.assertDictEqual(
self.project._get_profitability_items(False),
project._get_profitability_items(False),
self.project_profitability_items_empty,
'The profitability data of the project should return no data and so 0 for each total amount.'
)
# Create a new company with the foreign currency.
foreign_company = self.env['res.company'].create({'name': "My Test Company", 'currency_id': self.foreign_currency.id})
# Create new AAL with the new company.
self.env['account.analytic.line'].create([{
'name': 'extra revenues 1',
'account_id': self.project.analytic_account_id.id,
'account_id': project.analytic_account_id.id,
'amount': 100,
'company_id': foreign_company.id,
}, {
'name': 'extra costs 1',
'account_id': self.project.analytic_account_id.id,
'account_id': project.analytic_account_id.id,
'amount': -100,
'company_id': foreign_company.id,
}, {
'name': 'extra revenues 2',
'account_id': self.project.analytic_account_id.id,
'account_id': project.analytic_account_id.id,
'amount': 50,
'company_id': foreign_company.id,
}, {
'name': 'extra costs 2',
'account_id': self.project.analytic_account_id.id,
'account_id': project.analytic_account_id.id,
'amount': -50,
'company_id': foreign_company.id,
}])
# Ensures that when all the AAL of the account belongs to another company, the total amount is still converted to the currency of the current active company
self.assertDictEqual(
self.project._get_profitability_items(False),
project._get_profitability_items(False),
{
'revenues': {'data': [{'id': 'other_revenues', 'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_revenues'],
'invoiced': 150.0, 'to_invoice': 0.0}], 'total': {'invoiced': 150.0, 'to_invoice': 0.0}},
'invoiced': 30.0, 'to_invoice': 0.0}], 'total': {'invoiced': 30.0, 'to_invoice': 0.0}},
'costs': {'data': [{'id': 'other_costs', 'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_costs'],
'billed': -150.0, 'to_bill': 0.0}], 'total': {'billed': -150.0, 'to_bill': 0.0}}
'billed': -30.0, 'to_bill': 0.0}], 'total': {'billed': -30.0, 'to_bill': 0.0}}
},
'The profitability data of the project should return the total amount for the revenues and costs from tha AAL of the account of the project.'
)
self.env['account.analytic.line'].create([{
'name': 'extra revenues 1',
'account_id': project.analytic_account_id.id,
'amount': 100,
}, {
'name': 'extra costs 1',
'account_id': project.analytic_account_id.id,
'amount': -100,
}, {
'name': 'extra revenues 2',
'account_id': project.analytic_account_id.id,
'amount': 50,
}, {
'name': 'extra costs 2',
'account_id': project.analytic_account_id.id,
'amount': -50,
}])
# Ensures that multiple AAL from different companies are correctly computed for the project profitability
self.assertDictEqual(
project._get_profitability_items(False),
{
'revenues': {'data': [{'id': 'other_revenues', 'sequence': project._get_profitability_sequence_per_invoice_type()['other_revenues'],
'invoiced': 180.0, 'to_invoice': 0.0}], 'total': {'invoiced': 180.0, 'to_invoice': 0.0}},
'costs': {'data': [{'id': 'other_costs', 'sequence': project._get_profitability_sequence_per_invoice_type()['other_costs'],
'billed': -180.0, 'to_bill': 0.0}], 'total': {'billed': -180.0, 'to_bill': 0.0}}
},
'The profitability data of the project should return the total amount for the revenues and costs from tha AAL of the account of the project.'
)
+1 -1
View File
@@ -8,7 +8,7 @@
'category': 'Services/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'],
'depends': ['project_account', 'hr_expense'],
'demo': [
'data/project_hr_expense_demo.xml',
],
@@ -5,6 +5,7 @@ import json
from odoo import api, fields, models, _, _lt
from odoo.osv import expression
from collections import defaultdict
class Project(models.Model):
_inherit = 'project.project'
@@ -48,6 +49,12 @@ class Project(models.Model):
action["res_id"] = expense_ids[0]
return action
def _get_add_purchase_items_domain(self):
return expression.AND([
super()._get_add_purchase_items_domain(),
[('expense_id', '=', False)],
])
def action_profitability_items(self, section_name, domain=None, res_id=False):
if section_name == 'expenses':
return self._get_expense_action(domain, [res_id] if res_id else [])
@@ -83,20 +90,38 @@ class Project(models.Model):
can_see_expense = with_action and self.user_has_groups('hr_expense.group_hr_expense_team_approver')
query = self.env['hr.expense']._search([('state', 'in', ['approved', 'done'])])
query.add_where('hr_expense.analytic_distribution ? %s', [str(self.analytic_account_id.id)])
query_string, query_param = query.select('array_agg(id) as ids', 'SUM(untaxed_amount) as untaxed_amount')
query_string, query_param = query.select('currency_id', 'array_agg(id) as ids', 'SUM(untaxed_amount) as untaxed_amount')
query_string = f"{query_string} GROUP BY currency_id"
self._cr.execute(query_string, query_param)
expenses_read_group = [expense for expense in self._cr.dictfetchall()]
if not expenses_read_group or not expenses_read_group[0].get('ids'):
return {}
expense_data = expenses_read_group[0]
expense_ids = []
amount_billed = 0.0
dict_amount_per_currency = defaultdict(lambda: 0.0)
set_currency_ids = {self.currency_id.id}
for res in expenses_read_group:
if can_see_expense:
expense_ids.extend(res['ids'])
set_currency_ids.add(res['currency_id'])
dict_amount_per_currency[res['currency_id']] += res['untaxed_amount']
rate_per_currency_id = self.env['res.currency'].browse(set_currency_ids)._get_rates(self.company_id or self.env.company, fields.Date.context_today(self))
project_currency_rate = rate_per_currency_id[self.currency_id.id]
for currency_id, amount in dict_amount_per_currency.items():
if currency_id == self.currency_id.id:
amount_billed += amount
continue
rate = project_currency_rate / rate_per_currency_id[currency_id]
amount_billed += self.currency_id.round(amount * rate)
section_id = 'expenses'
expense_profitability_items = {
'costs': {'id': section_id, 'sequence': self._get_profitability_sequence_per_invoice_type()[section_id], 'billed': -expense_data['untaxed_amount'], 'to_bill': 0.0},
'costs': {'id': section_id, 'sequence': self._get_profitability_sequence_per_invoice_type()[section_id], 'billed': -amount_billed, 'to_bill': 0.0},
}
if can_see_expense:
args = [section_id, [('id', 'in', expense_data['ids'])]]
if expense_data['ids']:
args.append(expense_data['ids'])
args = [section_id, [('id', 'in', expense_ids)]]
if expense_ids:
args.append(expense_ids)
action = {'name': 'action_profitability_items', 'type': 'object', 'args': json.dumps(args)}
expense_profitability_items['action'] = action
return expense_profitability_items
@@ -3,10 +3,11 @@
from odoo.addons.hr_expense.tests.common import TestExpenseCommon
from odoo.addons.project.tests.test_project_profitability import TestProjectProfitabilityCommon
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.tests.common import tagged
class TestProjectHrExpenseProfitabilityCommon(TestExpenseCommon):
class TestProjectHrExpenseProfitabilityCommon(TestExpenseCommon, AccountTestInvoicingCommon):
def check_project_profitability_before_creating_and_approving_expense_sheet(self, expense, project, project_profitability_items_empty):
self.assertDictEqual(
project._get_profitability_items(False),
@@ -36,12 +37,18 @@ class TestProjectHrExpenseProfitabilityCommon(TestExpenseCommon):
class TestProjectHrExpenseProfitability(TestProjectProfitabilityCommon, TestProjectHrExpenseProfitabilityCommon):
def test_project_profitability(self):
self.project.company_id = False
# Create a new company with the foreign currency.
foreign_company = self.company_data_2['company']
foreign_company.currency_id = self.foreign_currency
foreign_employee = self.env['hr.employee'].create({'name': 'Foreign employee', 'company_id': foreign_company.id})
expense = self.env['hr.expense'].create({
'name': 'Car Travel Expenses',
'employee_id': self.expense_employee.id,
'product_id': self.product_a.id,
'unit_amount': 350.00,
'company_id': self.project.company_id.id,
'company_id': self.env.company.id,
'analytic_distribution': {self.project.analytic_account_id.id: 100},
})
@@ -65,9 +72,57 @@ class TestProjectHrExpenseProfitability(TestProjectProfitabilityCommon, TestProj
},
)
# Create an expense in a foreign company, the expense is linked to the AA of the project.
expense_foreign = self.env['hr.expense'].create({
'name': 'Car Travel Expenses foreign',
'employee_id': foreign_employee.id,
'product_id': self.product_a.id,
'unit_amount': 350.00,
'company_id': foreign_company.id,
'analytic_distribution': {self.project.analytic_account_id.id: 100},
'currency_id': self.foreign_currency.id,
})
expense_sheet_vals_list = expense_foreign._get_default_expense_sheet_values()
expense_sheet_vals_list[0]['employee_journal_id'] = self.company_data_2['default_journal_purchase'].id
expense_sheet_foreign = self.env['hr.expense.sheet'].create(expense_sheet_vals_list)
expense_sheet_foreign.action_submit_sheet()
self.assertEqual(expense_sheet_foreign.state, 'submit')
expense_sheet_foreign.action_approve_expense_sheets()
self.assertEqual(expense_sheet_foreign.state, 'approve')
# The cost of the foreign expense sheet should now be computed in the project profitability, since it is now approved
self.assertDictEqual(
self.project._get_profitability_items(False),
{
'costs': {
'data': [{
'id': 'expenses',
'sequence': expense_sequence,
'to_bill': 0.0,
'billed': -expense.untaxed_amount - expense_foreign.untaxed_amount * 0.2
}],
'total': {'to_bill': 0.0, 'billed': -expense.untaxed_amount - expense_foreign.untaxed_amount * 0.2},
},
'revenues': {'data': [], 'total': {'to_invoice': 0.0, 'invoiced': 0.0}},
},
)
# Cancel the expense sheet of the main company. Only the total from the foreign company should be computed
expense_sheet._do_refuse('Test cancel expense')
self.assertDictEqual(
self.project._get_profitability_items(False),
self.project_profitability_items_empty,
'No data should be found since the sheet is not approved yet.',
{
'costs': {
'data': [{'id': 'expenses', 'sequence': expense_sequence, 'to_bill': 0.0, 'billed': -expense_foreign.untaxed_amount * 0.2}],
'total': {'to_bill': 0.0, 'billed': -expense_foreign.untaxed_amount * 0.2},
},
'revenues': {'data': [], 'total': {'to_invoice': 0.0, 'invoiced': 0.0}},
},
)
expense_sheet_foreign._do_refuse('Test cancel foreign expense')
self.assertDictEqual(
self.project._get_profitability_items(False),
self.project_profitability_items_empty,
'No data should be found since the sheets are not approved yet.',
)
+23 -9
View File
@@ -65,16 +65,29 @@ class Project(models.Model):
def _get_profitability_items(self, with_action=True):
profitability_items = super()._get_profitability_items(with_action)
mrp_category = 'manufacturing_order'
count, amount_sum = self.env['account.analytic.line'].sudo()._read_group(
mrp_aal_read_group = self.env['account.analytic.line'].sudo()._read_group(
[('account_id', 'in', self.analytic_account_id.ids), ('category', '=', mrp_category)],
aggregates=['__count', 'amount:sum'],
)[0]
if count:
['currency_id'],
['amount:sum'],
)
if mrp_aal_read_group:
can_see_manufactoring_order = with_action and len(self) == 1 and self.user_has_groups('mrp.group_mrp_user')
total_amount = 0
currency_ids = {currency.id for currency, amount in mrp_aal_read_group}
currency_ids.add(self.currency_id.id)
rate_per_currency_id = self.env['res.currency'].browse(currency_ids)._get_rates(self.company_id or self.env.company, fields.Date.context_today(self))
project_currency_rate = rate_per_currency_id[self.currency_id.id]
for currency, amount_summed in mrp_aal_read_group:
if currency != self.currency_id:
rate = project_currency_rate / rate_per_currency_id[currency.id]
total_amount += self.currency_id.round(amount_summed * rate)
else:
total_amount += amount_summed
mrp_costs = {
'id': mrp_category,
'sequence': self._get_profitability_sequence_per_invoice_type()[mrp_category],
'billed': amount_sum,
'billed': total_amount,
'to_bill': 0.0,
}
if can_see_manufactoring_order:
@@ -87,22 +100,23 @@ class Project(models.Model):
def _get_stat_buttons(self):
buttons = super(Project, self)._get_stat_buttons()
if self.user_has_groups('mrp.group_mrp_user'):
self_sudo = self.sudo()
buttons.extend([{
'icon': 'flask',
'text': _lt('Bills of Materials'),
'number': self.bom_count,
'number': self_sudo.bom_count,
'action_type': 'object',
'action': 'action_view_mrp_bom',
'show': self.bom_count > 0,
'show': self_sudo.bom_count > 0,
'sequence': 35,
},
{
'icon': 'wrench',
'text': _lt('Manufacturing Orders'),
'number': self.production_count,
'number': self_sudo.production_count,
'action_type': 'object',
'action': 'action_view_mrp_production',
'show': self.production_count > 0,
'show': self_sudo.production_count > 0,
'sequence': 46,
}])
return buttons
+4
View File
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_project_profitability
@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import tagged
from odoo.addons.project.tests.test_project_profitability import TestProjectProfitabilityCommon
@tagged('-at_install', 'post_install')
class TestSaleProjectProfitabilityMrp(TestProjectProfitabilityCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.foreign_company = cls.env['res.company'].create(
{'name': "My Test Company", 'currency_id': cls.foreign_currency.id})
def test_profitability_mrp_project(self):
""" This test ensures that when mrp are linked to the project, the total is correctly computed for the project profitability. """
project = self.env['project.project'].create({'name': 'new project'})
project._create_analytic_account()
account = project.analytic_account_id
# creates the aal for the project
self.env['account.analytic.line'].create([{
'name': 'line 1',
'account_id': account.id,
'category': 'manufacturing_order',
'company_id': self.foreign_company.id,
'amount': '500',
'unit_amount': '1',
}, {
'name': 'line 2',
'account_id': account.id,
'category': 'manufacturing_order',
'company_id': self.foreign_company.id,
'amount': '100',
'unit_amount': '1',
}])
# Ensures that if none of the mrp linked to the project have the same company as the current active company, the total is still converted into the current active company.
self.assertDictEqual(project._get_profitability_items(with_action=False), {
'revenues': {'data': [], 'total': {'invoiced': 0.0, 'to_invoice': 0.0}},
'costs': {'data': [{'id': 'manufacturing_order', 'sequence': 12, 'billed': 120.0, 'to_bill': 0.0}], 'total': {'billed': 120.0, 'to_bill': 0.0}}
})
self.env['account.analytic.line'].create([{
'name': 'line 3',
'account_id': account.id,
'category': 'manufacturing_order',
'company_id': self.env.company.id,
'amount': '500',
'unit_amount': '1',
}, {
'name': 'line 4',
'account_id': account.id,
'category': 'manufacturing_order',
'company_id': self.env.company.id,
'amount': '200',
'unit_amount': '1',
}])
# Adds mrp AAL with the default company
self.assertDictEqual(project._get_profitability_items(with_action=False), {
'revenues': {'data': [], 'total': {'invoiced': 0.0, 'to_invoice': 0.0}},
'costs': {'data': [{'id': 'manufacturing_order', 'sequence': 12, 'billed': 820.0, 'to_bill': 0.0}], 'total': {'billed': 820.0, 'to_bill': 0.0}}
})
@@ -89,13 +89,14 @@ class Project(models.Model):
def _get_stat_buttons(self):
buttons = super(Project, self)._get_stat_buttons()
if self.user_has_groups('purchase.group_purchase_user'):
self_sudo = self.sudo()
buttons.append({
'icon': 'credit-card',
'text': _lt('Purchase Orders'),
'number': self.purchase_orders_count,
'number': self_sudo.purchase_orders_count,
'action_type': 'object',
'action': 'action_open_project_purchase_orders',
'show': self.purchase_orders_count > 0,
'show': self_sudo.purchase_orders_count > 0,
'sequence': 36,
})
return buttons
@@ -138,10 +139,10 @@ class Project(models.Model):
purchase_order_line_invoice_line_ids = self._get_already_included_profitability_invoice_line_ids()
with_action = with_action and self.user_has_groups('purchase.group_purchase_user, account.group_account_invoice, account.group_account_readonly')
if purchase_order_line_read:
# Get conversion rate from currencies to currency of the project
# Get conversion rate from currencies to currency of the current company
convert_company = self.company_id or self.env.company
currency_ids = {pol['currency_id'] for pol in purchase_order_line_read + [{'currency_id': self.currency_id.id}]}
rates = self.env['res.currency'].browse(list(currency_ids))._get_rates(self.company_id, date.today())
rates = self.env['res.currency'].browse(list(currency_ids))._get_rates(convert_company, date.today())
conversion_rates = {cid: rates[self.currency_id.id] / rate_from for cid, rate_from in rates.items()}
amount_invoiced = amount_to_invoice = 0.0
@@ -1,17 +1,19 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime
from datetime import datetime, timedelta
from odoo import Command
from odoo.tests import tagged
from odoo.addons.project.tests.test_project_profitability import TestProjectProfitabilityCommon
from odoo.addons.purchase.tests.test_purchase_invoice import TestPurchaseToInvoiceCommon
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.tools.float_utils import float_compare
@tagged('-at_install', 'post_install')
class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurchaseToInvoiceCommon):
class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurchaseToInvoiceCommon, AccountTestInvoicingCommon):
def test_bills_without_purchase_order_are_accounted_in_profitability_project_purchase(self):
"""
@@ -110,7 +112,7 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
"currency_id": self.env.company.currency_id.id,
})],
})
# bill_2 is not posted, therefor its cost should be "to_billed" = - sum of all product_price * qty for each line
# bill_2 is not posted, therefore its cost should be "to_billed" = - sum of all product_price * qty for each line
self.assertDictEqual(
self.project._get_profitability_items(False)['costs'],
{
@@ -133,7 +135,7 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
)
# post bill_2
bill_2.action_post()
# bill_2 is posted, therefor its cost should be counting in "billed", with the cost of bill_1
# bill_2 is posted, therefore its cost should be counting in "billed", with the cost of bill_1
self.assertDictEqual(
self.project._get_profitability_items(False)['costs'],
{
@@ -168,7 +170,7 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
})
purchase_order.button_confirm()
# we should have a new section "purchase_order", the total should be updated,
# but the "other_purchase_costs" shouldn't change, as we don't takes into
# but the "other_purchase_costs" shouldn't change, as we don't take into
# account bills from purchase orders, as those are already taken into calculations
# from the purchase orders (in "purchase_order" section)
self.assertDictEqual(
@@ -196,10 +198,7 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
},
},
)
purchase_order.action_create_invoice()
purchase_bill = purchase_order.invoice_ids # get the bill from the purchase
purchase_bill.invoice_date = datetime.today()
purchase_bill.action_post()
self._create_invoice_for_po(purchase_order)
# now the bill has been posted, its costs should be accounted in the "billed" part
# of the purchase_order section, but should touch in the other_purchase_costs
self.assertDictEqual(
@@ -283,10 +282,7 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
},
},
)
purchase_order.action_create_invoice()
purchase_bill = purchase_order.invoice_ids # get the bill from the purchase
purchase_bill.invoice_date = datetime.today()
purchase_bill.action_post()
self._create_invoice_for_po(purchase_order)
self.assertDictEqual(
self.project._get_profitability_items(False)['costs'],
{
@@ -302,3 +298,212 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
},
},
)
def test_multi_currency_for_project_purchase_profitability(self):
""" This test ensures that when purchase orders with different currencies are linked to the same project, the amount are correctly computed according to the
rate of the company """
project = self.env['project.project'].create({'name': 'new project'})
project._create_analytic_account()
account = project.analytic_account_id
foreign_company = self.company_data_2['company']
foreign_company.currency_id = self.foreign_currency
# a custom analytic contribution (number between 1 -> 100 included)
analytic_distribution = 42
analytic_contribution = analytic_distribution / 100.
# Create a bill_1 with the foreign_currency.
bill_1 = self.env['account.move'].create({
"name": "Bill foreign currency",
"move_type": "in_invoice",
"state": "draft",
"partner_id": self.partner.id,
"invoice_date": datetime.today(),
"date": datetime.today(),
"invoice_date_due": datetime.today() - timedelta(days=1),
"company_id": foreign_company.id,
"invoice_line_ids": [Command.create({
"analytic_distribution": {account.id: analytic_distribution},
"product_id": self.product_a.id,
"quantity": 1,
"product_uom_id": self.product_a.uom_id.id,
"price_unit": self.product_a.standard_price,
"currency_id": self.foreign_currency.id,
}), Command.create({
"analytic_distribution": {account.id: analytic_distribution},
"product_id": self.product_a.id,
"quantity": 2,
"product_uom_id": self.product_a.uom_id.id,
"price_unit": self.product_a.standard_price,
"currency_id": self.foreign_currency.id,
})],
})
# Ensures that if no items have the main currency, the total is still displayed in the main currency.
# Expected total : product_price * 0.2 (rate) * 3 (number of products).
# Note : for some reason, the method to round the amount to the rounding of the currency is not 100% reliable.
# We use a float_compare in order to ensure the value is close enough to the expected result. This problem has no repercusion on the client side, since
# there is also a rounding method on this side to ensure the amount is correctly displayed.
items = project._get_profitability_items(with_action=False)['costs']
self.assertEqual('other_purchase_costs', items['data'][0]['id'])
self.assertEqual(project._get_profitability_sequence_per_invoice_type()['other_purchase_costs'], items['data'][0]['sequence'])
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 0.6, items['data'][0]['to_bill'], 2), 0)
self.assertEqual(0.0, items['data'][0]['billed'])
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 0.6, items['total']['to_bill'], 2), 0)
self.assertEqual(0.0, items['total']['billed'])
# Create a bill 2 with the main currency.
bill_2 = self.env['account.move'].create({
"name": "Bill main currency",
"move_type": "in_invoice",
"state": "draft",
"partner_id": self.partner.id,
"invoice_date": datetime.today(),
"invoice_line_ids": [Command.create({
"analytic_distribution": {account.id: analytic_distribution},
"product_id": self.product_a.id,
"quantity": 1,
"product_uom_id": self.product_a.uom_id.id,
"price_unit": self.product_a.standard_price,
"currency_id": self.env.company.currency_id.id,
}), Command.create({
"analytic_distribution": {account.id: analytic_distribution},
"product_id": self.product_a.id,
"quantity": 2,
"product_uom_id": self.product_a.uom_id.id,
"price_unit": self.product_a.standard_price,
"currency_id": self.env.company.currency_id.id,
})],
})
# The 2 bills are in draft, therefore the "to_bill" section should contain the total cost of the 2 bills.
# The expected total is therefore product_price * 1 * 3 + product_price * 0.2 * 3 => * 3.6
items = project._get_profitability_items(with_action=False)['costs']
self.assertEqual('other_purchase_costs', items['data'][0]['id'])
self.assertEqual(project._get_profitability_sequence_per_invoice_type()['other_purchase_costs'], items['data'][0]['sequence'])
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 3.6, items['data'][0]['to_bill'], 2), 0)
self.assertEqual(0.0, items['data'][0]['billed'])
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 3.6, items['total']['to_bill'], 2), 0)
self.assertEqual(0.0, items['total']['billed'])
# Bill 2 is posted. Its total is now in the 'billed' section, while the bill_1 is still in the 'to bill' one.
bill_2.action_post()
items = project._get_profitability_items(with_action=False)['costs']
self.assertEqual('other_purchase_costs', items['data'][0]['id'])
self.assertEqual(project._get_profitability_sequence_per_invoice_type()['other_purchase_costs'], items['data'][0]['sequence'])
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 0.6, items['data'][0]['to_bill'], 2), 0)
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 3, items['data'][0]['billed'], 2), 0)
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 0.6, items['total']['to_bill'], 2), 0)
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 3, items['total']['billed'], 2), 0)
# Bill 1 is posted. Its total is now in the 'billed' section, the 'to bill' one should now be empty.
bill_1.action_post()
items = project._get_profitability_items(with_action=False)['costs']
self.assertEqual('other_purchase_costs', items['data'][0]['id'])
self.assertEqual(project._get_profitability_sequence_per_invoice_type()['other_purchase_costs'], items['data'][0]['sequence'])
self.assertEqual(0.0, items['data'][0]['to_bill'])
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 3.6, items['data'][0]['billed'], 2), 0)
self.assertEqual(0.0, items['total']['to_bill'])
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 3.6, items['total']['billed'], 2), 0)
# create a new purchase order with the foreign company
purchase_order_foreign = self.env['purchase.order'].create({
"name": "A foreign purchase order",
"partner_id": self.partner_a.id,
"company_id": foreign_company.id,
"order_line": [Command.create({
"analytic_distribution": {account.id: analytic_distribution},
"product_id": self.product_order.id,
"product_qty": 1,
"price_unit": self.product_order.standard_price,
"currency_id": self.foreign_currency.id,
}), Command.create({
"analytic_distribution": {account.id: analytic_distribution},
"product_id": self.product_order.id,
"product_qty": 2,
"price_unit": self.product_order.standard_price,
"currency_id": self.foreign_currency.id,
})],
})
purchase_order_foreign.button_confirm()
# We should have a new section "purchase_order", the total should be updated,
# but the "other_purchase_costs" shouldn't change, as we don't take into
# account bills from purchase orders in this section.
items = project._get_profitability_items(with_action=False)['costs']
self.assertEqual('purchase_order', items['data'][0]['id'])
self.assertEqual(project._get_profitability_sequence_per_invoice_type()['purchase_order'], items['data'][0]['sequence'])
self.assertEqual(float_compare(-self.product_order.standard_price * analytic_contribution * 0.6, items['data'][0]['to_bill'], 2), 0)
self.assertEqual(0.0, items['data'][0]['billed'])
self.assertEqual('other_purchase_costs', items['data'][1]['id'])
self.assertEqual(project._get_profitability_sequence_per_invoice_type()['other_purchase_costs'], items['data'][1]['sequence'])
self.assertEqual(0.0, items['data'][1]['to_bill'])
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 3.6, items['data'][1]['billed'], 2), 0)
self.assertEqual(float_compare(-self.product_order.standard_price * analytic_contribution * 0.6, items['total']['to_bill'], 2), 0)
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 3.6, items['total']['billed'], 2), 0)
# create a new purchase order
purchase_order = self.env['purchase.order'].create({
"name": "A foreign purchase order",
"partner_id": self.partner_a.id,
"company_id": self.env.company.id,
"order_line": [Command.create({
"analytic_distribution": {account.id: analytic_distribution},
"product_id": self.product_order.id,
"product_qty": 1,
"price_unit": self.product_order.standard_price,
"currency_id": self.env.company.currency_id.id,
}), Command.create({
"analytic_distribution": {account.id: analytic_distribution},
"product_id": self.product_order.id,
"product_qty": 2,
"price_unit": self.product_order.standard_price,
"currency_id": self.env.company.currency_id.id,
})],
})
purchase_order.button_confirm()
# The 'to bill' section should be updated in the 'total' and 'purchase orders' sections.
items = project._get_profitability_items(with_action=False)['costs']
self.assertEqual('purchase_order', items['data'][0]['id'])
self.assertEqual(project._get_profitability_sequence_per_invoice_type()['purchase_order'], items['data'][0]['sequence'])
self.assertEqual(float_compare(-self.product_order.standard_price * analytic_contribution * 3.6, items['data'][0]['to_bill'], 2), 0)
self.assertEqual(0.0, items['data'][0]['billed'])
self.assertEqual('other_purchase_costs', items['data'][1]['id'])
self.assertEqual(project._get_profitability_sequence_per_invoice_type()['other_purchase_costs'], items['data'][1]['sequence'])
self.assertEqual(0.0, items['data'][1]['to_bill'])
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 3.6, items['data'][1]['billed'], 2), 0)
self.assertEqual(float_compare(-self.product_order.standard_price * analytic_contribution * 3.6, items['total']['to_bill'], 2), 0)
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 3.6, items['total']['billed'], 2), 0)
self._create_invoice_for_po(purchase_order)
# The purchase order of the main company has been billed. Its total should now be in the 'billed' section.
items = project._get_profitability_items(with_action=False)['costs']
self.assertEqual('purchase_order', items['data'][0]['id'])
self.assertEqual(project._get_profitability_sequence_per_invoice_type()['purchase_order'], items['data'][0]['sequence'])
self.assertEqual(float_compare(-self.product_order.standard_price * analytic_contribution * 0.6, items['data'][0]['to_bill'], 2), 0)
self.assertEqual(float_compare(-self.product_order.standard_price * analytic_contribution * 3, items['data'][0]['billed'], 2), 0)
self.assertEqual('other_purchase_costs', items['data'][1]['id'])
self.assertEqual(project._get_profitability_sequence_per_invoice_type()['other_purchase_costs'], items['data'][1]['sequence'])
self.assertEqual(0.0, items['data'][1]['to_bill'])
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 3.6, items['data'][1]['billed'], 2), 0)
self.assertEqual(float_compare(-self.product_order.standard_price * analytic_contribution * 0.6, items['total']['to_bill'], 2), 0)
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 3.6 - self.product_order.standard_price * analytic_contribution * 3, items['total']['billed'], 2), 0)
self._create_invoice_for_po(purchase_order_foreign)
# The purchase order of the main company has been billed. Its total should now be in the 'billed' section.
# The 'to bill' section of the purchase order should now be empty
items = project._get_profitability_items(with_action=False)['costs']
self.assertEqual('purchase_order', items['data'][0]['id'])
self.assertEqual(project._get_profitability_sequence_per_invoice_type()['purchase_order'], items['data'][0]['sequence'])
self.assertEqual(0.0, items['data'][0]['to_bill'])
self.assertEqual(float_compare(-self.product_order.standard_price * analytic_contribution * 3.6, items['data'][0]['billed'], 2), 0)
self.assertEqual('other_purchase_costs', items['data'][1]['id'])
self.assertEqual(project._get_profitability_sequence_per_invoice_type()['other_purchase_costs'], items['data'][1]['sequence'])
self.assertEqual(0.0, items['data'][1]['to_bill'])
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 3.6, items['data'][1]['billed'], 2), 0)
self.assertEqual(0.0, items['total']['to_bill'])
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 3.6 - self.product_order.standard_price * analytic_contribution * 3.6, items['total']['billed'], 2), 0)
def _create_invoice_for_po(self, purchase_order):
purchase_order.action_create_invoice()
purchase_bill = purchase_order.invoice_ids # get the bill from the purchase
purchase_bill.invoice_date = datetime.today()
purchase_bill.action_post()
@@ -3,7 +3,8 @@
import json
from odoo import models
from odoo import models, fields
from collections import defaultdict
class Project(models.Model):
@@ -15,8 +16,8 @@ class Project(models.Model):
can_see_expense = with_action and self.user_has_groups('hr_expense.group_hr_expense_team_approver')
query = self.env['hr.expense']._search([('state', 'in', ['approved', 'done'])])
query.add_where('hr_expense.analytic_distribution ? %s', [str(self.analytic_account_id.id)])
query_string, query_param = query.select('sale_order_id', 'product_id', 'array_agg(id) as ids', 'SUM(untaxed_amount) as untaxed_amount')
query_string = f"{query_string} GROUP BY sale_order_id, product_id"
query_string, query_param = query.select('sale_order_id', 'product_id', 'currency_id', 'array_agg(id) as ids', 'SUM(untaxed_amount) as untaxed_amount')
query_string = f"{query_string} GROUP BY sale_order_id, product_id, currency_id"
self._cr.execute(query_string, query_param)
expenses_read_group = [expense for expense in self._cr.dictfetchall()]
if not expenses_read_group:
@@ -24,29 +25,59 @@ class Project(models.Model):
expenses_per_so_id = {}
expense_ids = []
amount_billed = 0.0
today = fields.Date.context_today(self)
convert_company = self.company_id or self.env.company
dict_amount_per_currency = defaultdict(lambda: 0.0)
for res in expenses_read_group:
so_id = res['sale_order_id']
product_id = res['product_id']
expenses_per_so_id.setdefault(so_id, {})[product_id] = res['ids']
if can_see_expense:
expense_ids.extend(res['ids'])
amount_billed += res['untaxed_amount']
dict_amount_per_currency[res['currency_id']] += res['untaxed_amount']
for currency_id in dict_amount_per_currency:
if currency_id == self.currency_id.id:
amount_billed += dict_amount_per_currency[currency_id]
continue
currency = self.env['res.currency'].browse(currency_id)
rate = currency._get_conversion_rate(currency, self.currency_id, convert_company, today)
amount_billed += self.currency_id.round(dict_amount_per_currency[currency_id] * rate)
sol_read_group = self.env['sale.order.line'].sudo()._read_group(
[
('order_id', 'in', list(expenses_per_so_id.keys())),
('is_expense', '=', True),
('state', '=', 'sale'),
],
['order_id', 'product_id'],
['untaxed_amount_to_invoice:sum', 'untaxed_amount_invoiced:sum'])
['order_id', 'product_id', 'currency_id'],
['untaxed_amount_to_invoice:sum', 'untaxed_amount_invoiced:sum'],
)
total_amount_expense_invoiced = total_amount_expense_to_invoice = 0.0
reinvoice_expense_ids = []
for order, product, untaxed_amount_to_invoice_sum, untaxed_amount_invoiced_sum in sol_read_group:
dict_invoices_amount_per_currency = defaultdict(lambda: {'to_invoice': 0.0, 'invoiced': 0.0})
set_currency_ids = {self.currency_id.id}
for order, product, currency, untaxed_amount_to_invoice_sum, untaxed_amount_invoiced_sum in sol_read_group:
expense_data_per_product_id = expenses_per_so_id[order.id]
if product.id in expense_data_per_product_id:
total_amount_expense_to_invoice += untaxed_amount_to_invoice_sum
total_amount_expense_invoiced += untaxed_amount_invoiced_sum
reinvoice_expense_ids += expense_data_per_product_id[product.id]
set_currency_ids.add(currency.id)
product_id = product.id
if product_id in expense_data_per_product_id:
dict_invoices_amount_per_currency[currency]['to_invoice'] += untaxed_amount_to_invoice_sum
dict_invoices_amount_per_currency[currency]['invoiced'] += untaxed_amount_invoiced_sum
reinvoice_expense_ids += expense_data_per_product_id[product_id]
rate_per_currency_id = {self.currency_id.id: 1}
if len(set_currency_ids) > 1:
rate_per_currency_id = self.env['res.currency'].browse(set_currency_ids)._get_rates(self.company_id or self.env.company, today)
project_currency_rate = rate_per_currency_id[self.currency_id.id]
for currency, revenues in dict_invoices_amount_per_currency.items():
if currency.id == self.currency_id.id:
total_amount_expense_to_invoice += revenues['to_invoice']
total_amount_expense_invoiced += revenues['invoiced']
continue
rate = project_currency_rate / rate_per_currency_id[currency.id]
total_amount_expense_to_invoice += self.currency_id.round(revenues['to_invoice'] * rate)
total_amount_expense_invoiced += self.currency_id.round(revenues['invoiced'] * rate)
section_id = 'expenses'
sequence = self._get_profitability_sequence_per_invoice_type()[section_id]
expense_data = {
@@ -11,23 +11,66 @@ from odoo.addons.sale_project.tests.test_project_profitability import TestProjec
@tagged('-at_install', 'post_install')
class TestProjectSaleExpenseProfitability(TestProjectProfitabilityCommon, TestProjectHrExpenseProfitabilityCommon, TestSaleCommon):
def test_project_profitability(self):
project = self.env['project.project'].create({'name': 'new project'})
project._create_analytic_account()
account = project.analytic_account_id
# Create a new company with the foreign currency.
foreign_company = self.company_data_2['company']
foreign_company.currency_id = self.foreign_currency
foreign_partner = self.env['res.partner'].create({
'name': 'Foreign Employee address',
'company_id': foreign_company.id,
})
foreign_employee = self.env['hr.employee'].create({
'name': 'foreign_employee',
'company_id': foreign_company.id,
'work_contact_id': foreign_partner.id
})
expense = self.env['hr.expense'].create({
'name': 'expense',
'product_id': self.company_data['product_order_sales_price'].id,
'unit_amount': self.company_data['product_order_sales_price'].list_price,
'employee_id': self.expense_employee.id,
'analytic_distribution': {self.project.analytic_account_id.id: 100},
'analytic_distribution': {account.id: 100},
'sale_order_id': self.sale_order.id,
})
# See method definition in `project_hr_expense.tests.test_project_profitability`
expense_sheet = self.check_project_profitability_before_creating_and_approving_expense_sheet(
expense,
self.project,
project,
self.project_profitability_items_empty)
expense_profitability = self.project._get_expenses_profitability_items(False)
sequence_per_invoice_type = self.project._get_profitability_sequence_per_invoice_type()
# Create an expense in a foreign company, the expense is linked to the AA of the project.
so_foreign = self.env['sale.order'].create({
'name': 'Sale order foreign',
'partner_id': self.partner_a.id,
'company_id': foreign_company.id,
'analytic_account_id': account.id,
})
so_foreign.currency_id = self.foreign_currency
so_foreign.action_confirm()
expense_foreign = self.env['hr.expense'].create({
'name': 'Expense foreign',
'employee_id': foreign_employee.id,
'product_id': self.company_data['product_order_sales_price'].id,
'unit_amount': 350.00,
'company_id': foreign_company.id,
'analytic_distribution': {account.id: 100},
'currency_id': self.foreign_currency.id,
'sale_order_id': so_foreign.id,
})
expense_sheet_vals_list = expense_foreign._get_default_expense_sheet_values()
expense_sheet_vals_list[0]['employee_journal_id'] = self.company_data_2['default_journal_purchase'].id
expense_sheet_foreign = self.env['hr.expense.sheet'].create(expense_sheet_vals_list)
expense_sheet_foreign.action_submit_sheet()
self.assertEqual(expense_sheet_foreign.state, 'submit')
expense_sheet_foreign.action_approve_expense_sheets()
self.assertEqual(expense_sheet_foreign.state, 'approve')
expense_profitability = project._get_expenses_profitability_items(False)
sequence_per_invoice_type = project._get_profitability_sequence_per_invoice_type()
self.assertIn('expenses', sequence_per_invoice_type)
expense_sequence = sequence_per_invoice_type['expenses']
@@ -37,11 +80,10 @@ class TestProjectSaleExpenseProfitability(TestProjectProfitabilityCommon, TestPr
)
self.assertDictEqual(
expense_profitability['costs'],
{'id': 'expenses', 'sequence': expense_sequence, 'billed': -280.0, 'to_bill': 0.0},
{'id': 'expenses', 'sequence': expense_sequence, 'billed': -280.0 - expense_foreign.untaxed_amount * 0.2, 'to_bill': 0.0},
)
expense_sheet.action_sheet_move_create()
self.assertRecordValues(self.sale_order.order_line, [
# Original SO line:
{
@@ -59,18 +101,18 @@ class TestProjectSaleExpenseProfitability(TestProjectProfitabilityCommon, TestPr
])
expense_sol = self.sale_order.order_line.filtered(lambda sol: sol.product_id == self.company_data['product_order_sales_price'])
expense_profitability = self.project._get_expenses_profitability_items(False)
expense_profitability = project._get_expenses_profitability_items(False)
self.assertDictEqual(
expense_profitability.get('revenues', {}),
{'id': 'expenses', 'sequence': expense_sequence, 'invoiced': 0.0, 'to_invoice': expense_sol.untaxed_amount_to_invoice},
)
self.assertDictEqual(
expense_profitability['costs'],
{'id': 'expenses', 'sequence': expense_sequence, 'billed': -280.0, 'to_bill': 0.0},
{'id': 'expenses', 'sequence': expense_sequence, 'billed': -280.0 - expense_foreign.untaxed_amount * 0.2, 'to_bill': 0.0},
)
self.assertDictEqual(
self.project._get_profitability_items(False),
project._get_profitability_items(False),
{
'revenues': {
'data': [expense_profitability['revenues']],
@@ -83,6 +125,31 @@ class TestProjectSaleExpenseProfitability(TestProjectProfitabilityCommon, TestPr
}
)
expense_sheet_foreign.action_sheet_move_create()
expense_sol_foreign = so_foreign.order_line[0]
expense_profitability = project._get_expenses_profitability_items(False)
self.assertDictEqual(
expense_profitability.get('revenues', {}),
{'id': 'expenses', 'sequence': expense_sequence, 'invoiced': 0.0, 'to_invoice': expense_sol.untaxed_amount_to_invoice + expense_sol_foreign.untaxed_amount_to_invoice * 0.2},
)
self.assertDictEqual(
expense_profitability['costs'],
{'id': 'expenses', 'sequence': expense_sequence, 'billed': -280.0 - expense_foreign.untaxed_amount * 0.2, 'to_bill': 0.0},
)
self.assertDictEqual(
project._get_profitability_items(False),
{
'revenues': {
'data': [expense_profitability['revenues']],
'total': {k: v for k, v in expense_profitability['revenues'].items() if k in ['to_invoice', 'invoiced']},
},
'costs': {
'data': [expense_profitability['costs']],
'total': {k: v for k, v in expense_profitability['costs'].items() if k in ['to_bill', 'billed']},
},
}
)
invoice = self.env['sale.advance.payment.inv'] \
.with_context({
'active_model': 'sale.order',
@@ -92,34 +159,78 @@ class TestProjectSaleExpenseProfitability(TestProjectProfitabilityCommon, TestPr
})._create_invoices(self.sale_order)
invoice.action_post()
expense_profitability = self.project._get_expenses_profitability_items(False)
expense_profitability = project._get_expenses_profitability_items(False)
self.assertDictEqual(
expense_profitability.get('revenues', {}),
{'id': 'expenses', 'sequence': expense_sequence, 'invoiced': expense_sol.untaxed_amount_invoiced, 'to_invoice': 0.0},
{'id': 'expenses', 'sequence': expense_sequence, 'invoiced': expense_sol.untaxed_amount_invoiced, 'to_invoice': expense_sol_foreign.untaxed_amount_to_invoice * 0.2},
)
credit_note = invoice._reverse_moves()
credit_note.action_post()
expense_profitability = self.project._get_expenses_profitability_items(False)
expense_profitability = project._get_expenses_profitability_items(False)
self.assertDictEqual(
expense_profitability.get('revenues', {}),
{'id': 'expenses', 'sequence': expense_sequence, 'invoiced': 0.0, 'to_invoice': expense_sol.untaxed_amount_to_invoice},
{'id': 'expenses', 'sequence': expense_sequence, 'invoiced': 0.0, 'to_invoice': expense_sol.untaxed_amount_to_invoice + expense_sol_foreign.untaxed_amount_to_invoice * 0.2},
)
self.sale_order._action_cancel()
expense_profitability = self.project._get_expenses_profitability_items(False)
expense_profitability = project._get_expenses_profitability_items(False)
self.assertDictEqual(
expense_profitability.get('revenues', {}),
{'id': 'expenses', 'sequence': expense_sequence, 'invoiced': 0.0, 'to_invoice': expense_sol_foreign.untaxed_amount_to_invoice * 0.2},
)
self.assertDictEqual(
expense_profitability['costs'],
{'id': 'expenses', 'sequence': expense_sequence, 'billed': -280.0 - expense_foreign.untaxed_amount * 0.2, 'to_bill': 0.0},
)
expense_sheet._do_refuse('Test Cancel Expense')
expense_profitability = project._get_expenses_profitability_items(False)
self.assertDictEqual(
expense_profitability.get('revenues', {}),
{'id': 'expenses', 'sequence': expense_sequence, 'invoiced': 0.0, 'to_invoice': expense_sol_foreign.untaxed_amount_to_invoice * 0.2},
)
self.assertDictEqual(
expense_profitability.get('costs', {}),
{'id': 'expenses', 'sequence': expense_sequence, 'billed': -expense_foreign.untaxed_amount * 0.2, 'to_bill': 0.0},
)
invoice = self.env['sale.advance.payment.inv'].with_context({
'active_model': 'sale.order',
'active_id': so_foreign.id,
}).create({
'advance_payment_method': 'delivered',
})._create_invoices(so_foreign)
invoice.action_post()
expense_profitability = project._get_expenses_profitability_items(False)
self.assertDictEqual(
expense_profitability.get('revenues', {}),
{'id': 'expenses', 'sequence': expense_sequence, 'invoiced': expense_sol_foreign.untaxed_amount_invoiced * 0.2, 'to_invoice': 0.0},
)
credit_note = invoice._reverse_moves()
credit_note.action_post()
expense_profitability = project._get_expenses_profitability_items(False)
self.assertDictEqual(
expense_profitability.get('revenues', {}),
{'id': 'expenses', 'sequence': expense_sequence, 'invoiced': 0.0, 'to_invoice': expense_sol_foreign.untaxed_amount_to_invoice * 0.2},
)
so_foreign._action_cancel()
expense_profitability = project._get_expenses_profitability_items(False)
self.assertDictEqual(
expense_profitability.get('revenues', {}),
{},
)
self.assertDictEqual(
expense_profitability['costs'],
{'id': 'expenses', 'sequence': expense_sequence, 'billed': -280.0, 'to_bill': 0.0},
{'id': 'expenses', 'sequence': expense_sequence, 'billed': -expense_foreign.untaxed_amount * 0.2, 'to_bill': 0.0},
)
expense_sheet._do_refuse('Test Cancel Expense')
expense_profitability = self.project._get_expenses_profitability_items(False)
expense_sheet_foreign._do_refuse('Test Cancel Expense')
expense_profitability = project._get_expenses_profitability_items(False)
self.assertDictEqual(
expense_profitability.get('revenues', {}),
{},
+3 -3
View File
@@ -33,10 +33,10 @@ class ProductTemplate(models.Model):
creating a new project based on the selected template.")
project_id = fields.Many2one(
'project.project', 'Project', company_dependent=True,
domain="[('company_id', '=', current_company_id)]")
domain="['|', ('company_id', '=', False), '&', ('company_id', '=?', company_id), ('company_id', '=', current_company_id)]")
project_template_id = fields.Many2one(
'project.project', 'Project Template', company_dependent=True, copy=True,
domain="[('company_id', '=', current_company_id)]")
'project.project', 'Project Template', copy=True,
domain="['|', ('company_id', '=', False), '&', ('company_id', '=?', company_id), ('company_id', '=', current_company_id)]")
service_policy = fields.Selection('_selection_service_policy', string="Service Invoicing Policy", compute='_compute_service_policy', inverse='_inverse_service_policy')
service_type = fields.Selection(selection_add=[
('milestones', 'Project Milestones'),
+26 -25
View File
@@ -2,13 +2,13 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
from collections import defaultdict
from datetime import date
from odoo import api, fields, models, _, _lt
from odoo.exceptions import ValidationError, AccessError
from odoo.osv import expression
from odoo.tools import Query
from datetime import date
from functools import reduce
from odoo.addons.project.models.project_task import CLOSED_STATES
@@ -20,7 +20,7 @@ class Project(models.Model):
sale_line_id = fields.Many2one(
'sale.order.line', 'Sales Order Item', copy=False,
compute="_compute_sale_line_id", store=True, readonly=False, index='btree_not_null',
domain="[('is_service', '=', True), ('is_expense', '=', False), ('state', '=', 'sale'), ('order_partner_id', '=?', partner_id), '|', ('company_id', '=', False), ('company_id', '=', company_id)]",
domain="[('is_service', '=', True), ('is_expense', '=', False), ('state', '=', 'sale'), ('order_partner_id', '=?', partner_id)]",
help="Sales order item that will be selected by default on the tasks and timesheets of this project,"
" except if the employee set on the timesheets is explicitely linked to another sales order item on the project.\n"
"It can be modified on each task and timesheet entry individually if necessary.")
@@ -38,9 +38,12 @@ class Project(models.Model):
defaults['sale_line_id'] = False
return defaults
@api.depends('allow_billable')
@api.depends('allow_billable', 'partner_id.company_id')
def _compute_partner_id(self):
self.filtered(lambda project: not project.allow_billable).partner_id = False
for project in self:
# Ensures that the partner_id and its project do not have different companies set
if not project.allow_billable or (project.company_id and project.partner_id.company_id and project.company_id != project.partner_id.company_id):
project.partner_id = False
@api.depends('partner_id')
def _compute_sale_line_id(self):
@@ -394,11 +397,12 @@ class Project(models.Model):
sequence_per_invoice_type = self._get_profitability_sequence_per_invoice_type()
if sale_line_read_group:
# Get conversion rate from currencies of the sale order lines to currency of project
convert_company = self.company_id or self.env.company
currency_ids = list(set([currency_id.id for currency_id, *_ in sale_line_read_group] + [self.currency_id.id]))
rates = self.env['res.currency'].browse(currency_ids)._get_rates(self.company_id, date.today())
rates = self.env['res.currency'].browse(currency_ids)._get_rates(convert_company, date.today())
conversion_rates = {cid: rates[self.currency_id.id] / rate_from for cid, rate_from in rates.items()}
sols_per_product = {}
sols_per_product = defaultdict(lambda: [0.0, 0.0, []])
downpayment_amount_invoiced = 0
downpayment_sol_ids = []
for currency, product, is_downpayment, sol_ids, untaxed_amount_to_invoice, untaxed_amount_invoiced in sale_line_read_group:
@@ -406,13 +410,9 @@ class Project(models.Model):
downpayment_amount_invoiced += untaxed_amount_invoiced * conversion_rates[currency.id]
downpayment_sol_ids += sol_ids
else:
sols_total_amounts = sols_per_product.setdefault(product.id, (0, 0, []))
sols_current_amounts = (
untaxed_amount_to_invoice * conversion_rates[currency.id],
untaxed_amount_invoiced * conversion_rates[currency.id],
sol_ids,
)
sols_per_product[product.id] = tuple(reduce(lambda x, y: x + y, pair) for pair in zip(sols_total_amounts, sols_current_amounts))
sols_per_product[product.id][0] += convert_company.currency_id.round(untaxed_amount_to_invoice * conversion_rates[currency.id])
sols_per_product[product.id][1] += convert_company.currency_id.round(untaxed_amount_invoiced * conversion_rates[currency.id])
sols_per_product[product.id][2] += sol_ids
if downpayment_amount_invoiced:
downpayments_data = {
'id': 'downpayments',
@@ -521,10 +521,9 @@ class Project(models.Model):
self._cr.execute(query_string, query_param)
invoices_move_line_read = self._cr.dictfetchall()
if invoices_move_line_read:
# Get conversion rate from currencies to currency of the project
# Get conversion rate from currencies to currency of the current company
currency_ids = {iml['currency_id'] for iml in invoices_move_line_read + [{'currency_id': self.currency_id.id}]}
rates = self.env['res.currency'].browse(list(currency_ids))._get_rates(self.company_id, date.today())
rates = self.env['res.currency'].browse(list(currency_ids))._get_rates(self.company_id or self.env.company, fields.Date.context_today(self))
conversion_rates = {cid: rates[self.currency_id.id] / rate_from for cid, rate_from in rates.items()}
move_ids = set()
@@ -602,33 +601,36 @@ class Project(models.Model):
def _get_stat_buttons(self):
buttons = super(Project, self)._get_stat_buttons()
if self.user_has_groups('sales_team.group_sale_salesman_all_leads'):
self_sudo = self.sudo()
buttons.append({
'icon': 'dollar',
'text': _lt('Sales Orders'),
'number': self.sale_order_count,
'number': self_sudo.sale_order_count,
'action_type': 'object',
'action': 'action_view_sos',
'show': self.sale_order_count > 0,
'show': self_sudo.sale_order_count > 0,
'sequence': 27,
})
if self.user_has_groups('account.group_account_readonly'):
self_sudo = self.sudo()
buttons.append({
'icon': 'pencil-square-o',
'text': _lt('Invoices'),
'number': self.invoice_count,
'number': self_sudo.invoice_count,
'action_type': 'object',
'action': 'action_open_project_invoices',
'show': bool(self.analytic_account_id) and self.invoice_count > 0,
'show': bool(self.analytic_account_id) and self_sudo.invoice_count > 0,
'sequence': 30,
})
if self.user_has_groups('account.group_account_readonly'):
self_sudo = self.sudo()
buttons.append({
'icon': 'pencil-square-o',
'text': _lt('Vendor Bills'),
'number': self.vendor_bill_count,
'number': self_sudo.vendor_bill_count,
'action_type': 'object',
'action': 'action_open_project_vendor_bills',
'show': self.vendor_bill_count > 0,
'show': self_sudo.vendor_bill_count > 0,
'sequence': 38,
})
return buttons
@@ -701,10 +703,9 @@ class ProjectTask(models.Model):
copy=True, tracking=True, index='btree_not_null', recursive=True,
compute='_compute_sale_line', store=True, readonly=False,
domain="""[
('company_id', '=', company_id),
'|', ('order_partner_id', 'child_of', partner_id if partner_id else []),
('order_partner_id', '=?', partner_id),
('is_service', '=', True), ('is_expense', '=', False), ('state', '=', 'sale')
('is_service', '=', True), ('is_expense', '=', False), ('state', '=', 'sale'),
]""",
help="Sales Order Item to which the time spent on this task will be added in order to be invoiced to your customer.\n"
"By default the sales order item set on the project will be selected. In the absence of one, the last prepaid sales order item that has time remaining will be used.\n"
@@ -80,7 +80,7 @@ class SaleOrderLine(models.Model):
if 'product_uom_qty' in values and not self.env.context.get('no_update_planned_hours', False):
for line in self:
if line.task_id and line.product_id.type == 'service':
planned_hours = line._convert_qty_company_hours(line.task_id.company_id)
planned_hours = line._convert_qty_company_hours(line.task_id.company_id or self.env.user.company_id)
line.task_id.write({'planned_hours': planned_hours})
return result
@@ -95,11 +95,11 @@ class SaleOrderLine(models.Model):
"""Generate project values"""
account = self.order_id.analytic_account_id
if not account:
service_products = self.order_id.order_line.product_id.filtered(lambda p: p.type == 'service' and p.default_code)
service_products = self.order_id.order_line.product_id.filtered(
lambda p: p.type == 'service' and p.default_code)
default_code = service_products.default_code if len(service_products) == 1 else None
self.order_id._create_analytic_account(prefix=default_code)
account = self.order_id.analytic_account_id
# create the project or duplicate one
return {
'name': '%s - %s' % (self.order_id.client_order_ref, self.order_id.name) if self.order_id.client_order_ref else self.order_id.name,
File diff suppressed because it is too large Load Diff
@@ -451,3 +451,51 @@ class TestSaleProject(TestSaleProjectCommon):
sale_order.action_confirm()
self.assertEqual(sale_order.tasks_count, 2, "SO should have 2 related tasks")
self.assertNotIn(default_task, sale_order.tasks_ids, "SO should link to the default task from the context")
def test_project_creation_on_so_confirm_with_account(self):
# Ensures that the company of the account of the SO is propagated to the project.
sale_order = self.env['sale.order'].with_context(tracking_disable=True).create({
'partner_id': self.partner.id,
'partner_invoice_id': self.partner.id,
'partner_shipping_id': self.partner.id,
})
analytic_account_company = self.env['account.analytic.account'].create({
'name': 'Account with company',
'plan_id': self.analytic_plan.id,
'company_id': self.env.company.id,
})
sale_order.analytic_account_id = analytic_account_company
self.env['sale.order.line'].create({
'name': self.product_order_service2.name,
'product_id': self.product_order_service3.id,
'order_id': sale_order.id,
})
self.assertTrue(sale_order.analytic_account_id, "The SO should have an analytic account before it is confirmed.")
sale_order.action_confirm()
self.assertEqual(self.env.company, sale_order.analytic_account_id.company_id, "The company of the account should be the company of the SO.")
self.assertEqual(sale_order.analytic_account_id, sale_order.project_ids.analytic_account_id, "The project created for the SO and the SO should have the same account.")
self.assertEqual(self.env.company, sale_order.project_ids.company_id, "The project created for the SO should have the same company as its account.")
def test_project_creation_on_so_confirm_with_default_plan_with_company_in_setting(self):
#This test ensures that the plan of the created account is the default plan of the setting, and that the company is correctly propagated
sale_order = self.env['sale.order'].with_context(tracking_disable=True).create({
'partner_id': self.partner.id,
'partner_invoice_id': self.partner.id,
'partner_shipping_id': self.partner.id,
})
self.env['sale.order.line'].create({
'name': self.product_order_service2.name,
'product_id': self.product_order_service3.id,
'order_id': sale_order.id,
})
analytic_plan = self.env['account.analytic.plan'].create({
'name': 'Plan 1',
'default_applicability': 'optional',
'company_id': self.env.company.id,
})
self.env['ir.config_parameter'].sudo().set_param("default_analytic_plan_id_%s" % self.env.company.id, analytic_plan.id)
self.assertFalse(sale_order.analytic_account_id, "The SO should not have any analytic account before it is confirmed.")
sale_order.action_confirm()
self.assertEqual(sale_order.analytic_account_id.company_id, analytic_plan.company_id, "The company_id of the account created should be the company of the default analytic plan of the setting.")
self.assertEqual(sale_order.analytic_account_id.plan_id, analytic_plan, "The plan of the account created should be the default analytic plan of the setting")
self.assertEqual(sale_order.analytic_account_id, sale_order.project_ids.analytic_account_id, "The project created for the SO and the SO should have the same account.")
+1
View File
@@ -9,3 +9,4 @@ from . import project_update
from . import sale_order
from . import res_config_settings
from . import project_sale_line_employee_map
from . import hr_employee
@@ -0,0 +1,15 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
class HrEmployee(models.Model):
_inherit = 'hr.employee'
@api.model
def default_get(self, fields):
result = super(HrEmployee, self).default_get(fields)
project_company_id = self.env.context.get('create_project_employee_mapping', False)
if project_company_id:
result['company_id'] = project_company_id
return result
+2 -2
View File
@@ -19,8 +19,8 @@ class ProductTemplate(models.Model):
('timesheet', 'Timesheets on project (one fare per SO/Project)'),
], ondelete={'timesheet': 'set manual'})
# override domain
project_id = fields.Many2one(domain="[('company_id', '=', current_company_id), ('allow_billable', '=', True), ('pricing_type', '=', 'task_rate'), ('allow_timesheets', 'in', [service_policy == 'delivered_timesheet', True])]")
project_template_id = fields.Many2one(domain="[('company_id', '=', current_company_id), ('allow_billable', '=', True), ('allow_timesheets', 'in', [service_policy == 'delivered_timesheet', True])]")
project_id = fields.Many2one(domain="['|', ('company_id', '=', False), '&', ('company_id', '=?', company_id), ('company_id', '=', current_company_id), ('allow_billable', '=', True), ('pricing_type', '=', 'task_rate'), ('allow_timesheets', 'in', [service_policy == 'delivered_timesheet', True])]")
project_template_id = fields.Many2one(domain="['|', ('company_id', '=', False), '&', ('company_id', '=?', company_id), ('company_id', '=', current_company_id), ('allow_billable', '=', True), ('allow_timesheets', 'in', [service_policy == 'delivered_timesheet', True])]")
service_upsell_threshold = fields.Float('Threshold', default=1, help="Percentage of time delivered compared to the prepaid amount that must be reached for the upselling opportunity activity to be triggered.")
service_upsell_threshold_ratio = fields.Char(compute='_compute_service_upsell_threshold_ratio')
+15 -5
View File
@@ -405,14 +405,25 @@ class Project(models.Model):
return profitability_items
aa_line_read_group = self.env['account.analytic.line'].sudo()._read_group(
self.sudo()._get_profitability_aal_domain(),
['timesheet_invoice_type'],
['amount:sum', 'id:array_agg'])
['timesheet_invoice_type', 'timesheet_invoice_id', 'currency_id'],
['amount:sum', 'id:array_agg'],
)
can_see_timesheets = with_action and len(self) == 1 and self.user_has_groups('hr_timesheet.group_hr_timesheet_approver')
revenues_dict = {}
costs_dict = {}
total_revenues = {'invoiced': 0.0, 'to_invoice': 0.0}
total_costs = {'billed': 0.0, 'to_bill': 0.0}
for invoice_type, amount, ids in aa_line_read_group:
dict_rate_per_currency = {}
today = fields.Date.context_today(self)
convert_company = self.company_id or self.env.company
for timesheet_invoice_type, dummy, currency, amount, ids in aa_line_read_group:
if currency != self.currency_id:
rate = dict_rate_per_currency.get(currency.id, False)
if not rate:
rate = currency._get_conversion_rate(currency, self.currency_id, convert_company, today)
dict_rate_per_currency[currency.id] = rate
amount = self.currency_id.round(amount * rate)
invoice_type = timesheet_invoice_type
cost = costs_dict.setdefault(invoice_type, {'billed': 0.0, 'to_bill': 0.0})
revenue = revenues_dict.setdefault(invoice_type, {'invoiced': 0.0, 'to_invoice': 0.0})
if amount < 0: # cost
@@ -424,7 +435,6 @@ class Project(models.Model):
if can_see_timesheets and invoice_type not in ['other_costs', 'other_revenues']:
cost.setdefault('record_ids', []).extend(ids)
revenue.setdefault('record_ids', []).extend(ids)
action_name = None
if can_see_timesheets:
action_name = 'action_profitability_items'
@@ -591,7 +601,7 @@ class ProjectTask(models.Model):
if not self.partner_id.commercial_partner_id or not self.allow_billable:
return False
domain = [
('company_id', '=', self.company_id.id),
('company_id', '=?', self.company_id.id),
('is_service', '=', True),
('order_partner_id', 'child_of', self.partner_id.commercial_partner_id.id),
('is_expense', '=', False),
@@ -18,8 +18,7 @@ class ProjectProductEmployeeMap(models.Model):
('is_service', '=', True),
('is_expense', '=', False),
('state', '=', 'sale'),
('order_partner_id', '=?', partner_id),
'|', ('company_id', '=', False), ('company_id', '=', company_id)]""")
('order_partner_id', '=?', partner_id)]""")
sale_order_id = fields.Many2one(related="project_id.sale_order_id")
company_id = fields.Many2one('res.company', string='Company', related='project_id.company_id')
partner_id = fields.Many2one(related='project_id.partner_id')
@@ -231,7 +231,6 @@ class SaleOrderLine(models.Model):
@api.depends('analytic_line_ids.project_id', 'project_id.pricing_type')
def _compute_qty_delivered(self):
super(SaleOrderLine, self)._compute_qty_delivered()
lines_by_timesheet = self.filtered(lambda sol: sol.qty_delivered_method == 'timesheet')
domain = lines_by_timesheet._timesheet_compute_delivered_quantity_domain()
mapping = lines_by_timesheet.sudo()._get_delivered_quantity_by_analytic(domain)
@@ -19,11 +19,32 @@ class TestSaleTimesheetProjectProfitability(TestCommonSaleTimesheet):
'revenues': {'data': [], 'total': {'to_invoice': 0.0, 'invoiced': 0.0}},
'costs': {'data': [], 'total': {'to_bill': 0.0, 'billed': 0.0}},
}
cls.foreign_currency = cls.env['res.currency'].create({
'name': 'Chaos orb',
'symbol': '☺',
'rounding': 0.001,
'position': 'after',
'currency_unit_label': 'Chaos',
'currency_subunit_label': 'orb',
})
cls.env['res.currency.rate'].create({
'name': '2016-01-01',
'rate': '5.0',
'currency_id': cls.foreign_currency.id,
'company_id': cls.env.company.id,
})
def test_get_project_profitability_items(self):
""" Test _get_project_profitability_items method to ensure the project profitability
is computed as expected.
"""
foreign_company = self.company_data_2['company']
foreign_company.currency_id = self.foreign_currency
self.project_task_rate.analytic_account_id.plan_id.company_id = False
self.project_task_rate.analytic_account_id.company_id = False
self.project_task_rate.company_id = False
# Create and confirm a SO with the main company
sale_order = self.env['sale.order'].with_context(mail_notrack=True, mail_create_nolog=True).create({
'partner_id': self.partner_b.id,
'partner_invoice_id': self.partner_b.id,
@@ -42,20 +63,55 @@ class TestSaleTimesheetProjectProfitability(TestCommonSaleTimesheet):
'No timesheets has been recorded in the task and no product has been deelivered in the SO linked so the project profitability has no data found.'
)
# Create and confirm a SO with the foreign company
sale_order_foreign = self.env['sale.order'].with_context(mail_notrack=True, mail_create_nolog=True).create({
'partner_id': self.partner_b.id,
'partner_invoice_id': self.partner_b.id,
'partner_shipping_id': self.partner_b.id,
'company_id': foreign_company.id,
})
sale_order_foreign.currency_id = foreign_company.currency_id
SaleOrderLineForeign = self.env['sale.order.line'].with_context(tracking_disable=True, default_order_id=sale_order_foreign.id)
SaleOrderLineForeign.create({
'product_id': self.product_delivery_manual1.id,
'product_uom_qty': 5,
})
sale_order_foreign.action_confirm()
self.task.write({'sale_line_id': delivery_service_order_line.id})
# Create the foreign users needed for the foreign timesheets
foreign_partner = self.env['res.partner'].create({
'name': 'Foreign Employee address',
'company_id': foreign_company.id,
})
foreign_employee = self.env['hr.employee'].create({
'name': 'test',
'company_id': foreign_company.id,
'work_contact_id': foreign_partner.id,
'hourly_cost': 200,
})
foreign_employee_2 = self.env['hr.employee'].create({
'name': 'test',
'company_id': foreign_company.id,
'work_contact_id': foreign_partner.id,
'hourly_cost': 500,
})
# Create 2 new timesheets linked to the task of the project
Timesheet = self.env['account.analytic.line'].with_context(
default_task_id=self.task.id,
)
timesheet1 = Timesheet.create({
'name': 'Timesheet 1',
'employee_id': self.employee_user.id,
foreign_timesheet1 = Timesheet.create({
'name': 'Foreign Timesheet 1',
'employee_id': foreign_employee.id,
'project_id': self.project_task_rate.id,
'unit_amount': 3.0,
'company_id': foreign_company.id,
})
timesheet2 = Timesheet.create({
'name': 'Timesheet 2',
'employee_id': self.employee_user.id,
foreign_timesheet2 = Timesheet.create({
'name': 'Foreign Timesheet 2',
'employee_id': foreign_employee.id,
'project_id': self.project_task_rate.id,
'unit_amount': 2.0,
'company_id': foreign_company.id,
})
sequence_per_invoice_type = self.project_task_rate._get_profitability_sequence_per_invoice_type()
@@ -63,9 +119,8 @@ class TestSaleTimesheetProjectProfitability(TestCommonSaleTimesheet):
self.assertIn('billable_fixed', sequence_per_invoice_type)
self.assertIn('billable_milestones', sequence_per_invoice_type)
self.assertIn('billable_manual', sequence_per_invoice_type)
self.assertEqual(self.task.sale_line_id, delivery_service_order_line)
self.assertEqual((timesheet1 + timesheet2).so_line, delivery_service_order_line)
self.assertEqual((foreign_timesheet1 + foreign_timesheet2).so_line, delivery_service_order_line)
self.assertEqual(delivery_service_order_line.qty_delivered, 0.0, 'The service type is not timesheet but manual so the quantity delivered is not increased by the timesheets linked.')
# Adding an extra cost/revenue to ensure those are computed correctly.
@@ -91,18 +146,107 @@ class TestSaleTimesheetProjectProfitability(TestCommonSaleTimesheet):
{
'id': 'billable_manual',
'sequence': sequence_per_invoice_type['billable_manual'],
'billed': (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost,
'billed': (foreign_timesheet1.unit_amount + foreign_timesheet2.unit_amount) * -foreign_employee.hourly_cost * 0.2,
'to_bill': 0.0,
},
],
'total': {
'to_bill': 0.0,
'billed': -100 + (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost
'billed': -100 + (foreign_timesheet1.unit_amount + foreign_timesheet2.unit_amount) * -foreign_employee.hourly_cost * 0.2
},
},
}
)
# Create 2 new timesheets linked to the task of the project
timesheet1 = Timesheet.create({
'name': 'Timesheet 1',
'employee_id': self.employee_user.id,
'project_id': self.project_task_rate.id,
'unit_amount': 3.0,
})
timesheet2 = Timesheet.create({
'name': 'Timesheet 2',
'employee_id': self.employee_user.id,
'project_id': self.project_task_rate.id,
'unit_amount': 2.0,
})
self.assertEqual((timesheet1 + timesheet2).so_line, delivery_service_order_line)
self.assertEqual(delivery_service_order_line.qty_delivered, 0.0, 'The service type is not timesheet but manual so the quantity delivered is not increased by the timesheets linked.')
self.assertDictEqual(
self.project_task_rate._get_profitability_items(False),
{
'revenues': {
'data': [{'id': 'other_revenues', 'sequence': sequence_per_invoice_type['other_revenues'], 'invoiced': 100.0, 'to_invoice': 0.0}],
'total': {'invoiced': 100.0, 'to_invoice': 0.0},
},
'costs': {
'data': [
{'id': 'other_costs', 'sequence': sequence_per_invoice_type['other_costs'], 'billed': -100.0, 'to_bill': 0.0},
{
'id': 'billable_manual',
'sequence': sequence_per_invoice_type['billable_manual'],
'billed': (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost +
(foreign_timesheet1.unit_amount + foreign_timesheet2.unit_amount) * -foreign_employee.hourly_cost * 0.2,
'to_bill': 0.0,
},
],
'total': {
'to_bill': 0.0,
'billed': -100 + (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost +
(foreign_timesheet1.unit_amount + foreign_timesheet2.unit_amount) * -foreign_employee.hourly_cost * 0.2
},
},
}
)
# Create a 3rd foreign timesheet and manually update it.
foreign_timesheet3 = Timesheet.create({
'name': 'Foreign_Timesheet 3',
'employee_id': foreign_employee_2.id,
'project_id': self.project_task_rate.id,
'unit_amount': 1.0,
'so_line': False,
'is_so_line_edited': True,
'company_id': foreign_company.id,
})
self.assertFalse(foreign_timesheet3.so_line, 'This timesheet should be non billable since the user manually empty the SOL.')
self.assertDictEqual(
self.project_task_rate._get_profitability_items(False),
{
'revenues': {
'data': [{'id': 'other_revenues', 'sequence': sequence_per_invoice_type['other_revenues'], 'invoiced': 100.0, 'to_invoice': 0.0}],
'total': {'invoiced': 100.0, 'to_invoice': 0.0},
},
'costs': {
'data': [
{'id': 'other_costs', 'sequence': sequence_per_invoice_type['other_costs'], 'billed': -100.0, 'to_bill': 0.0},
{
'id': 'billable_manual',
'sequence': sequence_per_invoice_type['billable_manual'],
'billed': (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost +
(foreign_timesheet1.unit_amount + foreign_timesheet2.unit_amount) * -foreign_employee.hourly_cost * 0.2,
'to_bill': 0.0,
},
{
'id': 'non_billable',
'sequence': sequence_per_invoice_type['non_billable'],
'billed': foreign_timesheet3.unit_amount * -foreign_employee_2.hourly_cost * 0.2,
'to_bill': 0.0,
},
],
'total': {
'to_bill': 0.0,
'billed': -100 + (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost +
(foreign_timesheet1.unit_amount + foreign_timesheet2.unit_amount) * -foreign_employee.hourly_cost * 0.2 +
foreign_timesheet3.unit_amount * -foreign_employee_2.hourly_cost * 0.2
},
},
}
)
# Create a 3rd timesheet and manually update it.
timesheet3 = Timesheet.create({
'name': 'Timesheet 3',
'employee_id': self.employee_manager.id,
@@ -126,45 +270,52 @@ class TestSaleTimesheetProjectProfitability(TestCommonSaleTimesheet):
{
'id': 'billable_manual',
'sequence': sequence_per_invoice_type['billable_manual'],
'billed': (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost,
'billed': (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost +
(foreign_timesheet1.unit_amount + foreign_timesheet2.unit_amount) * -foreign_employee.hourly_cost * 0.2,
'to_bill': 0.0,
},
{
'id': 'non_billable',
'sequence': sequence_per_invoice_type['non_billable'],
'billed': timesheet3.unit_amount * -self.employee_manager.hourly_cost,
'billed': foreign_timesheet3.unit_amount * -foreign_employee_2.hourly_cost * 0.2 + timesheet3.unit_amount * -self.employee_manager.hourly_cost,
'to_bill': 0.0,
},
],
'total': {
'to_bill': 0.0,
'billed':
-100
+ (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost
+ timesheet3.unit_amount * -self.employee_manager.hourly_cost,
'billed': -100 + (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost +
(foreign_timesheet1.unit_amount + foreign_timesheet2.unit_amount) * -foreign_employee.hourly_cost * 0.2 +
foreign_timesheet3.unit_amount * -foreign_employee_2.hourly_cost * 0.2 + timesheet3.unit_amount * -self.employee_manager.hourly_cost
},
},
},
'The previous costs should remains and the cost of the third timesheet should be added.'
}
)
delivery_timesheet_order_line = SaleOrderLine.create({
# Create a new foreign sol, and link this sol to the so_line of the task.
foreign_delivery_timesheet_order_line = SaleOrderLineForeign.create({
'product_id': self.product_delivery_timesheet1.id,
'product_uom_qty': 5,
})
self.task.write({'sale_line_id': delivery_timesheet_order_line.id})
billable_timesheets = timesheet1 + timesheet2
self.assertEqual(billable_timesheets.so_line, delivery_timesheet_order_line, 'The SOL of the timesheets should be the one of the task.')
self.assertEqual(delivery_timesheet_order_line.qty_delivered, timesheet1.unit_amount + timesheet2.unit_amount)
self.assertEqual(
self.task.write({'sale_line_id': foreign_delivery_timesheet_order_line.id})
billable_timesheets = timesheet1 + timesheet2 + foreign_timesheet1 + foreign_timesheet2
self.assertEqual(billable_timesheets.so_line, foreign_delivery_timesheet_order_line, 'The SOL of the timesheets should be the one of the task.')
self.assertEqual(foreign_delivery_timesheet_order_line.qty_delivered, timesheet1.unit_amount + timesheet2.unit_amount + foreign_timesheet1.unit_amount + foreign_timesheet2.unit_amount,
'Since the product type of the SOL is "delivered on TS", the qty_delivered of the SOL should be the total of unit amount of the TS.')
self.assertDictEqual(
self.project_task_rate._get_profitability_items(False),
{
'revenues': {
'data': [
{'id': 'other_revenues', 'sequence': sequence_per_invoice_type['other_revenues'], 'invoiced': 100.0, 'to_invoice': 0.0},
{'id': 'billable_time', 'sequence': sequence_per_invoice_type['billable_time'], 'to_invoice': delivery_timesheet_order_line.untaxed_amount_to_invoice, 'invoiced': 0.0},
{
'id': 'billable_time',
'sequence': sequence_per_invoice_type['billable_time'],
'to_invoice': foreign_delivery_timesheet_order_line.untaxed_amount_to_invoice * 0.2,
'invoiced': 0.0
},
],
'total': {'invoiced': 100.0, 'to_invoice': delivery_timesheet_order_line.untaxed_amount_to_invoice},
'total': {'invoiced': 100.0, 'to_invoice': foreign_delivery_timesheet_order_line.untaxed_amount_to_invoice * 0.2},
},
'costs': {
'data': [
@@ -172,56 +323,170 @@ class TestSaleTimesheetProjectProfitability(TestCommonSaleTimesheet):
{
'id': 'billable_time',
'sequence': sequence_per_invoice_type['billable_time'],
'billed': (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost,
'billed': (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost +
(foreign_timesheet1.unit_amount + foreign_timesheet2.unit_amount) * -foreign_employee.hourly_cost * 0.2,
'to_bill': 0.0,
},
{
'id': 'non_billable',
'sequence': sequence_per_invoice_type['non_billable'],
'billed': timesheet3.unit_amount * -self.employee_manager.hourly_cost,
'billed': foreign_timesheet3.unit_amount * -foreign_employee_2.hourly_cost * 0.2 + timesheet3.unit_amount * -self.employee_manager.hourly_cost,
'to_bill': 0.0,
},
],
'total': {
'to_bill': 0.0,
'billed':
-100
+ (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost
+ timesheet3.unit_amount * -self.employee_manager.hourly_cost,
'billed': -100 + (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost +
(foreign_timesheet1.unit_amount + foreign_timesheet2.unit_amount) * -foreign_employee.hourly_cost * 0.2 +
foreign_timesheet3.unit_amount * -foreign_employee_2.hourly_cost * 0.2 + timesheet3.unit_amount * -self.employee_manager.hourly_cost
},
},
},
}
)
milestone_order_line = SaleOrderLine.create({
'product_id': self.product_milestone.id,
'product_uom_qty': 1,
# Create a new task in the project, link to it a new SO form the main company SO with a delivery timesheet product.
delivery_timesheet_order_line = SaleOrderLine.create({
'product_id': self.product_delivery_timesheet1.id,
'product_uom_qty': 5,
})
task2 = self.env['project.task'].with_context({'mail_create_nolog': True}).create({
'name': 'Test',
task_2 = self.env['project.task'].with_context({'mail_create_nolog': True}).create({
'name': 'Task 2',
'project_id': self.project_task_rate.id,
'sale_line_id': milestone_order_line.id,
'sale_line_id': delivery_timesheet_order_line.id,
})
task2_timesheet = Timesheet.with_context(default_task_id=task2.id).create({
task2_timesheet = Timesheet.with_context(default_task_id=task_2.id).create({
'name': '/',
'project_id': self.project_task_rate.id,
'employee_id': self.employee_user.id,
'unit_amount': 1,
})
self.assertEqual(task2_timesheet.so_line, milestone_order_line)
self.assertNotEqual(delivery_timesheet_order_line.untaxed_amount_to_invoice, 0.0)
self.assertDictEqual(
self.project_task_rate._get_profitability_items(False),
{
'revenues': {
'data': [
{'id': 'other_revenues', 'sequence': sequence_per_invoice_type['other_revenues'],
'invoiced': 100.0, 'to_invoice': 0.0},
{
'id': 'billable_time',
'sequence': sequence_per_invoice_type['billable_time'],
'to_invoice': delivery_timesheet_order_line.untaxed_amount_to_invoice + foreign_delivery_timesheet_order_line.untaxed_amount_to_invoice * 0.2,
'invoiced': 0.0
},
],
'total': {'invoiced': 100.0, 'to_invoice': foreign_delivery_timesheet_order_line.untaxed_amount_to_invoice * 0.2 + delivery_timesheet_order_line.untaxed_amount_to_invoice},
},
'costs': {
'data': [
{'id': 'other_costs', 'sequence': sequence_per_invoice_type['other_costs'], 'billed': -100.0,
'to_bill': 0.0},
{
'id': 'billable_time',
'sequence': sequence_per_invoice_type['billable_time'],
'billed': (timesheet1.unit_amount + timesheet2.unit_amount + task2_timesheet.unit_amount) * -self.employee_user.hourly_cost +
(foreign_timesheet1.unit_amount + foreign_timesheet2.unit_amount) * -foreign_employee.hourly_cost * 0.2,
'to_bill': 0.0,
},
{
'id': 'non_billable',
'sequence': sequence_per_invoice_type['non_billable'],
'billed': foreign_timesheet3.unit_amount * -foreign_employee_2.hourly_cost * 0.2 + timesheet3.unit_amount * -self.employee_manager.hourly_cost,
'to_bill': 0.0,
},
],
'total': {
'to_bill': 0.0,
'billed': -100 + (timesheet1.unit_amount + timesheet2.unit_amount + task2_timesheet.unit_amount) * -self.employee_user.hourly_cost +
(foreign_timesheet1.unit_amount + foreign_timesheet2.unit_amount) * -foreign_employee.hourly_cost * 0.2 +
foreign_timesheet3.unit_amount * -foreign_employee_2.hourly_cost * 0.2 + timesheet3.unit_amount * -self.employee_manager.hourly_cost
},
},
}
)
# Create a SOL in the foreign SO with a milestone service product.
milestone_foreign_order_line = SaleOrderLineForeign.create({
'product_id': self.product_milestone.id,
'product_uom_qty': 1,
})
task2_foreign = self.env['project.task'].with_context({'mail_create_nolog': True}).create({
'name': 'Test',
'project_id': self.project_task_rate.id,
'sale_line_id': milestone_foreign_order_line.id,
})
task2_foreign_timesheet = Timesheet.with_context(default_task_id=task2_foreign.id).create({
'name': '/',
'project_id': self.project_task_rate.id,
'employee_id': foreign_employee.id,
'unit_amount': 1,
})
self.assertEqual(task2_foreign_timesheet.so_line, milestone_foreign_order_line)
profitability_items = self.project_task_rate._get_profitability_items(False)
self.assertFalse([data for data in profitability_items['revenues']['data'] if data['id'] == 'billable_milestones'])
self.assertDictEqual(
[data for data in profitability_items['costs']['data'] if data['id'] == 'billable_milestones'][0],
{'id': 'billable_milestones', 'sequence': sequence_per_invoice_type['billable_milestones'], 'to_bill': 0.0, 'billed': task2_timesheet.amount},
{'id': 'billable_milestones', 'sequence': sequence_per_invoice_type['billable_milestones'], 'to_bill': 0.0, 'billed': task2_foreign_timesheet.amount * 0.2},
)
milestone_foreign_order_line.qty_delivered = 1
profitability_items = self.project_task_rate._get_profitability_items(False)
self.assertDictEqual(
[data for data in profitability_items['revenues']['data'] if data['id'] == 'billable_milestones'][0],
{'id': 'billable_milestones', 'sequence': sequence_per_invoice_type['billable_milestones'],
'to_invoice': milestone_foreign_order_line.untaxed_amount_to_invoice * 0.2, 'invoiced': 0.0},
)
# Create a second timesheet in the new task, with an employee from the main company.
task2_timesheet = Timesheet.with_context(default_task_id=task2_foreign.id).create({
'name': '/',
'project_id': self.project_task_rate.id,
'employee_id': self.employee_user.id,
'unit_amount': 1,
})
profitability_items = self.project_task_rate._get_profitability_items(False)
self.assertDictEqual(
[data for data in profitability_items['costs']['data'] if data['id'] == 'billable_milestones'][0],
{'id': 'billable_milestones', 'sequence': sequence_per_invoice_type['billable_milestones'], 'to_bill': 0.0,
'billed': task2_timesheet.amount + task2_foreign_timesheet.amount * 0.2},
)
milestone_foreign_order_line.qty_delivered = 2
profitability_items = self.project_task_rate._get_profitability_items(False)
self.assertDictEqual(
[data for data in profitability_items['revenues']['data'] if data['id'] == 'billable_milestones'][0],
{'id': 'billable_milestones', 'sequence': sequence_per_invoice_type['billable_milestones'],
'to_invoice': milestone_foreign_order_line.untaxed_amount_to_invoice * 0.2, 'invoiced': 0.0},
)
# Create a SOL in the foreign SO with a milestone service product.
milestone_order_line = SaleOrderLine.create({
'product_id': self.product_milestone.id,
'product_uom_qty': 1,
})
task3_milestone = self.env['project.task'].with_context({'mail_create_nolog': True}).create({
'name': 'Task 3',
'project_id': self.project_task_rate.id,
'sale_line_id': milestone_order_line.id,
})
task3_timesheet = Timesheet.with_context(default_task_id=task3_milestone.id).create({
'name': '/',
'project_id': self.project_task_rate.id,
'employee_id': self.employee_user.id,
'unit_amount': 1,
})
profitability_items = self.project_task_rate._get_profitability_items(False)
self.assertDictEqual(
[data for data in profitability_items['costs']['data'] if data['id'] == 'billable_milestones'][0],
{'id': 'billable_milestones', 'sequence': sequence_per_invoice_type['billable_milestones'], 'to_bill': 0.0,
'billed': task2_timesheet.amount + task2_foreign_timesheet.amount * 0.2 + task3_timesheet.amount},
)
milestone_order_line.qty_delivered = 1
profitability_items = self.project_task_rate._get_profitability_items(False)
self.assertDictEqual(
[data for data in profitability_items['revenues']['data'] if data['id'] == 'billable_milestones'][0],
{'id': 'billable_milestones', 'sequence': sequence_per_invoice_type['billable_milestones'], 'to_invoice': milestone_order_line.untaxed_amount_to_invoice, 'invoiced': 0.0},
{'id': 'billable_milestones', 'sequence': sequence_per_invoice_type['billable_milestones'],
'to_invoice': milestone_foreign_order_line.untaxed_amount_to_invoice * 0.2 + milestone_order_line.untaxed_amount_to_invoice, 'invoiced': 0.0},
)
# Cancel the milestone timesheets
task2_timesheet.unlink()
task2_foreign_timesheet.unlink()
task3_timesheet.unlink()
profitability_items = self.project_task_rate._get_profitability_items(False)
self.assertFalse([data for data in profitability_items['revenues']['data'] if data['id'] == 'billable_milestones'])
self.assertFalse([data for data in profitability_items['costs']['data'] if data['id'] == 'billable_milestones'])
@@ -17,7 +17,7 @@
<field name="company_id" invisible="1"/>
<field name="partner_id" invisible="1"/>
<field name="sale_order_id" invisible="1"/>
<field name="employee_id" widget="many2one_avatar_user"/>
<field name="employee_id" widget="many2one_avatar_user" context="{'create_project_employee_mapping': company_id}"/>
<field name="existing_employee_ids" invisible="1"/>
<field name="sale_line_id" attrs="{'required': True}" options="{'no_create': True}" context="{'search_default_order_id': sale_order_id}"/>
<field name="price_unit" widget="monetary" force_save="1" options="{'currency_field': 'currency_id'}"/>
@@ -115,7 +115,7 @@ class ProjectCreateSalesOrder(models.TransientModel):
'partner_id': self.partner_id.id,
'analytic_account_id': self.project_id.analytic_account_id.id,
'client_order_ref': self.project_id.name,
'company_id': self.project_id.company_id.id,
'company_id': self.project_id.company_id.id or self.env.company.id,
})
# rewrite the user as the onchange_partner_id erases it
sale_order.write({'user_id': self.project_id.user_id.id})