diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..dc5e6b6 --- /dev/null +++ b/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..8819369 --- /dev/null +++ b/__manifest__.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +{ + 'name': "Sale Stock Margin", + 'category': 'Sales/Sales', + 'description': 'Once the delivery is validated, update the cost on the SO to have an exact margin computation.', + 'version': '0.1', + 'depends': ['sale_stock', 'sale_margin'], + 'installable': True, + 'auto_install': True, + 'license': 'LGPL-3', +} diff --git a/i18n/ru.po b/i18n/ru.po new file mode 100644 index 0000000..b3089d6 --- /dev/null +++ b/i18n/ru.po @@ -0,0 +1,23 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * sale_stock_margin +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-10-26 21:55+0000\n" +"PO-Revision-Date: 2024-01-30 15:14+0400\n" +"Last-Translator: \n" +"Language-Team: Russian (https://app.transifex.com/odoo/teams/41243/ru/)\n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n%100>=11 && n%100<=14)? 2 : 3);\n" + +#. module: sale_stock_margin +#: model:ir.model,name:sale_stock_margin.model_sale_order_line +msgid "Sales Order Line" +msgstr "Позиция заказа на продажу" diff --git a/i18n/sale_stock_margin.pot b/i18n/sale_stock_margin.pot new file mode 100644 index 0000000..4a87698 --- /dev/null +++ b/i18n/sale_stock_margin.pot @@ -0,0 +1,21 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * sale_stock_margin +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-10-26 21:55+0000\n" +"PO-Revision-Date: 2023-10-26 21:55+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: sale_stock_margin +#: model:ir.model,name:sale_stock_margin.model_sale_order_line +msgid "Sales Order Line" +msgstr "" diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..83c06ee --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import sale_order_line diff --git a/models/sale_order_line.py b/models/sale_order_line.py new file mode 100644 index 0000000..f31f9ea --- /dev/null +++ b/models/sale_order_line.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class SaleOrderLine(models.Model): + _inherit = 'sale.order.line' + + @api.depends('move_ids', 'move_ids.stock_valuation_layer_ids', 'move_ids.picking_id.state') + def _compute_purchase_price(self): + lines_without_moves = self.browse() + for line in self: + product = line.product_id.with_company(line.company_id) + if not line.has_valued_move_ids(): + lines_without_moves |= line + elif product and product.categ_id.property_cost_method != 'standard': + purch_price = product._compute_average_price(0, line.product_uom_qty, line.move_ids) + if line.product_uom and line.product_uom != product.uom_id: + purch_price = product.uom_id._compute_price(purch_price, line.product_uom) + to_cur = line.currency_id or line.order_id.currency_id + line.purchase_price = line._convert_to_sol_currency( + purch_price, + product.cost_currency_id, + ) + return super(SaleOrderLine, lines_without_moves)._compute_purchase_price() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..c4661d8 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import test_sale_stock_margin diff --git a/tests/test_sale_stock_margin.py b/tests/test_sale_stock_margin.py new file mode 100644 index 0000000..6787951 --- /dev/null +++ b/tests/test_sale_stock_margin.py @@ -0,0 +1,317 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields + +from odoo.tests import Form, tagged +from odoo.addons.stock_account.tests.test_stockvaluationlayer import TestStockValuationCommon + + +@tagged('post_install', '-at_install') +class TestSaleStockMargin(TestStockValuationCommon): + + @classmethod + def setUpClass(cls): + super(TestSaleStockMargin, cls).setUpClass() + cls.pricelist = cls.env['product.pricelist'].create({'name': 'Simple Pricelist'}) + cls.env['res.currency.rate'].search([]).unlink() + + ######### + # UTILS # + ######### + + def _create_sale_order(self): + return self.env['sale.order'].create({ + 'name': 'Sale order', + 'partner_id': self.env.ref('base.partner_admin').id, + 'partner_invoice_id': self.env.ref('base.partner_admin').id, + 'pricelist_id': self.pricelist.id, + }) + + def _create_sale_order_line(self, sale_order, product, quantity, price_unit=0): + return self.env['sale.order.line'].create({ + 'name': 'Sale order', + 'order_id': sale_order.id, + 'price_unit': price_unit, + 'product_id': product.id, + 'product_uom_qty': quantity, + 'product_uom': self.env.ref('uom.product_uom_unit').id, + }) + + def _create_product(self): + product_template = self.env['product.template'].create({ + 'name': 'Super product', + 'type': 'product', + }) + product_template.categ_id.property_cost_method = 'fifo' + return product_template.product_variant_ids + + ######### + # TESTS # + ######### + + def test_sale_stock_margin_1(self): + sale_order = self._create_sale_order() + product = self._create_product() + + self._make_in_move(product, 2, 35) + self._make_out_move(product, 1) + + order_line = self._create_sale_order_line(sale_order, product, 1, 50) + sale_order.action_confirm() + + self.assertEqual(order_line.purchase_price, 35) + self.assertEqual(sale_order.margin, 15) + + sale_order.picking_ids.move_ids.write({'quantity': 1, 'picked': True}) + sale_order.picking_ids.button_validate() + + self.assertEqual(order_line.purchase_price, 35) + self.assertEqual(order_line.margin, 15) + self.assertEqual(sale_order.margin, 15) + + def test_sale_stock_margin_2(self): + sale_order = self._create_sale_order() + product = self._create_product() + + self._make_in_move(product, 2, 32) + self._make_in_move(product, 5, 17) + self._make_out_move(product, 1) + + order_line = self._create_sale_order_line(sale_order, product, 2, 50) + sale_order.action_confirm() + + self.assertEqual(order_line.purchase_price, 19.5) + self.assertAlmostEqual(sale_order.margin, 61) + + sale_order.picking_ids.move_ids.write({'quantity': 2, 'picked': True}) + sale_order.picking_ids.button_validate() + + self.assertAlmostEqual(order_line.purchase_price, 24.5) + self.assertAlmostEqual(order_line.margin, 51) + self.assertAlmostEqual(sale_order.margin, 51) + + def test_sale_stock_margin_3(self): + sale_order = self._create_sale_order() + product = self._create_product() + + self._make_in_move(product, 2, 10) + self._make_out_move(product, 1) + + order_line = self._create_sale_order_line(sale_order, product, 2, 20) + sale_order.action_confirm() + + self.assertEqual(order_line.purchase_price, 10) + self.assertAlmostEqual(sale_order.margin, 20) + + sale_order.picking_ids.move_ids.write({'quantity': 1, 'picked': True}) + sale_order.picking_ids.button_validate() + + self.assertAlmostEqual(order_line.purchase_price, 10) + self.assertAlmostEqual(order_line.margin, 20) + self.assertAlmostEqual(sale_order.margin, 20) + + def test_sale_stock_margin_4(self): + sale_order = self._create_sale_order() + product = self._create_product() + + self._make_in_move(product, 2, 10) + self._make_in_move(product, 1, 20) + self._make_out_move(product, 1) + + order_line = self._create_sale_order_line(sale_order, product, 2, 20) + sale_order.action_confirm() + + self.assertEqual(order_line.purchase_price, 15) + self.assertAlmostEqual(sale_order.margin, 10) + + sale_order.picking_ids.move_ids.write({'quantity': 1, 'picked': True}) + res = sale_order.picking_ids.button_validate() + Form(self.env[res['res_model']].with_context(res['context'])).save().process() + + self.assertAlmostEqual(order_line.purchase_price, 15) + self.assertAlmostEqual(order_line.margin, 10) + self.assertAlmostEqual(sale_order.margin, 10) + + def test_sale_stock_margin_5(self): + sale_order = self._create_sale_order() + product_1 = self._create_product() + product_2 = self._create_product() + + self._make_in_move(product_1, 2, 35) + self._make_in_move(product_1, 1, 51) + self._make_out_move(product_1, 1) + + self._make_in_move(product_2, 2, 17) + self._make_in_move(product_2, 1, 11) + self._make_out_move(product_2, 1) + + order_line_1 = self._create_sale_order_line(sale_order, product_1, 2, 60) + order_line_2 = self._create_sale_order_line(sale_order, product_2, 4, 20) + sale_order.action_confirm() + + self.assertAlmostEqual(order_line_1.purchase_price, 43) + self.assertAlmostEqual(order_line_2.purchase_price, 14) + self.assertAlmostEqual(order_line_1.margin, 17 * 2) + self.assertAlmostEqual(order_line_2.margin, 6 * 4) + self.assertAlmostEqual(sale_order.margin, 58) + + sale_order.picking_ids.move_ids[0].write({'quantity': 2, 'picked': True}) + sale_order.picking_ids.move_ids[1].write({'quantity': 3, 'picked': True}) + + res = sale_order.picking_ids.button_validate() + Form(self.env[res['res_model']].with_context(res['context'])).save().process() + + self.assertAlmostEqual(order_line_1.purchase_price, 43) # (35 + 51) / 2 + self.assertAlmostEqual(order_line_2.purchase_price, 12.5) # (17 + 11 + 11 + 11) / 4 + self.assertAlmostEqual(order_line_1.margin, 34) # (60 - 43) * 2 + self.assertAlmostEqual(order_line_2.margin, 30) # (20 - 12.5) * 4 + self.assertAlmostEqual(sale_order.margin, 64) + + def test_sale_stock_margin_6(self): + """ Test that the purchase price doesn't change when there is a service product in the SO""" + service = self.env['product.product'].create({ + 'name': 'Service', + 'type': 'service', + 'list_price': 100.0, + 'standard_price': 50.0}) + self.product1.list_price = 80.0 + self.product1.standard_price = 40.0 + sale_order = self._create_sale_order() + order_line_1 = self._create_sale_order_line(sale_order, service, 1, 100) + order_line_2 = self._create_sale_order_line(sale_order, self.product1, 1, 80) + + self.assertEqual(order_line_1.purchase_price, 50, "Sales order line cost should be 50.00") + self.assertEqual(order_line_2.purchase_price, 40, "Sales order line cost should be 40.00") + + self.assertEqual(order_line_1.margin, 50, "Sales order line profit should be 50.00") + self.assertEqual(order_line_2.margin, 40, "Sales order line profit should be 40.00") + self.assertEqual(sale_order.margin, 90, "Sales order profit should be 90.00") + + # Change the purchase price of the service product. + order_line_1.purchase_price = 100.0 + self.assertEqual(order_line_1.purchase_price, 100, "Sales order line cost should be 100.00") + + # Confirm the sales order. + sale_order.action_confirm() + + self.assertEqual(order_line_1.purchase_price, 100, "Sales order line cost should be 100.00") + self.assertEqual(order_line_2.purchase_price, 40, "Sales order line cost should be 40.00") + + def test_so_and_multicurrency(self): + ResCurrencyRate = self.env['res.currency.rate'] + company_currency = self.env.company.currency_id + other_currency = self.env.ref('base.EUR') if company_currency == self.env.ref('base.USD') else self.env.ref('base.USD') + + date = fields.Date.today() + ResCurrencyRate.create({'currency_id': company_currency.id, 'rate': 1, 'name': date}) + other_currency_rate = ResCurrencyRate.search([('name', '=', date), ('currency_id', '=', other_currency.id)]) + if other_currency_rate: + other_currency_rate.rate = 2 + else: + ResCurrencyRate.create({'currency_id': other_currency.id, 'rate': 2, 'name': date}) + + so = self._create_sale_order() + so.pricelist_id = self.env['product.pricelist'].create({ + 'name': 'Super Pricelist', + 'currency_id': other_currency.id, + }) + + product = self._create_product() + product.standard_price = 100 + product.list_price = 200 + + so_form = Form(so) + with so_form.order_line.new() as line: + line.product_id = product + so = so_form.save() + so_line = so.order_line + + self.assertEqual(so_line.purchase_price, 200) + self.assertEqual(so_line.price_unit, 400) + so.action_confirm() + self.assertEqual(so_line.purchase_price, 200) + self.assertEqual(so_line.price_unit, 400) + + def test_so_and_multicompany(self): + """ In a multicompany environnement, when the user is on company C01 and confirms a SO that + belongs to a second company C02, this test ensures that the computations will be based on + C02's data""" + main_company = self.env['res.company']._get_main_company() + main_company_currency = main_company.currency_id + new_company_currency = self.env.ref('base.EUR') if main_company_currency == self.env.ref('base.USD') else self.env.ref('base.USD') + + date = fields.Date.today() + self.env['res.currency.rate'].create([ + {'currency_id': main_company_currency.id, 'rate': 1, 'name': date, 'company_id': False}, + {'currency_id': new_company_currency.id, 'rate': 3, 'name': date, 'company_id': False}, + ]) + + new_company = self.env['res.company'].create({ + 'name': 'Super Company', + 'currency_id': new_company_currency.id, + }) + self.env.user.company_id = new_company.id + + self.pricelist.currency_id = new_company_currency.id + + product = self._create_product() + + incoming_picking_type = self.env['stock.picking.type'].search([('company_id', '=', new_company.id), ('code', '=', 'incoming')], limit=1) + production_location = self.env['stock.location'].search([('company_id', '=', new_company.id), ('usage', '=', 'production')]) + + picking = self.env['stock.picking'].create({ + 'picking_type_id': incoming_picking_type.id, + 'location_id': production_location.id, + 'location_dest_id': incoming_picking_type.default_location_dest_id.id, + }) + self.env['stock.move'].create({ + 'name': 'Incoming Product', + 'product_id': product.id, + 'location_id': production_location.id, + 'location_dest_id': incoming_picking_type.default_location_dest_id.id, + 'product_uom': product.uom_id.id, + 'product_uom_qty': 1, + 'price_unit': 100, + 'picking_type_id': incoming_picking_type.id, + 'picking_id': picking.id, + }) + picking.action_confirm() + picking.button_validate() + + self.pricelist.currency_id = new_company_currency.id + partner = self.env['res.partner'].create({'name': 'Super Partner'}) + so = self.env['sale.order'].create({ + 'name': 'Sale order', + 'partner_id': partner.id, + 'partner_invoice_id': partner.id, + 'pricelist_id': self.pricelist.id, + }) + sol = self._create_sale_order_line(so, product, 1, price_unit=200) + + self.env.user.company_id = main_company.id + so.action_confirm() + + self.assertEqual(sol.purchase_price, 100) + self.assertEqual(sol.margin, 100) + + def test_purchase_price_changes(self): + so = self._create_sale_order() + product = self._create_product() + product.categ_id.property_cost_method = 'standard' + product.standard_price = 20 + self._create_sale_order_line(so, product, 1, product.list_price) + + so_form = Form(so) + with so_form.order_line.edit(0) as line: + line.purchase_price = 15 + so = so_form.save() + email_act = so.action_quotation_send() + email_ctx = email_act.get('context', {}) + so.with_context(**email_ctx).message_post_with_source( + self.env['mail.template'].browse(email_ctx.get('default_template_id')), + subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment'), + ) + + self.assertEqual(so.state, 'sent') + self.assertEqual(so.order_line[0].purchase_price, 15)