diff --git a/addons/analytic/models/analytic_mixin.py b/addons/analytic/models/analytic_mixin.py index 33f3dc4c7f2..1cf4154bcb0 100644 --- a/addons/analytic/models/analytic_mixin.py +++ b/addons/analytic/models/analytic_mixin.py @@ -29,7 +29,7 @@ class AnalyticMixin(models.AbstractModel): FROM information_schema.tables WHERE table_name=%s ''' self.env.cr.execute(query, [self._table]) - if self.env.cr.dictfetchone(): + if self.env.cr.dictfetchone() and self._fields['analytic_distribution'].store: query = f""" CREATE INDEX IF NOT EXISTS {self._table}_analytic_distribution_gin_index ON {self._table} USING gin(analytic_distribution); diff --git a/addons/mrp_account/models/analytic_account.py b/addons/mrp_account/models/analytic_account.py index 1cf3e213eed..1d87481aa5d 100644 --- a/addons/mrp_account/models/analytic_account.py +++ b/addons/mrp_account/models/analytic_account.py @@ -8,11 +8,11 @@ class AccountAnalyticAccount(models.Model): _inherit = 'account.analytic.account' _description = 'Analytic Account' - production_ids = fields.One2many('mrp.production', 'analytic_account_id', string='Manufacturing Orders') + production_ids = fields.Many2many('mrp.production') production_count = fields.Integer("Manufacturing Orders Count", compute='_compute_production_count') - bom_ids = fields.One2many('mrp.bom', 'analytic_account_id', string='Bills of Materials') + bom_ids = fields.Many2many('mrp.bom') bom_count = fields.Integer("BoM Count", compute='_compute_bom_count') - workcenter_ids = fields.One2many('mrp.workcenter', 'costs_hour_account_id', string='Workcenters') + workcenter_ids = fields.Many2many('mrp.workcenter') workorder_count = fields.Integer("Work Order Count", compute='_compute_workorder_count') @api.depends('production_ids') @@ -77,3 +77,14 @@ class AccountAnalyticLine(models.Model): _inherit = 'account.analytic.line' category = fields.Selection(selection_add=[('manufacturing_order', 'Manufacturing Order')]) + +class AccountAnalyticApplicability(models.Model): + _inherit = 'account.analytic.applicability' + _description = "Analytic Plan's Applicabilities" + + business_domain = fields.Selection( + selection_add=[ + ('manufacturing_order', 'Manufacturing Order'), + ], + ondelete={'manufacturing_order': 'cascade'}, + ) diff --git a/addons/mrp_account/models/mrp_bom.py b/addons/mrp_account/models/mrp_bom.py index 8ab3aba569c..7f23d2d1774 100644 --- a/addons/mrp_account/models/mrp_bom.py +++ b/addons/mrp_account/models/mrp_bom.py @@ -1,10 +1,48 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import json + +from odoo import models, api, fields -from odoo import fields, models class MrpBom(models.Model): - _inherit = 'mrp.bom' + _name = 'mrp.bom' + _inherit = ['mrp.bom', 'analytic.mixin'] - analytic_account_id = fields.Many2one('account.analytic.account', 'Analytic Account', company_dependent=True, copy=True, - help="Analytic account in which cost and revenue entries will take place for financial management of the manufacturing order.") + # Company dependent JSON fields are not yet supported + analytic_distribution_text = fields.Text(company_dependent=True) + analytic_distribution = fields.Json(inverse="_inverse_analytic_distribution", store=False, precompute=False) + analytic_account_ids = fields.Many2many('account.analytic.account', compute="_compute_analytic_account_ids", copy=True) + + @api.depends_context('company') + @api.depends('analytic_distribution_text') + def _compute_analytic_distribution(self): + for record in self: + record.analytic_distribution = json.loads(record.analytic_distribution_text or '{}') + + def _inverse_analytic_distribution(self): + for record in self: + record.analytic_distribution_text = json.dumps(record.analytic_distribution) + + @api.depends('analytic_distribution') + def _compute_analytic_account_ids(self): + for record in self: + record.analytic_account_ids = list(map(int, record.analytic_distribution.keys())) if record.analytic_distribution else [] + + @api.onchange('product_id') + def _onchange_analytic_distribution(self): + for record in self: + if record.product_id: + record.analytic_distribution = record.env['account.analytic.distribution.model']._get_distribution({ + "product_id": record.product_id.id, + "product_categ_id": record.product_id.categ_id.id, + "company_id": record.company_id.id, + }) + + @api.constrains('analytic_distribution') + def _check_analytic(self): + for record in self: + record.with_context({'validate_analytic': True})._validate_distribution(**{ + 'product': record.product_id.id, + 'company_id': record.company_id.id, + }) diff --git a/addons/mrp_account/models/mrp_production.py b/addons/mrp_account/models/mrp_production.py index 10ee676c9ca..3ebb7d484e4 100644 --- a/addons/mrp_account/models/mrp_production.py +++ b/addons/mrp_account/models/mrp_production.py @@ -1,53 +1,60 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. - from ast import literal_eval from odoo import api, fields, models, _ -from odoo.tools import float_is_zero, float_round +from odoo.tools import float_round class MrpProduction(models.Model): - _inherit = 'mrp.production' + _name = 'mrp.production' + _inherit = ['mrp.production', 'analytic.mixin'] extra_cost = fields.Float(copy=False, string='Extra Unit Cost') show_valuation = fields.Boolean(compute='_compute_show_valuation') - analytic_account_id = fields.Many2one( - 'account.analytic.account', 'Analytic Account', copy=True, - help="Analytic account in which cost and revenue entries will take\ - place for financial management of the manufacturing order.", - compute='_compute_analytic_account_id', store=True, readonly=False) + analytic_account_ids = fields.Many2many('account.analytic.account', compute='_compute_analytic_account_ids', store=True) def _compute_show_valuation(self): for order in self: order.show_valuation = any(m.state == 'done' for m in order.move_finished_ids) - @api.depends('bom_id') - def _compute_analytic_account_id(self): - if self.bom_id.analytic_account_id: - self.analytic_account_id = self.bom_id.analytic_account_id + @api.depends('bom_id', 'product_id') + def _compute_analytic_distribution(self): + for record in self: + if record.bom_id.analytic_distribution: + record.analytic_distribution = record.bom_id.analytic_distribution + else: + record.analytic_distribution = record.env['account.analytic.distribution.model']._get_distribution({ + "product_id": record.product_id.id, + "product_categ_id": record.product_id.categ_id.id, + "company_id": record.company_id.id, + }) + + @api.depends('analytic_distribution') + def _compute_analytic_account_ids(self): + for record in self: + record.analytic_account_ids = list( + map(int, record.analytic_distribution.keys())) if record.analytic_distribution else [] + + @api.constrains('analytic_distribution') + def _check_analytic(self): + for record in self: + params = {'business_domain': 'manufacturing_order', 'company_id': record.company_id.id} + if record.product_id: + params['product'] = record.product_id.id + record.with_context({'validate_analytic': True})._validate_distribution(**params) def write(self, vals): - origin_analytic_account = {production: production.analytic_account_id for production in self} res = super().write(vals) for production in self: if vals.get('name'): - production.move_raw_ids.analytic_account_line_id.ref = production.display_name + production.move_raw_ids.analytic_account_line_ids.ref = production.display_name for workorder in production.workorder_ids: - workorder.mo_analytic_account_line_id.ref = production.display_name - workorder.mo_analytic_account_line_id.name = _("[WC] %s", workorder.display_name) - if 'analytic_account_id' in vals and production.state != 'draft': - if vals['analytic_account_id'] and origin_analytic_account[production]: - # Link the account analytic lines to the new AA - production.move_raw_ids.analytic_account_line_id.write({'account_id': vals['analytic_account_id']}) - production.workorder_ids.mo_analytic_account_line_id.write({'account_id': vals['analytic_account_id']}) - elif vals['analytic_account_id'] and not origin_analytic_account[production]: - # Create the account analytic lines if no AA is set in the MO - production.move_raw_ids._account_analytic_entry_move() - production.workorder_ids._create_or_update_analytic_entry() - else: - production.move_raw_ids.analytic_account_line_id.unlink() - production.workorder_ids.mo_analytic_account_line_id.unlink() + workorder.mo_analytic_account_line_ids.ref = production.display_name + workorder.mo_analytic_account_line_ids.name = _("[WC] %s", workorder.display_name) + if 'analytic_distribution' in vals and production.state != 'draft': + production.move_raw_ids._account_analytic_entry_move() + production.workorder_ids._create_or_update_analytic_entry() return res def action_view_stock_valuation_layers(self): @@ -60,15 +67,14 @@ class MrpProduction(models.Model): context['search_default_group_by_product_id'] = False return dict(action, domain=domain, context=context) - def action_view_analytic_account(self): + def action_view_analytic_accounts(self): self.ensure_one() return { "type": "ir.actions.act_window", "res_model": "account.analytic.account", - 'res_id': self.analytic_account_id.id, - "context": {"create": False}, - "name": _("Analytic Account"), - 'view_mode': 'form', + 'domain': [('id', 'in', self.analytic_account_ids.ids)], + "name": _("Analytic Accounts"), + 'view_mode': 'tree,form', } def _cal_price(self, consumed_moves): diff --git a/addons/mrp_account/models/mrp_workcenter.py b/addons/mrp_account/models/mrp_workcenter.py index 3cddf595e66..6f56856beaa 100644 --- a/addons/mrp_account/models/mrp_workcenter.py +++ b/addons/mrp_account/models/mrp_workcenter.py @@ -1,12 +1,23 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import fields, models +from odoo import api, models, fields class MrpWorkcenter(models.Model): - _inherit = 'mrp.workcenter' + _name = 'mrp.workcenter' + _inherit = ['mrp.workcenter', 'analytic.mixin'] - costs_hour_account_id = fields.Many2one( - 'account.analytic.account', string='Analytic Account', - help="Posts analytical accounting entries in real time for both component and operational costs.") + costs_hour_account_ids = fields.Many2many('account.analytic.account', compute="_compute_costs_hour_account_ids", store=True) + + @api.depends('analytic_distribution') + def _compute_costs_hour_account_ids(self): + for record in self: + record.costs_hour_account_ids = list(map(int, record.analytic_distribution.keys())) if record.analytic_distribution else [] + + @api.constrains('analytic_distribution') + def _check_analytic(self): + for record in self: + record.with_context({'validate_analytic': True})._validate_distribution(**{ + 'company_id': record.company_id.id, + }) diff --git a/addons/mrp_account/models/mrp_workorder.py b/addons/mrp_account/models/mrp_workorder.py index 89c7e8a2fab..9cd2f7e285c 100644 --- a/addons/mrp_account/models/mrp_workorder.py +++ b/addons/mrp_account/models/mrp_workorder.py @@ -2,14 +2,13 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import fields, models, _ -from odoo.tools import float_is_zero class MrpWorkorder(models.Model): _inherit = 'mrp.workorder' - mo_analytic_account_line_id = fields.Many2one('account.analytic.line', copy=False) - wc_analytic_account_line_id = fields.Many2one('account.analytic.line', copy=False) + mo_analytic_account_line_ids = fields.Many2many('account.analytic.line', 'mrp_workorder_mo_analytic_rel', copy=False) + wc_analytic_account_line_ids = fields.Many2many('account.analytic.line', 'mrp_workorder_wc_analytic_rel', copy=False) def _compute_duration(self): res = super()._compute_duration() @@ -22,15 +21,15 @@ class MrpWorkorder(models.Model): return res def action_cancel(self): - (self.mo_analytic_account_line_id | self.wc_analytic_account_line_id).unlink() + (self.mo_analytic_account_line_ids | self.wc_analytic_account_line_ids).unlink() return super().action_cancel() - def _prepare_analytic_line_values(self, account, unit_amount, amount): + def _prepare_analytic_line_values(self, account, amount, unit_amount): self.ensure_one() return { 'name': _("[WC] %s", self.display_name), 'amount': amount, - 'account_id': account.id, + 'account_id': account, 'unit_amount': unit_amount, 'product_id': self.product_id.id, 'product_uom_id': self.env.ref('uom.product_uom_hour').id, @@ -40,43 +39,18 @@ class MrpWorkorder(models.Model): } def _create_or_update_analytic_entry(self): - wo_to_link_mo_analytic_line = self.env['mrp.workorder'] - wo_to_link_wc_analytic_line = self.env['mrp.workorder'] - mo_analytic_line_vals_list = [] - wc_analytic_line_vals_list = [] - for wo in self.filtered(lambda wo: wo.production_id.analytic_account_id or wo.workcenter_id.costs_hour_account_id): + for wo in self.filtered(lambda wo: wo.id and (wo.production_id.analytic_distribution or wo.workcenter_id.analytic_distribution or wo.wc_analytic_account_line_ids or wo.mo_analytic_account_line_ids)): hours = wo.duration / 60.0 value = -hours * wo.workcenter_id.costs_hour - mo_account = wo.production_id.analytic_account_id - wc_account = wo.workcenter_id.costs_hour_account_id - if mo_account: - mo_currency = mo_account.currency_id or wo.company_id.currency_id - is_zero = float_is_zero(value, precision_rounding=mo_currency.rounding) - if wo.mo_analytic_account_line_id: - wo.mo_analytic_account_line_id.write({ - 'unit_amount': hours, - 'amount': value if not is_zero else 0, - }) - elif not is_zero: - wo_to_link_mo_analytic_line += wo - mo_analytic_line_vals_list.append(wo._prepare_analytic_line_values(mo_account, hours, value)) - if wc_account and wc_account != mo_account: - is_zero = float_is_zero(value, precision_rounding=wc_account.currency_id.rounding) - if wo.wc_analytic_account_line_id: - wo.wc_analytic_account_line_id.write({ - 'unit_amount': hours, - 'amount': value if not is_zero else 0, - }) - elif not is_zero: - wo_to_link_wc_analytic_line += wo - wc_analytic_line_vals_list.append(wo._prepare_analytic_line_values(wc_account, hours, value)) - analytic_lines = self.env['account.analytic.line'].sudo().create(mo_analytic_line_vals_list + wc_analytic_line_vals_list) - mo_analytic_lines, wc_analytic_lines = analytic_lines[:len(wo_to_link_mo_analytic_line)], analytic_lines[len(wo_to_link_mo_analytic_line):] - for wo, analytic_line in zip(wo_to_link_mo_analytic_line, mo_analytic_lines): - wo.mo_analytic_account_line_id = analytic_line - for wo, analytic_line in zip(wo_to_link_wc_analytic_line, wc_analytic_lines): - wo.wc_analytic_account_line_id = analytic_line + + mo_analytic_line_vals = self.env['account.analytic.account']._perform_analytic_distribution(wo.production_id.analytic_distribution, value, hours, wo.mo_analytic_account_line_ids, wo) + if mo_analytic_line_vals: + wo.mo_analytic_account_line_ids += self.env['account.analytic.line'].sudo().create(mo_analytic_line_vals) + + wc_analytic_line_vals = self.env['account.analytic.account']._perform_analytic_distribution(wo.workcenter_id.analytic_distribution, value, hours, wo.wc_analytic_account_line_ids, wo) + if wc_analytic_line_vals: + wo.wc_analytic_account_line_ids += self.env['account.analytic.line'].sudo().create(wc_analytic_line_vals) def unlink(self): - (self.mo_analytic_account_line_id | self.wc_analytic_account_line_id).unlink() + (self.mo_analytic_account_line_ids | self.wc_analytic_account_line_ids).unlink() return super().unlink() diff --git a/addons/mrp_account/models/stock_move.py b/addons/mrp_account/models/stock_move.py index e13ff82be58..4b22c852fa6 100644 --- a/addons/mrp_account/models/stock_move.py +++ b/addons/mrp_account/models/stock_move.py @@ -12,19 +12,19 @@ class StockMove(models.Model): res += self.filtered(lambda m: m.bom_line_id.bom_id.product_tmpl_id.id == product.product_tmpl_id.id) return res - def _generate_analytic_lines_data(self, unit_amount, amount): - vals = super()._generate_analytic_lines_data(unit_amount, amount) - if self.raw_material_production_id.analytic_account_id: + def _generate_analytic_lines_data(self, unit_amount, amount, distribution, account, distribution_on_each_plan): + vals = super()._generate_analytic_lines_data(unit_amount, amount, distribution, account, distribution_on_each_plan) + if self._get_analytic_distribution(): vals['name'] = _('[Raw] %s', self.product_id.display_name) vals['ref'] = self.raw_material_production_id.display_name vals['category'] = 'manufacturing_order' return vals - def _get_analytic_account(self): - account = self.raw_material_production_id.analytic_account_id - if account: - return account - return super()._get_analytic_account() + def _get_analytic_distribution(self): + distribution = self.raw_material_production_id.analytic_distribution + if distribution: + return distribution + return super()._get_analytic_distribution() def _ignore_automatic_valuation(self): return bool(self.raw_material_production_id) diff --git a/addons/mrp_account/models/stock_rule.py b/addons/mrp_account/models/stock_rule.py index f10a8bc17f6..5c1624eccdf 100644 --- a/addons/mrp_account/models/stock_rule.py +++ b/addons/mrp_account/models/stock_rule.py @@ -9,6 +9,6 @@ class StockRule(models.Model): def _prepare_mo_vals(self, product_id, product_qty, product_uom, location_id, name, origin, company_id, values, bom): res = super()._prepare_mo_vals(product_id, product_qty, product_uom, location_id, name, origin, company_id, values, bom) - if not bom.analytic_account_id and values.get('analytic_account_id'): - res['analytic_account_id'] = values.get('analytic_account_id').id + if not bom.analytic_distribution and values.get('analytic_distribution'): + res['analytic_distribution'] = values.get('analytic_distribution') return res diff --git a/addons/mrp_account/tests/test_analytic_account.py b/addons/mrp_account/tests/test_analytic_account.py index ee25eb56ac1..8c9488eacb9 100644 --- a/addons/mrp_account/tests/test_analytic_account.py +++ b/addons/mrp_account/tests/test_analytic_account.py @@ -64,32 +64,30 @@ class TestAnalyticAccount(TestMrpAnalyticAccount): mo_form.product_id = self.product mo_form.bom_id = self.bom mo_form.product_qty = 10.0 - mo_form.analytic_account_id = self.analytic_account + mo_form.analytic_distribution = {str(self.analytic_account.id): 100.0} mo = mo_form.save() mo.action_confirm() self.assertEqual(mo.state, 'confirmed') - self.assertEqual(len(mo.move_raw_ids.analytic_account_line_id), 0) + self.assertEqual(len(mo.move_raw_ids.analytic_account_line_ids), 0) # increase qty_producing to 5.0 mo_form = Form(mo) mo_form.qty_producing = 5.0 mo_form.save() self.assertEqual(mo.state, 'progress') - self.assertEqual(mo.move_raw_ids.analytic_account_line_id.amount, -50.0) + self.assertEqual(mo.move_raw_ids.analytic_account_line_ids.amount, -50.0) # increase qty_producing to 10.0 mo_form = Form(mo) mo_form.qty_producing = 10.0 mo_form.save() - # Hack to bypass test doing strange things - mo._set_qty_producing() mo.workorder_ids.button_finish() self.assertEqual(mo.state, 'to_close') - self.assertEqual(mo.move_raw_ids.analytic_account_line_id.amount, -100.0) + self.assertEqual(mo.move_raw_ids.analytic_account_line_ids.amount, -100.0) # mark as done mo.button_mark_done() self.assertEqual(mo.state, 'done') - self.assertEqual(mo.move_raw_ids.analytic_account_line_id.amount, -100.0) + self.assertEqual(mo.move_raw_ids.analytic_account_line_ids.amount, -100.0) def test_mo_analytic_backorder(self): """Test the analytic lines are correctly posted when backorder. @@ -99,65 +97,23 @@ class TestAnalyticAccount(TestMrpAnalyticAccount): mo_form.product_id = self.product mo_form.bom_id = self.bom mo_form.product_qty = 10.0 - mo_form.analytic_account_id = self.analytic_account + mo_form.analytic_distribution = {str(self.analytic_account.id): 100.0} mo = mo_form.save() mo.action_confirm() self.assertEqual(mo.state, 'confirmed') - self.assertEqual(len(mo.move_raw_ids.analytic_account_line_id), 0) + self.assertEqual(len(mo.move_raw_ids.analytic_account_line_ids), 0) # increase qty_producing to 5.0 mo_form = Form(mo) mo_form.qty_producing = 5.0 mo_form.save() self.assertEqual(mo.state, 'progress') - self.assertEqual(mo.move_raw_ids.analytic_account_line_id.amount, -50.0) + self.assertEqual(mo.move_raw_ids.analytic_account_line_ids.amount, -50.0) backorder_wizard_dict = mo.button_mark_done() Form(self.env[(backorder_wizard_dict.get('res_model'))].with_context(backorder_wizard_dict['context'])).save().action_backorder() self.assertEqual(mo.state, 'done') - self.assertEqual(mo.move_raw_ids.analytic_account_line_id.amount, -50.0) - - def test_workcenter_same_analytic_account(self): - """Test when workcenter and MO are using the same analytic account, no - duplicated lines will be post. - """ - # Required for `workorder_ids` to be visible in the view - self.env.user.groups_id += self.env.ref('mrp.group_mrp_routings') - # set wc analytic account to be the same of the one on the bom - self.workcenter.costs_hour_account_id = self.analytic_account - - # create a mo - mo_form = Form(self.env['mrp.production']) - mo_form.product_id = self.product - mo_form.bom_id = self.bom - mo_form.product_qty = 10.0 - mo_form.analytic_account_id = self.analytic_account - mo = mo_form.save() - mo.action_confirm() - self.assertEqual(len(mo.workorder_ids.wc_analytic_account_line_id), 0) - - # change duration to 60 - mo_form = Form(mo) - with mo_form.workorder_ids.edit(0) as line_edit: - line_edit.duration = 60.0 - mo_form.save() - self.assertEqual(mo.workorder_ids.mo_analytic_account_line_id.amount, -10.0) - self.assertEqual(len(mo.workorder_ids.wc_analytic_account_line_id), 0) - - # change duration to 120 - with mo_form.workorder_ids.edit(0) as line_edit: - line_edit.duration = 120.0 - mo_form.save() - self.assertEqual(mo.workorder_ids.mo_analytic_account_line_id.amount, -20.0) - self.assertEqual(len(mo.workorder_ids.wc_analytic_account_line_id), 0) - - # mark as done - mo_form.qty_producing = 10.0 - mo_form.save() - mo.button_mark_done() - self.assertEqual(mo.state, 'done') - self.assertEqual(mo.workorder_ids.mo_analytic_account_line_id.amount, -20.0) - self.assertEqual(len(mo.workorder_ids.wc_analytic_account_line_id), 0) + self.assertEqual(mo.move_raw_ids.analytic_account_line_ids.amount, -50.0) def test_workcenter_different_analytic_account(self): """Test when workcenter and MO are using the same analytic account, no @@ -168,46 +124,46 @@ class TestAnalyticAccount(TestMrpAnalyticAccount): # set wc analytic account to be different from the one on the bom analytic_plan = self.env['account.analytic.plan'].create({'name': 'Plan Test', 'company_id': False}) wc_analytic_account = self.env['account.analytic.account'].create({'name': 'wc_analytic_account', 'plan_id': analytic_plan.id}) - self.workcenter.costs_hour_account_id = wc_analytic_account + self.workcenter.analytic_distribution = {str(wc_analytic_account.id): 100.0} # create a mo mo_form = Form(self.env['mrp.production']) mo_form.product_id = self.product mo_form.bom_id = self.bom mo_form.product_qty = 10.0 - mo_form.analytic_account_id = self.analytic_account + mo_form.analytic_distribution = {str(self.analytic_account.id): 100.0} mo = mo_form.save() mo.action_confirm() - self.assertEqual(len(mo.workorder_ids.wc_analytic_account_line_id), 0) + self.assertEqual(len(mo.workorder_ids.wc_analytic_account_line_ids), 0) # change duration to 60 mo_form = Form(mo) with mo_form.workorder_ids.edit(0) as line_edit: line_edit.duration = 60.0 mo_form.save() - self.assertEqual(mo.workorder_ids.mo_analytic_account_line_id.amount, -10.0) - self.assertEqual(mo.workorder_ids.mo_analytic_account_line_id.account_id, self.analytic_account) - self.assertEqual(mo.workorder_ids.wc_analytic_account_line_id.amount, -10.0) - self.assertEqual(mo.workorder_ids.wc_analytic_account_line_id.account_id, wc_analytic_account) + self.assertEqual(mo.workorder_ids.mo_analytic_account_line_ids.amount, -10.0) + self.assertEqual(mo.workorder_ids.mo_analytic_account_line_ids.account_id, self.analytic_account) + self.assertEqual(mo.workorder_ids.wc_analytic_account_line_ids.amount, -10.0) + self.assertEqual(mo.workorder_ids.wc_analytic_account_line_ids.account_id, wc_analytic_account) # change duration to 120 with mo_form.workorder_ids.edit(0) as line_edit: line_edit.duration = 120.0 mo_form.save() - self.assertEqual(mo.workorder_ids.mo_analytic_account_line_id.amount, -20.0) - self.assertEqual(mo.workorder_ids.mo_analytic_account_line_id.account_id, self.analytic_account) - self.assertEqual(mo.workorder_ids.wc_analytic_account_line_id.amount, -20.0) - self.assertEqual(mo.workorder_ids.wc_analytic_account_line_id.account_id, wc_analytic_account) + self.assertEqual(mo.workorder_ids.mo_analytic_account_line_ids.amount, -20.0) + self.assertEqual(mo.workorder_ids.mo_analytic_account_line_ids.account_id, self.analytic_account) + self.assertEqual(mo.workorder_ids.wc_analytic_account_line_ids.amount, -20.0) + self.assertEqual(mo.workorder_ids.wc_analytic_account_line_ids.account_id, wc_analytic_account) # mark as done mo_form.qty_producing = 10.0 mo_form.save() mo.button_mark_done() self.assertEqual(mo.state, 'done') - self.assertEqual(mo.workorder_ids.mo_analytic_account_line_id.amount, -20.0) - self.assertEqual(mo.workorder_ids.mo_analytic_account_line_id.account_id, self.analytic_account) - self.assertEqual(mo.workorder_ids.wc_analytic_account_line_id.amount, -20.0) - self.assertEqual(mo.workorder_ids.wc_analytic_account_line_id.account_id, wc_analytic_account) + self.assertEqual(mo.workorder_ids.mo_analytic_account_line_ids.amount, -20.0) + self.assertEqual(mo.workorder_ids.mo_analytic_account_line_ids.account_id, self.analytic_account) + self.assertEqual(mo.workorder_ids.wc_analytic_account_line_ids.amount, -20.0) + self.assertEqual(mo.workorder_ids.wc_analytic_account_line_ids.account_id, wc_analytic_account) def test_changing_mo_analytic_account(self): """ Check if the MO account analytic lines are correctly updated @@ -220,46 +176,46 @@ class TestAnalyticAccount(TestMrpAnalyticAccount): mo_form.product_id = self.product mo_form.bom_id = self.bom mo_form.product_qty = 1 - mo_form.analytic_account_id = self.analytic_account + mo_form.analytic_distribution = {str(self.analytic_account.id): 100.0} mo = mo_form.save() mo.action_confirm() self.assertEqual(mo.state, 'confirmed') - self.assertEqual(len(mo.move_raw_ids.analytic_account_line_id), 0) - self.assertEqual(len(mo.workorder_ids.mo_analytic_account_line_id), 0) + self.assertEqual(len(mo.move_raw_ids.analytic_account_line_ids), 0) + self.assertEqual(len(mo.workorder_ids.mo_analytic_account_line_ids), 0) # Change duration to 60 mo_form = Form(mo) with mo_form.workorder_ids.edit(0) as line_edit: line_edit.duration = 60.0 mo_form.save() - self.assertEqual(mo.workorder_ids.mo_analytic_account_line_id.account_id, self.analytic_account) + self.assertEqual(mo.workorder_ids.mo_analytic_account_line_ids.account_id, self.analytic_account) # Mark as done mo.button_mark_done() self.assertEqual(mo.state, 'done') - self.assertEqual(len(mo.move_raw_ids.analytic_account_line_id), 1) + self.assertEqual(len(mo.move_raw_ids.analytic_account_line_ids), 1) # Create a new analytic account analytic_plan = self.env['account.analytic.plan'].create({'name': 'Plan Test', 'company_id': False}) new_analytic_account = self.env['account.analytic.account'].create({'name': 'test_analytic_account_2', 'plan_id': analytic_plan.id}) # Change the MO analytic account - mo.analytic_account_id = new_analytic_account - self.assertEqual(mo.move_raw_ids.analytic_account_line_id.account_id.id, new_analytic_account.id) - self.assertEqual(mo.workorder_ids.mo_analytic_account_line_id.account_id.id, new_analytic_account.id) + mo.analytic_distribution = {str(new_analytic_account.id): 100.0} + self.assertEqual(mo.move_raw_ids.analytic_account_line_ids.account_id.id, new_analytic_account.id) + self.assertEqual(mo.workorder_ids.mo_analytic_account_line_ids.account_id.id, new_analytic_account.id) #Get the MO analytic account lines - mo_analytic_account_raw_lines = mo.move_raw_ids.analytic_account_line_id - mo_analytic_account_wc_lines = mo.workorder_ids.mo_analytic_account_line_id - mo.analytic_account_id = False + mo_analytic_account_raw_lines = mo.move_raw_ids.analytic_account_line_ids + mo_analytic_account_wc_lines = mo.move_raw_ids.analytic_account_line_ids + mo.analytic_distribution = {} # Check that the MO analytic account lines are deleted - self.assertEqual(len(mo.move_raw_ids.analytic_account_line_id), 0) - self.assertEqual(len(mo.workorder_ids.mo_analytic_account_line_id), 0) + self.assertEqual(len(mo.move_raw_ids.analytic_account_line_ids), 0) + self.assertEqual(len(mo.workorder_ids.mo_analytic_account_line_ids), 0) self.assertFalse(mo_analytic_account_raw_lines.exists()) self.assertFalse(mo_analytic_account_wc_lines.exists()) # Check that the AA lines are recreated correctly if we delete the AA, save the MO, and assign a new one - mo.analytic_account_id = self.analytic_account - self.assertEqual(len(mo.move_raw_ids.analytic_account_line_id), 1) - self.assertEqual(len(mo.workorder_ids.mo_analytic_account_line_id), 1) + mo.analytic_distribution = {str(self.analytic_account.id): 100.0} + self.assertEqual(len(mo.move_raw_ids.analytic_account_line_ids), 1) + self.assertEqual(len(mo.workorder_ids.mo_analytic_account_line_ids), 1) def test_add_remove_wo_analytic_no_company(self): """Test the addition and removal of work orders to a MO linked to @@ -275,7 +231,7 @@ class TestAnalyticAccount(TestMrpAnalyticAccount): # Create a mo linked to an analytic account with no associated company mo_no_company = self.env['mrp.production'].create({ 'product_id': self.product.id, - 'analytic_account_id': analytic_account_no_company.id, + 'analytic_distribution': {str(analytic_account_no_company.id): 100.0}, 'product_uom_id': self.bom.product_uom_id.id, }) @@ -289,7 +245,7 @@ class TestAnalyticAccount(TestMrpAnalyticAccount): }) mo_no_c_form.save() self.assertTrue(mo_no_company.workorder_ids) - self.assertEqual(wo.production_id.analytic_account_id, analytic_account_no_company) + self.assertEqual(wo.production_id.analytic_account_ids, analytic_account_no_company) self.assertEqual(len(analytic_account_no_company.line_ids), 1) mo_no_company.workorder_ids.unlink() self.assertEqual(len(analytic_account_no_company.line_ids), 0) diff --git a/addons/mrp_account/views/mrp_bom_views.xml b/addons/mrp_account/views/mrp_bom_views.xml index 5e42b78483e..24ef39867d8 100644 --- a/addons/mrp_account/views/mrp_bom_views.xml +++ b/addons/mrp_account/views/mrp_bom_views.xml @@ -6,7 +6,9 @@ - + diff --git a/addons/mrp_account/views/mrp_production_views.xml b/addons/mrp_account/views/mrp_production_views.xml index 4b68de29983..381c88e2678 100644 --- a/addons/mrp_account/views/mrp_production_views.xml +++ b/addons/mrp_account/views/mrp_production_views.xml @@ -5,7 +5,10 @@ - + @@ -29,9 +32,9 @@ Analytic Account @@ -41,7 +44,9 @@ - + diff --git a/addons/project_mrp/__manifest__.py b/addons/project_mrp/__manifest__.py index 53d46b657dc..fe923dd78cc 100644 --- a/addons/project_mrp/__manifest__.py +++ b/addons/project_mrp/__manifest__.py @@ -7,9 +7,6 @@ 'summary': "Monitor MRP using project", 'category': 'Services/Project', 'depends': ['mrp_account', 'project'], - 'demo': [ - 'data/project_mrp_demo.xml', - ], 'auto_install': True, 'license': 'LGPL-3', } diff --git a/addons/project_mrp/data/project_mrp_demo.xml b/addons/project_mrp/data/project_mrp_demo.xml deleted file mode 100644 index 0dda83a3947..00000000000 --- a/addons/project_mrp/data/project_mrp_demo.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/addons/project_mrp/models/project_project.py b/addons/project_mrp/models/project_project.py index d31d32390a1..459c3501c0e 100644 --- a/addons/project_mrp/models/project_project.py +++ b/addons/project_mrp/models/project_project.py @@ -15,7 +15,7 @@ class Project(models.Model): def action_view_mrp_production(self): self.ensure_one() action = self.env['ir.actions.actions']._for_xml_id('mrp.mrp_production_action') - action['domain'] = [('analytic_account_id', '=', self.analytic_account_id.id)] + action['domain'] = [('id', 'in', self.analytic_account_id.production_ids.ids)] action['context'] = {'default_analytic_account_id': self.analytic_account_id.id} if self.production_count == 1: action['view_mode'] = 'form' diff --git a/addons/stock_account/models/__init__.py b/addons/stock_account/models/__init__.py index 69857b438fd..d2930494f67 100644 --- a/addons/stock_account/models/__init__.py +++ b/addons/stock_account/models/__init__.py @@ -3,6 +3,7 @@ from . import account_chart_template from . import account_move +from . import analytic_account from . import product from . import stock_move from . import stock_location diff --git a/addons/stock_account/models/analytic_account.py b/addons/stock_account/models/analytic_account.py new file mode 100644 index 00000000000..66fe5d62978 --- /dev/null +++ b/addons/stock_account/models/analytic_account.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models +from odoo.tools import float_compare, float_is_zero + + +class AccountAnalyticPlan(models.Model): + _inherit = 'account.analytic.plan' + + def _calculate_distribution_amount(self, amount, percentage, distribution_on_each_plan): + existing_amount = distribution_on_each_plan.get(self, 0) + distribution_plan = existing_amount + percentage + decimal_precision = self.env['decimal.precision'].precision_get('Percentage Analytic') + if float_compare(distribution_plan, 100, precision_digits=decimal_precision) == 0: + distributed_amount = amount * (100 - existing_amount) / 100.0 + else: + distributed_amount = amount * percentage / 100.0 + distribution_on_each_plan[self] = distribution_plan + return distributed_amount + +class AccountAnalyticAccount(models.Model): + _inherit = 'account.analytic.account' + + def _perform_analytic_distribution(self, distribution, amount, unit_amount, lines, obj, additive=False): + """ + Redistributes the analytic lines to match the given distribution: + - For account_ids where lines already exist, the amount and unit_amount of these lines get updated, + lines where the updated amount becomes zero get unlinked. + - For account_ids where lines don't exist yet, the line values to create them are returned, + lines where the amount becomes zero are not included. + + :param distribution: the desired distribution to match the analytic lines to + :param amount: the total amount to distribute over the analytic lines + :param unit_amount: the total unit amount (will not be distributed) + :param lines: the (current) analytic account lines that need to be matched to the new distribution + :param obj: the object on which _prepare_analytic_line_values(account_id, amount, unit_amount) will be + called to get the template for the values of new analytic line objects + :param additive: if True, the unit_amount and (distributed) amount get added to the existing lines + + :returns: a list of dicts containing the values for new analytic lines that need to be created + :rtype: dict + """ + distribution_on_each_plan = {} + processed_distributions = [] + for existing_aal in lines: + if distribution and existing_aal.account_id.id in [int(i) for i in distribution]: + # Update the existing AAL for this account + percentage = distribution[str(existing_aal.account_id.id)] + new_amount = existing_aal.account_id.root_plan_id._calculate_distribution_amount(amount, percentage, distribution_on_each_plan) + new_unit_amount = amount + if additive: + new_amount += existing_aal.amount + new_unit_amount += existing_aal.unit_amount + currency = existing_aal.account_id.currency_id or obj.company_id.currency_id + if float_is_zero(new_amount, precision_rounding=currency.rounding): + existing_aal.unlink() + else: + existing_aal.amount = new_amount + existing_aal.unit_amount = new_unit_amount + processed_distributions.append(existing_aal.account_id.id) + else: + # Delete the existing AAL if it is no longer present in the new distribution + existing_aal.unlink() + lines_to_link = [] + if not distribution: + return [] + for account_id, percentage in distribution.items(): + # Only create a new AAL if an existing one was not already modified before + account_id = int(account_id) + if account_id not in processed_distributions: + account = self.browse(account_id) + new_amount = account.root_plan_id._calculate_distribution_amount(amount, percentage, distribution_on_each_plan) + currency = account.currency_id or obj.company_id.currency_id + if not float_is_zero(new_amount, precision_rounding=currency.rounding): + lines_to_link.append(obj._prepare_analytic_line_values(account.id, new_amount, unit_amount)) + return lines_to_link diff --git a/addons/stock_account/models/stock_move.py b/addons/stock_account/models/stock_move.py index cea807ff740..e082dce4ec8 100644 --- a/addons/stock_account/models/stock_move.py +++ b/addons/stock_account/models/stock_move.py @@ -5,7 +5,7 @@ from collections import defaultdict from odoo import api, fields, models, _ from odoo.exceptions import UserError -from odoo.tools import float_compare, float_is_zero, float_round, OrderedSet +from odoo.tools import float_is_zero, float_round, float_compare, OrderedSet import logging _logger = logging.getLogger(__name__) @@ -18,8 +18,7 @@ class StockMove(models.Model): help='Trigger a decrease of the delivered/received quantity in the associated Sale Order/Purchase Order') account_move_ids = fields.One2many('account.move', 'stock_move_id') stock_valuation_layer_ids = fields.One2many('stock.valuation.layer', 'stock_move_id') - analytic_account_line_id = fields.Many2one( - 'account.analytic.line', copy=False) + analytic_account_line_ids = fields.Many2many('account.analytic.line', copy=False) def _filter_anglo_saxon_moves(self, product): return self.filtered(lambda m: m.product_id.id == product.id) @@ -31,7 +30,7 @@ class StockMove(models.Model): return action_data def _action_cancel(self): - self.analytic_account_line_id.unlink() + self.analytic_account_line_ids.unlink() return super()._action_cancel() def _should_force_price_unit(self): @@ -407,7 +406,7 @@ class StockMove(models.Model): def _prepare_analytic_lines(self): self.ensure_one() - if not self._get_analytic_account(): + if not self._get_analytic_distribution() and not self.analytic_account_line_ids: return False if self.state in ['cancel', 'draft']: @@ -429,24 +428,19 @@ class StockMove(models.Model): elif sum(self.stock_valuation_layer_ids.mapped('quantity')): amount = sum(self.stock_valuation_layer_ids.mapped('value')) unit_amount = - sum(self.stock_valuation_layer_ids.mapped('quantity')) - if self.analytic_account_line_id: - self.analytic_account_line_id.unit_amount = unit_amount - self.analytic_account_line_id.amount = amount - return False - elif amount: - return self._generate_analytic_lines_data( - unit_amount, amount) + + return self.env['account.analytic.account']._perform_analytic_distribution( + self._get_analytic_distribution(), amount, unit_amount, self.analytic_account_line_ids, self) def _ignore_automatic_valuation(self): return False - def _generate_analytic_lines_data(self, unit_amount, amount): + def _prepare_analytic_line_values(self, account, amount, unit_amount): self.ensure_one() - account_id = self._get_analytic_account() return { 'name': self.name, 'amount': amount, - 'account_id': account_id.id, + 'account_id': account, 'unit_amount': unit_amount, 'product_id': self.product_id.id, 'product_uom_id': self.product_id.uom_id.id, @@ -532,18 +526,10 @@ class StockMove(models.Model): } def _account_analytic_entry_move(self): - analytic_lines_vals = [] - moves_to_link = [] for move in self: analytic_line_vals = move._prepare_analytic_lines() - if not analytic_line_vals: - continue - moves_to_link.append(move.id) - analytic_lines_vals.append(analytic_line_vals) - analytic_lines = self.env['account.analytic.line'].sudo().create(analytic_lines_vals) - for move_id, analytic_line in zip(moves_to_link, analytic_lines): - self.env['stock.move'].browse( - move_id).analytic_account_line_id = analytic_line + if analytic_line_vals: + move.analytic_account_line_ids += self.env['account.analytic.line'].sudo().create(analytic_line_vals) def _account_entry_move(self, qty, description, svl_id, cost): """ Accounting Valuation Entries """ @@ -593,7 +579,7 @@ class StockMove(models.Model): return am_vals - def _get_analytic_account(self): + def _get_analytic_distribution(self): return False def _get_related_invoices(self): # To be overridden in purchase and sale_stock