diff --git a/addons/stock_account/__manifest__.py b/addons/stock_account/__manifest__.py index 2662210d0c1..62873c1c87c 100644 --- a/addons/stock_account/__manifest__.py +++ b/addons/stock_account/__manifest__.py @@ -32,6 +32,7 @@ Dashboard / Reports for Warehouse Management includes: 'views/product_views.xml', 'views/stock_quant_views.xml', 'views/stock_valuation_layer_views.xml', + 'wizard/stock_valuation_layer_revaluation_views.xml' ], 'test': [ ], diff --git a/addons/stock_account/models/product.py b/addons/stock_account/models/product.py index 48f59d5f836..bea0dfacd87 100644 --- a/addons/stock_account/models/product.py +++ b/addons/stock_account/models/product.py @@ -97,6 +97,7 @@ class ProductProduct(models.Model): quantity_svl = fields.Float(compute='_compute_value_svl') stock_valuation_layer_ids = fields.One2many('stock.valuation.layer', 'product_id') valuation = fields.Selection(related="categ_id.property_valuation", readonly=True) + cost_method = fields.Selection(related="categ_id.property_cost_method", readonly=True) def write(self, vals): if 'standard_price' in vals and not self.env.context.get('disable_auto_svl'): @@ -126,6 +127,22 @@ class ProductProduct(models.Model): remaining.value_svl = 0 remaining.quantity_svl = 0 + # ------------------------------------------------------------------------- + # Actions + # ------------------------------------------------------------------------- + def action_revaluation(self): + self.ensure_one() + ctx = dict(self._context, default_product_id=self.id, default_company_id=self.env.company.id) + return { + 'name': _("Product Revaluation"), + 'view_mode': 'form', + 'res_model': 'stock.valuation.layer.revaluation', + 'view_id': self.env.ref('stock_account.stock_valuation_layer_revaluation_form_view').id, + 'type': 'ir.actions.act_window', + 'context': ctx, + 'target': 'new' + } + # ------------------------------------------------------------------------- # SVL creation helpers # ------------------------------------------------------------------------- diff --git a/addons/stock_account/security/ir.model.access.csv b/addons/stock_account/security/ir.model.access.csv index bd56cc16f9a..f1c43927394 100644 --- a/addons/stock_account/security/ir.model.access.csv +++ b/addons/stock_account/security/ir.model.access.csv @@ -6,3 +6,4 @@ access_stock_picking_invoicing_payments,stock.picking,stock.model_stock_picking, access_stock_move_invoicing_payments_readonly,stock.move,model_stock_move,account.group_account_readonly,1,0,0,0 access_stock_move_invoicing_payments,stock.move,model_stock_move,account.group_account_invoice,1,1,1,0 access_stock_valuation_layer,access_stock_valuation_layer,model_stock_valuation_layer,stock.group_stock_manager,1,1,1,0 +access_stock_valuation_layer_revaluation,access_stock_valuation_layer_revaluation,model_stock_valuation_layer_revaluation,stock.group_stock_manager,1,1,1,0 \ No newline at end of file diff --git a/addons/stock_account/tests/__init__.py b/addons/stock_account/tests/__init__.py index 78353eecb83..fe72444ecfd 100644 --- a/addons/stock_account/tests/__init__.py +++ b/addons/stock_account/tests/__init__.py @@ -1,3 +1,4 @@ from . import test_anglo_saxon_valuation_reconciliation_common from . import test_stockvaluation from . import test_stockvaluationlayer +from . import test_stock_valuation_layer_revaluation diff --git a/addons/stock_account/tests/test_stock_valuation_layer_revaluation.py b/addons/stock_account/tests/test_stock_valuation_layer_revaluation.py new file mode 100644 index 00000000000..d84f865229a --- /dev/null +++ b/addons/stock_account/tests/test_stock_valuation_layer_revaluation.py @@ -0,0 +1,173 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.exceptions import UserError +from odoo.tests import Form +from odoo.addons.stock_account.tests.test_stockvaluation import _create_accounting_data +from odoo.addons.stock_account.tests.test_stockvaluationlayer import TestStockValuationCommon + + +class TestStockValuationLayerRevaluation(TestStockValuationCommon): + @classmethod + def setUpClass(cls): + super(TestStockValuationLayerRevaluation, cls).setUpClass() + cls.stock_input_account, cls.stock_output_account, cls.stock_valuation_account, cls.expense_account, cls.stock_journal = _create_accounting_data(cls.env) + cls.product1.write({ + 'property_account_expense_id': cls.expense_account.id, + }) + cls.product1.categ_id.write({ + 'property_stock_account_input_categ_id': cls.stock_input_account.id, + 'property_stock_account_output_categ_id': cls.stock_output_account.id, + 'property_stock_valuation_account_id': cls.stock_valuation_account.id, + 'property_stock_journal': cls.stock_journal.id, + }) + + cls.product1.categ_id.property_valuation = 'real_time' + + def test_stock_valuation_layer_revaluation_avco(self): + self.product1.categ_id.property_cost_method = 'average' + context = { + 'default_product_id': self.product1.id, + 'default_company_id': self.env.company.id, + 'default_added_value': 0.0 + } + # Quantity of product1 is zero, raise + with self.assertRaises(UserError): + Form(self.env['stock.valuation.layer.revaluation'].with_context(context)).save() + + self._make_in_move(self.product1, 10, unit_cost=2) + self._make_in_move(self.product1, 10, unit_cost=4) + + self.assertEqual(self.product1.standard_price, 3) + self.assertEqual(self.product1.quantity_svl, 20) + + old_layers = self.env['stock.valuation.layer'].search([('product_id', '=', self.product1.id)], order="create_date desc, id desc") + + self.assertEqual(len(old_layers), 2) + self.assertEqual(old_layers[0].remaining_value, 40) + + revaluation_wizard = Form(self.env['stock.valuation.layer.revaluation'].with_context(context)) + revaluation_wizard.added_value = 20 + revaluation_wizard.account_id = self.stock_valuation_account + revaluation_wizard.save().action_validate_revaluation() + + # Check standard price change + self.assertEqual(self.product1.standard_price, 4) + self.assertEqual(self.product1.quantity_svl, 20) + + # Check the creation of stock.valuation.layer + new_layer = self.env['stock.valuation.layer'].search([('product_id', '=', self.product1.id)], order="create_date desc, id desc", limit=1) + self.assertEqual(new_layer.value, 20) + + # Check the remaing value of current layers + self.assertEqual(old_layers[0].remaining_value, 50) + self.assertEqual(sum(slv.remaining_value for slv in old_layers), 80) + + # Check account move + self.assertTrue(bool(new_layer.account_move_id)) + self.assertEqual(len(new_layer.account_move_id.line_ids), 2) + + self.assertEqual(sum(new_layer.account_move_id.line_ids.mapped("debit")), 20) + self.assertEqual(sum(new_layer.account_move_id.line_ids.mapped("credit")), 20) + + credit_lines = [l for l in new_layer.account_move_id.line_ids if l.credit > 0] + self.assertEqual(len(credit_lines), 1) + self.assertEqual(credit_lines[0].account_id.id, self.stock_valuation_account.id) + + def test_stock_valuation_layer_revaluation_avco_rounding(self): + self.product1.categ_id.property_cost_method = 'average' + context = { + 'default_product_id': self.product1.id, + 'default_company_id': self.env.company.id, + 'default_added_value': 0.0 + } + # Quantity of product1 is zero, raise + with self.assertRaises(UserError): + Form(self.env['stock.valuation.layer.revaluation'].with_context(context)).save() + + self._make_in_move(self.product1, 1, unit_cost=1) + self._make_in_move(self.product1, 1, unit_cost=1) + self._make_in_move(self.product1, 1, unit_cost=1) + + self.assertEqual(self.product1.standard_price, 1) + self.assertEqual(self.product1.quantity_svl, 3) + + old_layers = self.env['stock.valuation.layer'].search([('product_id', '=', self.product1.id)], order="create_date desc, id desc") + + self.assertEqual(len(old_layers), 3) + self.assertEqual(old_layers[0].remaining_value, 1) + + revaluation_wizard = Form(self.env['stock.valuation.layer.revaluation'].with_context(context)) + revaluation_wizard.added_value = 1 + revaluation_wizard.account_id = self.stock_valuation_account + revaluation_wizard.save().action_validate_revaluation() + + # Check standard price change + self.assertEqual(self.product1.standard_price, 1.33) + self.assertEqual(self.product1.quantity_svl, 3) + + # Check the creation of stock.valuation.layer + new_layer = self.env['stock.valuation.layer'].search([('product_id', '=', self.product1.id)], order="create_date desc, id desc", limit=1) + self.assertEqual(new_layer.value, 1) + + # Check the remaing value of current layers + self.assertEqual(sum(slv.remaining_value for slv in old_layers), 4) + self.assertTrue(1.34 in old_layers.mapped("remaining_value")) + + # Check account move + self.assertTrue(bool(new_layer.account_move_id)) + self.assertEqual(len(new_layer.account_move_id.line_ids), 2) + + self.assertEqual(sum(new_layer.account_move_id.line_ids.mapped("debit")), 1) + self.assertEqual(sum(new_layer.account_move_id.line_ids.mapped("credit")), 1) + + credit_lines = [l for l in new_layer.account_move_id.line_ids if l.credit > 0] + self.assertEqual(len(credit_lines), 1) + self.assertEqual(credit_lines[0].account_id.id, self.stock_valuation_account.id) + + def test_stock_valuation_layer_revaluation_fifo(self): + self.product1.categ_id.property_cost_method = 'fifo' + context = { + 'default_product_id': self.product1.id, + 'default_company_id': self.env.company.id, + 'default_added_value': 0.0 + } + # Quantity of product1 is zero, raise + with self.assertRaises(UserError): + Form(self.env['stock.valuation.layer.revaluation'].with_context(context)).save() + + self._make_in_move(self.product1, 10, unit_cost=2) + self._make_in_move(self.product1, 10, unit_cost=4) + + self.assertEqual(self.product1.standard_price, 2) + self.assertEqual(self.product1.quantity_svl, 20) + + old_layers = self.env['stock.valuation.layer'].search([('product_id', '=', self.product1.id)], order="create_date desc, id desc") + + self.assertEqual(len(old_layers), 2) + self.assertEqual(old_layers[0].remaining_value, 40) + + revaluation_wizard = Form(self.env['stock.valuation.layer.revaluation'].with_context(context)) + revaluation_wizard.added_value = 20 + revaluation_wizard.account_id = self.stock_valuation_account + revaluation_wizard.save().action_validate_revaluation() + + self.assertEqual(self.product1.standard_price, 2) + + # Check the creation of stock.valuation.layer + new_layer = self.env['stock.valuation.layer'].search([('product_id', '=', self.product1.id)], order="create_date desc, id desc", limit=1) + self.assertEqual(new_layer.value, 20) + + # Check the remaing value of current layers + self.assertEqual(old_layers[0].remaining_value, 50) + self.assertEqual(sum(slv.remaining_value for slv in old_layers), 80) + + # Check account move + self.assertTrue(bool(new_layer.account_move_id)) + self.assertTrue(len(new_layer.account_move_id.line_ids), 2) + + self.assertEqual(sum(new_layer.account_move_id.line_ids.mapped("debit")), 20) + self.assertEqual(sum(new_layer.account_move_id.line_ids.mapped("credit")), 20) + + credit_lines = [l for l in new_layer.account_move_id.line_ids if l.credit > 0] + self.assertEqual(len(credit_lines), 1) diff --git a/addons/stock_account/views/stock_valuation_layer_views.xml b/addons/stock_account/views/stock_valuation_layer_views.xml index a89aac82267..fc76a676907 100644 --- a/addons/stock_account/views/stock_valuation_layer_views.xml +++ b/addons/stock_account/views/stock_valuation_layer_views.xml @@ -50,6 +50,11 @@ + + + + + diff --git a/addons/stock_account/wizard/__init__.py b/addons/stock_account/wizard/__init__.py index 31822ebf05e..b920576a240 100644 --- a/addons/stock_account/wizard/__init__.py +++ b/addons/stock_account/wizard/__init__.py @@ -3,3 +3,4 @@ from . import stock_quantity_history from . import stock_picking_return +from . import stock_valuation_layer_revaluation diff --git a/addons/stock_account/wizard/stock_valuation_layer_revaluation.py b/addons/stock_account/wizard/stock_valuation_layer_revaluation.py new file mode 100644 index 00000000000..49d693b728a --- /dev/null +++ b/addons/stock_account/wizard/stock_valuation_layer_revaluation.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.tools import float_is_zero + + +class StockValuationLayerRevaluation(models.TransientModel): + _name = 'stock.valuation.layer.revaluation' + _description = "Wizard model to reavaluate a stock inventory for a product" + _check_company_auto = True + + @api.model + def default_get(self, default_fields): + res = super().default_get(default_fields) + if res.get('product_id'): + product = self.env['product.product'].browse(res['product_id']) + accounts = product.product_tmpl_id.get_product_accounts() + if product.categ_id.property_cost_method == 'standard': + raise UserError(_("You cannot revalue a product with a standard cost method.")) + if product.quantity_svl <= 0: + raise UserError(_("You cannot revalue a product with an empty or negative stock.")) + if 'account_journal_id' not in res and 'account_journal_id' in default_fields: + res['account_journal_id'] = accounts['stock_journal'].id + return res + + company_id = fields.Many2one('res.company', "Company", readonly=True, required=True) + currency_id = fields.Many2one('res.currency', "Currency", related='company_id.currency_id', required=True) + + product_id = fields.Many2one('product.product', "Related product", required=True, check_company=True) + property_valuation = fields.Selection(related='product_id.categ_id.property_valuation') + product_uom_name = fields.Char("Unit of Measure", related='product_id.uom_id.name') + current_value_svl = fields.Float("Current Value", related="product_id.value_svl") + current_quantity_svl = fields.Float("Current Quantity", related="product_id.quantity_svl") + + added_value = fields.Monetary("Added value", required=True) + new_value = fields.Monetary("New value", compute='_compute_new_value') + new_value_by_qty = fields.Monetary("New value by quantity", compute='_compute_new_value') + reason = fields.Char("Reason", help="Reason of the revaluation") + + account_journal_id = fields.Many2one('account.journal', "Journal", required=True, check_company=True) + account_id = fields.Many2one('account.account', "Counterpart Account", domain=[('deprecated', '=', False)], required=True, check_company=True) + date = fields.Date("Accounting Date") + + @api.depends('current_value_svl', 'current_quantity_svl', 'added_value') + def _compute_new_value(self): + for reval in self: + reval.new_value = reval.current_value_svl + reval.added_value + if not float_is_zero(reval.current_quantity_svl, precision_rounding=self.product_id.uom_id.rounding): + reval.new_value_by_qty = reval.new_value / reval.current_quantity_svl + else: + reval.new_value_by_qty = 0.0 + + def action_validate_revaluation(self): + """ Revaluate the stock for `self.product_id` in `self.company_id`. + + - Change the stardard price with the new valuation by product unit. + - Create a manual stock valuation layer with the `added_value` of `self`. + - Distribute the `added_value` on the the remaining_value of layers still in stock (with a remaining quantity) + - If the Inventory Valuation of the product category is automated, create + related account move. + """ + self.ensure_one() + if self.currency_id.is_zero(self.added_value): + raise UserError(_("The added value doesn't have any impact on the stock valuation")) + + product_id = self.product_id.with_company(self.company_id) + + remaining_svls = self.env['stock.valuation.layer'].search([ + ('product_id', '=', product_id.id), + ('remaining_qty', '>', 0), + ('company_id', '=', self.company_id.id), + ]) + + # Create a manual stock valuation layer + revaluation_svl_vals = { + 'company_id': self.company_id.id, + 'product_id': product_id.id, + 'description': _("Manual Stock Valuation: %s.%s") % ( + self.reason or _("No Reason Given"), + product_id.categ_id.property_cost_method == 'average' and (_(" Product cost updated from %s to %s.") % (product_id.standard_price, product_id.standard_price + self.added_value / self.current_quantity_svl)) or ''), + 'value': self.added_value, + 'quantity': 0, + } + + remaining_qty = sum(remaining_svls.mapped('remaining_qty')) + remaining_value = self.added_value + remaining_value_unit_cost = self.currency_id.round(remaining_value / remaining_qty) + for svl in remaining_svls: + if float_is_zero(svl.remaining_qty - remaining_qty, precision_rounding=self.product_id.uom_id.rounding): + svl.remaining_value += remaining_value + else: + taken_remaining_value = remaining_value_unit_cost * svl.remaining_qty + svl.remaining_value += taken_remaining_value + remaining_value -= taken_remaining_value + remaining_qty -= svl.remaining_qty + + revaluation_svl = self.env['stock.valuation.layer'].create(revaluation_svl_vals) + + # Update the stardard price in case of AVCO + if product_id.categ_id.property_cost_method == 'average': + product_id.with_context(disable_auto_svl=True).standard_price += self.added_value / self.current_quantity_svl + + # If the Inventory Valuation of the product category is automated, create related account move. + if self.property_valuation != 'real_time': + return True + + accounts = product_id.product_tmpl_id.get_product_accounts() + + if self.added_value < 0: + debit_account_id = self.account_id.id + credit_account_id = accounts.get('stock_valuation') and accounts['stock_valuation'].id + else: + debit_account_id = accounts.get('stock_valuation') and accounts['stock_valuation'].id + credit_account_id = self.account_id.id + + move_vals = { + 'journal_id': self.account_journal_id.id or accounts['stock_journal'].id, + 'company_id': self.company_id.id, + 'ref': _("Revaluation of %s") % product_id.display_name, + 'stock_valuation_layer_ids': [(6, None, [revaluation_svl.id])], + 'date': self.date or fields.Date.today(), + 'move_type': 'entry', + 'line_ids': [(0, 0, { + 'name': _('%s changed stock valuation from %s to %s of %s') % ( + self.env.user.name, self.current_value_svl, self.current_value_svl + self.added_value, product_id.display_name), + 'account_id': debit_account_id, + 'debit': abs(self.added_value), + 'credit': 0, + 'product_id': product_id.id, + }), (0, 0, { + 'name': _('%s changed stock valuation from %s to %s of %s') % ( + self.env.user.name, self.current_value_svl, self.current_value_svl + self.added_value, product_id.display_name), + 'account_id': credit_account_id, + 'debit': 0, + 'credit': abs(self.added_value), + 'product_id': product_id.id, + })], + } + account_move = self.env['account.move'].create(move_vals) + account_move.post() + + return True diff --git a/addons/stock_account/wizard/stock_valuation_layer_revaluation_views.xml b/addons/stock_account/wizard/stock_valuation_layer_revaluation_views.xml new file mode 100644 index 00000000000..ffe75dc5236 --- /dev/null +++ b/addons/stock_account/wizard/stock_valuation_layer_revaluation_views.xml @@ -0,0 +1,44 @@ + + + + + stock.valuation.layer.revaluation.form + stock.valuation.layer.revaluation + + + + + + + for + + + + = ( by ) + Use a negative added value to record a decrease in the product value + + + + + + + + + + + + + + + + + + + + + + +