[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:
@@ -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"/>
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 & 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)])
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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]):
|
||||
|
||||
@@ -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"/>&nbsp;<field class="d-inline" name="access_instruction_message" nolabel="1"/>
|
||||
|
||||
@@ -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.'
|
||||
)
|
||||
|
||||
@@ -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.',
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', {}),
|
||||
{},
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user