diff --git a/addons/stock_account/tests/test_stockvaluation.py b/addons/stock_account/tests/test_stockvaluation.py index e12b8fa29f9..6cfac5552b8 100644 --- a/addons/stock_account/tests/test_stockvaluation.py +++ b/addons/stock_account/tests/test_stockvaluation.py @@ -8,6 +8,44 @@ from odoo.fields import Datetime from odoo.tests.common import Form, SavepointCase +def _create_accounting_data(env): + """Create the accounts and journals used in stock valuation. + + :param env: environment used to create the records + :return: an input account, an output account, a valuation account, an expense account, a stock journal + """ + stock_input_account = env['account.account'].create({ + 'name': 'Stock Input', + 'code': 'StockIn', + 'user_type_id': env.ref('account.data_account_type_current_assets').id, + 'reconcile': True, + }) + stock_output_account = env['account.account'].create({ + 'name': 'Stock Output', + 'code': 'StockOut', + 'user_type_id': env.ref('account.data_account_type_current_assets').id, + 'reconcile': True, + }) + stock_valuation_account = env['account.account'].create({ + 'name': 'Stock Valuation', + 'code': 'Stock Valuation', + 'user_type_id': env.ref('account.data_account_type_current_assets').id, + 'reconcile': True, + }) + expense_account = env['account.account'].create({ + 'name': 'Expense Account', + 'code': 'Expense Account', + 'user_type_id': env.ref('account.data_account_type_expenses').id, + 'reconcile': True, + }) + stock_journal = env['account.journal'].create({ + 'name': 'Stock Journal', + 'code': 'STJTEST', + 'type': 'general', + }) + return stock_input_account, stock_output_account, stock_valuation_account, expense_account, stock_journal + + class TestStockValuation(SavepointCase): @classmethod def setUpClass(cls): @@ -37,34 +75,9 @@ class TestStockValuation(SavepointCase): 'groups_id': [(6, 0, [cls.env.ref('stock.group_stock_user').id])] }) + 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.categ_id.property_valuation = 'real_time' cls.product2.categ_id.property_valuation = 'real_time' - Account = cls.env['account.account'] - cls.stock_input_account = Account.create({ - 'name': 'Stock Input', - 'code': 'StockIn', - 'user_type_id': cls.env.ref('account.data_account_type_current_assets').id, - }) - cls.stock_output_account = Account.create({ - 'name': 'Stock Output', - 'code': 'StockOut', - 'user_type_id': cls.env.ref('account.data_account_type_current_assets').id, - }) - cls.stock_valuation_account = Account.create({ - 'name': 'Stock Valuation', - 'code': 'Stock Valuation', - 'user_type_id': cls.env.ref('account.data_account_type_current_assets').id, - }) - cls.expense_account = Account.create({ - 'name': 'Expense Account', - 'code': 'Expense Account', - 'user_type_id': cls.env.ref('account.data_account_type_expenses').id, - }) - cls.stock_journal = cls.env['account.journal'].create({ - 'name': 'Stock Journal', - 'code': 'STJTEST', - 'type': 'general', - }) cls.product1.write({ 'property_account_expense_id': cls.expense_account.id, }) diff --git a/addons/stock_account/tests/test_stockvaluationlayer.py b/addons/stock_account/tests/test_stockvaluationlayer.py index dd2d65dd75e..1cc6df146b6 100644 --- a/addons/stock_account/tests/test_stockvaluationlayer.py +++ b/addons/stock_account/tests/test_stockvaluationlayer.py @@ -3,6 +3,7 @@ """ Implementation of "INVENTORY VALUATION TESTS (With valuation layers)" spreadsheet. """ +from odoo.addons.stock_account.tests.test_stockvaluation import _create_accounting_data from odoo.tests import Form, tagged from odoo.tests.common import SavepointCase, TransactionCase @@ -709,8 +710,22 @@ class TestStockValuationChangeCostMethod(TestStockValuationCommon): self.assertEqual(self.product1.quantity_svl, 19) -@tagged('post_install', '-at_install') class TestStockValuationChangeValuation(TestStockValuationCommon): + @classmethod + def setUpClass(cls): + super(TestStockValuationChangeValuation, 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.categ_id.property_valuation = 'real_time' + 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, + }) + def test_standard_manual_to_auto_1(self): self.product1.product_tmpl_id.categ_id.property_cost_method = 'standard' self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic' @@ -743,9 +758,16 @@ class TestStockValuationChangeValuation(TestStockValuationCommon): self.assertEqual(len(self.product1.stock_valuation_layer_ids.mapped('account_move_id')), 0) self.assertEqual(len(self.product1.stock_valuation_layer_ids), 1) - cat2 = self.env['product.category'].create({'name': 'fifo'}) + cat2 = self.env['product.category'].create({'name': 'standard auto'}) cat2.property_cost_method = 'standard' cat2.property_valuation = 'real_time' + cat2.write({ + 'property_stock_account_input_categ_id': self.stock_input_account.id, + 'property_stock_account_output_categ_id': self.stock_output_account.id, + 'property_stock_valuation_account_id': self.stock_valuation_account.id, + 'property_stock_journal': self.stock_journal.id, + }) + self.product1.categ_id = cat2 self.assertEqual(self.product1.value_svl, 100) diff --git a/addons/stock_landed_costs/__manifest__.py b/addons/stock_landed_costs/__manifest__.py index 88e4de8dbd9..d11472b8e0b 100644 --- a/addons/stock_landed_costs/__manifest__.py +++ b/addons/stock_landed_costs/__manifest__.py @@ -19,6 +19,7 @@ This module allows you to easily add extra costs on pickings and decide the spli 'security/ir.model.access.csv', 'security/stock_landed_cost_security.xml', 'data/stock_landed_cost_data.xml', + 'views/account_move_views.xml', 'views/product_views.xml', 'views/stock_landed_cost_views.xml', 'views/stock_valuation_layer_views.xml', diff --git a/addons/stock_landed_costs/models/__init__.py b/addons/stock_landed_costs/models/__init__.py index a3a586e5c1f..43a7e03eabd 100644 --- a/addons/stock_landed_costs/models/__init__.py +++ b/addons/stock_landed_costs/models/__init__.py @@ -5,5 +5,6 @@ from . import product from . import res_company from . import res_config_settings from . import stock_landed_cost +from . import account_move from . import stock_valuation_layer diff --git a/addons/stock_landed_costs/models/account_move.py b/addons/stock_landed_costs/models/account_move.py new file mode 100644 index 00000000000..ae50e6d5ec5 --- /dev/null +++ b/addons/stock_landed_costs/models/account_move.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class AccountMove(models.Model): + _inherit = 'account.move' + + landed_costs_ids = fields.One2many('stock.landed.cost', 'vendor_bill_id', string='Landed Costs') + landed_costs_visible = fields.Boolean(compute='_compute_landed_costs_visible') + + @api.depends('line_ids', 'line_ids.is_landed_costs_line') + def _compute_landed_costs_visible(self): + for account_move in self: + if account_move.landed_costs_ids: + account_move.landed_costs_visible = False + else: + account_move.landed_costs_visible = any(line.is_landed_costs_line for line in account_move.line_ids) + + def button_create_landed_costs(self): + """Create a `stock.landed.cost` record associated to the account move of `self`, each + `stock.landed.costs` lines mirroring the current `account.move.line` of self. + """ + self.ensure_one() + landed_costs_lines = self.line_ids.filtered(lambda line: line.is_landed_costs_line) + + landed_costs = self.env['stock.landed.cost'].create({ + 'vendor_bill_id': self.id, + 'cost_lines': [(0, 0, { + 'product_id': l.product_id.id, + 'name': l.product_id.name, + 'account_id': l.product_id.product_tmpl_id.get_product_accounts()['stock_input'].id, + 'price_unit': l.price_subtotal, + 'split_method': 'equal', + }) for l in landed_costs_lines], + }) + action = self.env.ref('stock_landed_costs.action_stock_landed_cost').read()[0] + return dict(action, view_mode='form', res_id=landed_costs.id, views=[(False, 'form')]) + + def action_view_landed_costs(self): + self.ensure_one() + action = self.env.ref('stock_landed_costs.action_stock_landed_cost').read()[0] + domain = [('id', 'in', self.landed_costs_ids.ids)] + context = dict(self.env.context, default_vendor_bill_id=self.id) + views = [(self.env.ref('stock_landed_costs.view_stock_landed_cost_tree2').id, 'tree'), (False, 'form'), (False, 'kanban')] + return dict(action, domain=domain, context=context, views=views) + + +class AccountMoveLine(models.Model): + _inherit = 'account.move.line' + + product_type = fields.Selection(related='product_id.type', readonly=True) + is_landed_costs_line = fields.Boolean() + + @api.onchange('is_landed_costs_line') + def _onchange_is_landed_costs_line(self): + """Mark an invoice line as a landed cost line and adapt `self.account_id`. The default + value can be set according to `self.product_id.landed_cost_ok`.""" + if self.product_id: + accounts = self.product_id.product_tmpl_id._get_product_accounts() + if self.product_type != 'service': + self.account_id = accounts['expense'] + self.is_landed_costs_line = False + elif self.is_landed_costs_line and self.move_id.company_id.anglo_saxon_accounting: + self.account_id = accounts['stock_input'] + else: + self.account_id = accounts['expense'] + + @api.onchange('product_id') + def _onchange_is_landed_costs_line_product(self): + if self.product_id.landed_cost_ok: + self.is_landed_costs_line = True + else: + self.is_landed_costs_line = False diff --git a/addons/stock_landed_costs/models/stock_landed_cost.py b/addons/stock_landed_costs/models/stock_landed_cost.py index a516da51338..2e8b27357ce 100644 --- a/addons/stock_landed_costs/models/stock_landed_cost.py +++ b/addons/stock_landed_costs/models/stock_landed_cost.py @@ -118,6 +118,7 @@ class LandedCost(models.Model): 'date': cost.date, 'ref': cost.name, 'line_ids': [], + 'type': 'entry', } for line in cost.valuation_adjustment_lines.filtered(lambda line: line.move_id): remaining_qty = sum(line.move_id.stock_valuation_layer_ids.mapped('remaining_qty')) @@ -155,6 +156,13 @@ class LandedCost(models.Model): move = move.create(move_vals) cost.write({'state': 'done', 'account_move_id': move.id}) move.post() + + if cost.vendor_bill_id and cost.vendor_bill_id.state == 'posted' and cost.company_id.anglo_saxon_accounting: + all_amls = cost.vendor_bill_id.line_ids | cost.account_move_id.line_ids + for product in cost.cost_lines.product_id: + accounts = product.product_tmpl_id.get_product_accounts() + input_account = accounts['stock_input'] + all_amls.filtered(lambda aml: aml.account_id == input_account).reconcile() return True def _check_sum(self): @@ -285,7 +293,8 @@ class LandedCostLine(models.Model): self.name = self.product_id.name or '' self.split_method = 'equal' self.price_unit = self.product_id.standard_price or 0.0 - self.account_id = self.product_id.property_account_expense_id.id or self.product_id.categ_id.property_account_expense_categ_id.id + accounts_data = self.product_id.product_tmpl_id.get_product_accounts() + self.account_id = accounts_data['stock_input'] class AdjustmentLines(models.Model): @@ -341,7 +350,7 @@ class AdjustmentLines(models.Model): if self.move_id._is_dropshipped(): debit_account_id = accounts.get('expense') and accounts['expense'].id or False already_out_account_id = accounts['stock_output'].id - credit_account_id = self.cost_line_id.account_id.id or cost_product.property_account_expense_id.id or cost_product.categ_id.property_account_expense_categ_id.id + credit_account_id = self.cost_line_id.account_id.id or cost_product.categ_id.property_stock_account_input_categ_id.id if not credit_account_id: raise UserError(_('Please configure Stock Expense Account for product: %s.') % (cost_product.name)) @@ -394,12 +403,12 @@ class AdjustmentLines(models.Model): AccountMoveLine.append([0, 0, debit_line]) AccountMoveLine.append([0, 0, credit_line]) - # TDE FIXME: oh dear if self.env.company.anglo_saxon_accounting: + expense_account_id = self.product_id.product_tmpl_id.get_product_accounts()['expense'].id debit_line = dict(base_line, name=(self.name + ": " + str(qty_out) + _(' already out')), quantity=0, - account_id=credit_account_id) + account_id=expense_account_id) credit_line = dict(base_line, name=(self.name + ": " + str(qty_out) + _(' already out')), quantity=0, diff --git a/addons/stock_landed_costs/tests/test_stockvaluationlayer.py b/addons/stock_landed_costs/tests/test_stockvaluationlayer.py index db4525b8af8..b54273790f9 100644 --- a/addons/stock_landed_costs/tests/test_stockvaluationlayer.py +++ b/addons/stock_landed_costs/tests/test_stockvaluationlayer.py @@ -5,6 +5,7 @@ from odoo.tests import Form, tagged from odoo.addons.stock_account.tests.test_stockvaluationlayer import TestStockValuationCommon +from odoo.addons.stock_account.tests.test_stockvaluation import _create_accounting_data class TestStockValuationLC(TestStockValuationCommon): @@ -16,11 +17,55 @@ class TestStockValuationLC(TestStockValuationCommon): 'type': 'service', 'categ_id': cls.env.ref('product.product_category_all').id, }) + 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.productlc1.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.payable_account = cls.env['account.account'].create({ + 'name': 'payable', + 'code': 'payable', + 'user_type_id': cls.env.ref('account.data_account_type_payable').id, + 'reconcile': True, + }) + + def _get_stock_input_move_lines(self): + return self.env['account.move.line'].search([ + ('account_id', '=', self.stock_input_account.id), + ], order='date, id') + + def _get_stock_output_move_lines(self): + return self.env['account.move.line'].search([ + ('account_id', '=', self.stock_output_account.id), + ], order='date, id') + + def _get_stock_valuation_move_lines(self): + return self.env['account.move.line'].search([ + ('account_id', '=', self.stock_valuation_account.id), + ], order='date, id') + + def _get_payable_move_lines(self): + return self.env['account.move.line'].search([ + ('account_id', '=', self.payable_account.id), + ], order='id') + + def _get_expense_move_lines(self): + return self.env['account.move.line'].search([ + ('account_id', '=', self.expense_account.id), + ], order='id') def _make_lc(self, move, amount): picking = move.picking_id lc = Form(self.env['stock.landed.cost']) - lc.account_journal_id = self.env['account.journal'].search([('name', 'ilike', '%misc%')], limit=1) + lc.account_journal_id = self.stock_journal lc.picking_ids.add(move.picking_id) with lc.cost_lines.new() as cost_line: cost_line.product_id = self.productlc1 @@ -31,7 +76,6 @@ class TestStockValuationLC(TestStockValuationCommon): return lc -@tagged('post_install', '-at_install') class TestStockValuationLCFIFO(TestStockValuationLC): def setUp(self): super(TestStockValuationLCFIFO, self).setUp() @@ -96,7 +140,6 @@ class TestStockValuationLCFIFO(TestStockValuationLC): self.assertEqual(in_svl.value, 225) -@tagged('post_install', '-at_install') class TestStockValuationLCAVCO(TestStockValuationLC): def setUp(self): super(TestStockValuationLCAVCO, self).setUp() @@ -137,3 +180,189 @@ class TestStockValuationLCAVCO(TestStockValuationLC): self.assertEqual(self.product1.value_svl, 375) self.assertEqual(self.product1.quantity_svl, 19) + + +class TestStockValuationLCFIFOVB(TestStockValuationLC): + @classmethod + def setUpClass(cls): + super(TestStockValuationLCFIFOVB, cls).setUpClass() + cls.vendor1 = cls.env['res.partner'].search([], limit=1) + cls.vendor1.property_account_payable_id = cls.payable_account + cls.vendor2 = cls.env['res.partner'].search([], limit=2)[-1] + cls.vendor2.property_account_payable_id = cls.payable_account + cls.product1.product_tmpl_id.categ_id.property_cost_method = 'fifo' + cls.product1.product_tmpl_id.categ_id.property_valuation = 'real_time' + + def test_vendor_bill_flow_anglo_saxon_1(self): + """In anglo saxon accounting, receive 10@10 and invoice. Then invoice 1@50 as a landed costs + and create a linked landed costs record. + """ + self.env.company.anglo_saxon_accounting = True + + # Create an RFQ for self.product1, 10@10 + rfq = Form(self.env['purchase.order']) + rfq.partner_id = self.vendor1 + + with rfq.order_line.new() as po_line: + po_line.product_id = self.product1 + po_line.price_unit = 10 + po_line.product_qty = 10 + po_line.taxes_id.clear() + + rfq = rfq.save() + rfq.button_confirm() + + # Process the receipt + receipt = rfq.picking_ids + wiz = receipt.button_validate() + wiz = self.env['stock.immediate.transfer'].browse(wiz['res_id']).process() + self.assertEqual(rfq.order_line.qty_received, 10) + + input_aml = self._get_stock_input_move_lines()[-1] + self.assertEqual(input_aml.debit, 0) + self.assertEqual(input_aml.credit, 100) + valuation_aml = self._get_stock_valuation_move_lines()[-1] + self.assertEqual(valuation_aml.debit, 100) + self.assertEqual(valuation_aml.credit, 0) + + # Create a vebdor bill for the RFQ + action = rfq.action_view_invoice() + vb = Form(self.env['account.move'].with_context(action['context'])) + vb = vb.save() + vb.post() + + input_aml = self._get_stock_input_move_lines()[-1] + self.assertEqual(input_aml.debit, 100) + self.assertEqual(input_aml.credit, 0) + payable_aml = self._get_payable_move_lines()[-1] + self.assertEqual(payable_aml.debit, 0) + self.assertEqual(payable_aml.credit, 100) + + # Create a vendor bill for a landed cost product, post it and validate a landed cost + # linked to this vendor bill. LC; 1@50 + lcvb = Form(self.env['account.move'].with_context(default_type='in_invoice')) + lcvb.partner_id = self.vendor2 + with lcvb.invoice_line_ids.new() as inv_line: + inv_line.product_id = self.productlc1 + inv_line.price_unit = 50 + inv_line.is_landed_costs_line = True + with lcvb.invoice_line_ids.edit(0) as inv_line: + inv_line.tax_ids.clear() + lcvb = lcvb.save() + lcvb.post() + + input_aml = self._get_stock_input_move_lines()[-1] + self.assertEqual(input_aml.debit, 50) + self.assertEqual(input_aml.credit, 0) + payable_aml = self._get_payable_move_lines()[-1] + self.assertEqual(payable_aml.debit, 0) + self.assertEqual(payable_aml.credit, 50) + + action = lcvb.button_create_landed_costs() + lc = Form(self.env[action['res_model']].browse(action['res_id'])) + lc.picking_ids.add(receipt) + lc = lc.save() + lc.button_validate() + + self.assertEqual(lc.cost_lines.price_unit, 50) + self.assertEqual(lc.cost_lines.product_id, self.productlc1) + + input_aml = self._get_stock_input_move_lines()[-1] + self.assertEqual(input_aml.debit, 0) + self.assertEqual(input_aml.credit, 50) + valuation_aml = self._get_stock_valuation_move_lines()[-1] + self.assertEqual(valuation_aml.debit, 50) + self.assertEqual(valuation_aml.credit, 0) + + # Check reconciliation of input aml of lc + lc_input_aml = lc.account_move_id.line_ids.filtered(lambda aml: aml.account_id == self.stock_input_account) + self.assertTrue(len(lc_input_aml.full_reconcile_id), 1) + + self.assertEqual(self.product1.quantity_svl, 10) + self.assertEqual(self.product1.value_svl, 150) + + def test_vendor_bill_flow_continental_1(self): + """In continental accounting, receive 10@10 and invoice. Then invoice 1@50 as a landed costs + and create a linked landed costs record. + """ + self.env.company.anglo_saxon_accounting = False + + # Create an RFQ for self.product1, 10@10 + rfq = Form(self.env['purchase.order']) + rfq.partner_id = self.vendor1 + + with rfq.order_line.new() as po_line: + po_line.product_id = self.product1 + po_line.price_unit = 10 + po_line.product_qty = 10 + po_line.taxes_id.clear() + + rfq = rfq.save() + rfq.button_confirm() + + # Process the receipt + receipt = rfq.picking_ids + wiz = receipt.button_validate() + wiz = self.env['stock.immediate.transfer'].browse(wiz['res_id']).process() + self.assertEqual(rfq.order_line.qty_received, 10) + + input_aml = self._get_stock_input_move_lines()[-1] + self.assertEqual(input_aml.debit, 0) + self.assertEqual(input_aml.credit, 100) + valuation_aml = self._get_stock_valuation_move_lines()[-1] + self.assertEqual(valuation_aml.debit, 100) + self.assertEqual(valuation_aml.credit, 0) + + # Create a vebdor bill for the RFQ + action = rfq.action_view_invoice() + vb = Form(self.env['account.move'].with_context(action['context'])) + vb = vb.save() + vb.post() + + expense_aml = self._get_expense_move_lines()[-1] + self.assertEqual(expense_aml.debit, 100) + self.assertEqual(expense_aml.credit, 0) + + payable_aml = self._get_payable_move_lines()[-1] + self.assertEqual(payable_aml.debit, 0) + self.assertEqual(payable_aml.credit, 100) + + # Create a vendor bill for a landed cost product, post it and validate a landed cost + # linked to this vendor bill. LC; 1@50 + lcvb = Form(self.env['account.move'].with_context(default_type='in_invoice')) + lcvb.partner_id = self.vendor2 + with lcvb.invoice_line_ids.new() as inv_line: + inv_line.product_id = self.productlc1 + inv_line.price_unit = 50 + inv_line.is_landed_costs_line = True + with lcvb.invoice_line_ids.edit(0) as inv_line: + inv_line.tax_ids.clear() + lcvb = lcvb.save() + lcvb.post() + + expense_aml = self._get_expense_move_lines()[-1] + self.assertEqual(expense_aml.debit, 50) + self.assertEqual(expense_aml.credit, 0) + payable_aml = self._get_payable_move_lines()[-1] + self.assertEqual(payable_aml.debit, 0) + self.assertEqual(payable_aml.credit, 50) + + action = lcvb.button_create_landed_costs() + lc = Form(self.env[action['res_model']].browse(action['res_id'])) + lc.picking_ids.add(receipt) + lc = lc.save() + lc.button_validate() + + self.assertEqual(lc.cost_lines.price_unit, 50) + self.assertEqual(lc.cost_lines.product_id, self.productlc1) + + input_aml = self._get_stock_input_move_lines()[-1] + self.assertEqual(input_aml.debit, 0) + self.assertEqual(input_aml.credit, 50) + valuation_aml = self._get_stock_valuation_move_lines()[-1] + self.assertEqual(valuation_aml.debit, 50) + self.assertEqual(valuation_aml.credit, 0) + + self.assertEqual(self.product1.quantity_svl, 10) + self.assertEqual(self.product1.value_svl, 150) + diff --git a/addons/stock_landed_costs/views/account_move_views.xml b/addons/stock_landed_costs/views/account_move_views.xml new file mode 100644 index 00000000000..97ffde5283d --- /dev/null +++ b/addons/stock_landed_costs/views/account_move_views.xml @@ -0,0 +1,33 @@ + + + + account.view.move.form.inherited + account.move + + + + +