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..9c4a8be --- /dev/null +++ b/__manifest__.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'MTO Sale <-> Purchase', + 'version': '1.0', + 'category': 'Hidden', + 'summary': 'SO/PO relation in case of MTO', + 'description': """ +Add relation information between Sale Orders and Purchase Orders if Make to Order (MTO) is activated on one sold product. +""", + 'depends': ['sale_stock', 'purchase_stock', 'sale_purchase'], + 'installable': True, + 'auto_install': True, + 'license': 'LGPL-3', +} diff --git a/i18n/ru.po b/i18n/ru.po new file mode 100644 index 0000000..e46102a --- /dev/null +++ b/i18n/ru.po @@ -0,0 +1,28 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * sale_purchase_stock +# +# 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_purchase_stock +#: model:ir.model,name:sale_purchase_stock.model_purchase_order +msgid "Purchase Order" +msgstr "Заказа на покупку" + +#. module: sale_purchase_stock +#: model:ir.model,name:sale_purchase_stock.model_sale_order +msgid "Sales Order" +msgstr "Заказ на продажу" diff --git a/i18n/sale_purchase_stock.pot b/i18n/sale_purchase_stock.pot new file mode 100644 index 0000000..8f62428 --- /dev/null +++ b/i18n/sale_purchase_stock.pot @@ -0,0 +1,26 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * sale_purchase_stock +# +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_purchase_stock +#: model:ir.model,name:sale_purchase_stock.model_purchase_order +msgid "Purchase Order" +msgstr "" + +#. module: sale_purchase_stock +#: model:ir.model,name:sale_purchase_stock.model_sale_order +msgid "Sales Order" +msgstr "" diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..9ed2642 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import purchase_order +from . import sale_order diff --git a/models/purchase_order.py b/models/purchase_order.py new file mode 100644 index 0000000..09ec51a --- /dev/null +++ b/models/purchase_order.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models + + +class PurchaseOrder(models.Model): + _inherit = 'purchase.order' + + @api.depends('order_line.move_dest_ids.group_id.sale_id', 'order_line.move_ids.move_dest_ids.group_id.sale_id') + def _compute_sale_order_count(self): + super(PurchaseOrder, self)._compute_sale_order_count() + + def _get_sale_orders(self): + return super(PurchaseOrder, self)._get_sale_orders() | self.order_line.move_dest_ids.group_id.sale_id | self.order_line.move_ids.move_dest_ids.group_id.sale_id diff --git a/models/sale_order.py b/models/sale_order.py new file mode 100644 index 0000000..50ff80a --- /dev/null +++ b/models/sale_order.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + @api.depends('procurement_group_id.stock_move_ids.created_purchase_line_ids.order_id', 'procurement_group_id.stock_move_ids.move_orig_ids.purchase_line_id.order_id') + def _compute_purchase_order_count(self): + super(SaleOrder, self)._compute_purchase_order_count() + + def _get_purchase_orders(self): + return super(SaleOrder, self)._get_purchase_orders() | self.procurement_group_id.stock_move_ids.created_purchase_line_ids.order_id | self.procurement_group_id.stock_move_ids.move_orig_ids.purchase_line_id.order_id diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..32d6b90 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_sale_purchase_stock_flow +from . import test_access_rights +from . import test_unwanted_replenish_flow diff --git a/tests/test_access_rights.py b/tests/test_access_rights.py new file mode 100644 index 0000000..fa66f43 --- /dev/null +++ b/tests/test_access_rights.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import tagged +from odoo.addons.sale_purchase.tests.common import TestCommonSalePurchaseNoChart + + +@tagged('post_install', '-at_install') +class TestAccessRights(TestCommonSalePurchaseNoChart): + + @classmethod + def setUpClass(cls): + super(TestAccessRights, cls).setUpClass() + + group_sale_user = cls.env.ref('sales_team.group_sale_salesman') + + cls.user_salesperson = cls.env['res.users'].with_context(no_reset_password=True).create({ + 'name': 'Le Grand Jojo User', + 'login': 'grand.jojo', + 'email': 'grand.jojo@chansonbelge.com', + 'groups_id': [(6, 0, [group_sale_user.id])] + }) + + def test_access_saleperson_decreases_qty(self): + """ + Suppose a user who has no right on PO + Suppose a PO linked to a SO + The user decreases the qty on the SO + This test ensures that an activity (warning) is added to the PO + """ + mto_route = self.env.ref('stock.route_warehouse0_mto') + buy_route = self.env.ref('purchase_stock.route_warehouse0_buy') + mto_route.active = True + + vendor = self.env['res.partner'].create({'name': 'vendor'}) + seller = self.env['product.supplierinfo'].create({ + 'partner_id': vendor.id, + 'price': 8, + }) + + product = self.env['product.product'].create({ + 'name': 'SuperProduct', + 'type': 'product', + 'seller_ids': [(6, 0, seller.ids)], + 'route_ids': [(6, 0, (mto_route + buy_route).ids)] + }) + + so = self.env['sale.order'].with_user(self.user_salesperson).create({ + 'partner_id': self.partner_a.id, + 'user_id': self.user_salesperson.id, + }) + so_line, _ = self.env['sale.order.line'].create([{ + 'name': product.name, + 'product_id': product.id, + 'product_uom_qty': 1, + 'product_uom': product.uom_id.id, + 'price_unit': product.list_price, + 'tax_id': False, + 'order_id': so.id, + }, { + 'name': 'Super Section', + 'display_type': 'line_section', + 'order_id': so.id, + }]) + + so.action_confirm() + + po = self.env['purchase.order'].search([('partner_id', '=', vendor.id)]) + po.button_confirm() + + # salesperson writes on the SO + so.write({ + 'order_line': [(1, so_line.id, {'product_uom_qty': 0.9})] + }) + + self.assertIn(so.name, po.activity_ids.note) diff --git a/tests/test_lead_time.py b/tests/test_lead_time.py new file mode 100644 index 0000000..c29bf84 --- /dev/null +++ b/tests/test_lead_time.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import timedelta + +from odoo import fields +from odoo.tests import tagged +from odoo.addons.sale_purchase.tests.common import TestCommonSalePurchaseNoChart + + +@tagged('post_install', '-at_install') +class TestLeadTime(TestCommonSalePurchaseNoChart): + + @classmethod + def setUpClass(cls): + super(TestLeadTime, cls).setUpClass() + + cls.buy_route = cls.env.ref('purchase_stock.route_warehouse0_buy') + cls.mto_route = cls.env.ref('stock.route_warehouse0_mto') + cls.mto_route.active = True + cls.vendor = cls.env['res.partner'].create({'name': 'The Emperor'}) + cls.user_salesperson = cls.env['res.users'].with_context(no_reset_password=True).create({ + 'name': 'Le Grand Horus', + 'login': 'grand.horus', + 'email': 'grand.horus@chansonbelge.dz', + }) + + + def test_supplier_lead_time(self): + """ Basic stock configuration and a supplier with a minimum qty and a lead time """ + + self.env.user.company_id.po_lead = 7 + seller = self.env['product.supplierinfo'].create({ + 'name': self.vendor.id, + 'min_qty': 1, + 'price': 10, + 'date_start': fields.Date.today() - timedelta(days=1), + }) + + product = self.env['product.product'].create({ + 'name': 'corpse starch', + 'type': 'product', + 'seller_ids': [(6, 0, seller.ids)], + 'route_ids': [(6, 0, (self.mto_route + self.buy_route).ids)], + }) + + so = self.env['sale.order'].with_user(self.user_salesperson).create({ + 'partner_id': self.partner_a.id, + 'user_id': self.user_salesperson.id, + }) + self.env['sale.order.line'].create({ + 'name': product.name, + 'product_id': product.id, + 'product_uom_qty': 1, + 'product_uom': product.uom_id.id, + 'price_unit': product.list_price, + 'tax_id': False, + 'order_id': so.id, + }) + so.action_confirm() + + po = self.env['purchase.order'].search([('partner_id', '=', self.vendor.id)]) + self.assertEqual(po.order_line.price_unit, seller.price) diff --git a/tests/test_sale_purchase_stock_flow.py b/tests/test_sale_purchase_stock_flow.py new file mode 100644 index 0000000..d5c6c1b --- /dev/null +++ b/tests/test_sale_purchase_stock_flow.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests.common import TransactionCase, Form +from freezegun import freeze_time +from datetime import datetime, timedelta + +class TestSalePurchaseStockFlow(TransactionCase): + + @classmethod + def setUpClass(cls): + super(TestSalePurchaseStockFlow, cls).setUpClass() + cls.mto_route = cls.env.ref('stock.route_warehouse0_mto') + cls.buy_route = cls.env.ref('purchase_stock.route_warehouse0_buy') + cls.mto_route.active = True + + cls.customer_location = cls.env.ref('stock.stock_location_customers') + + cls.vendor = cls.env['res.partner'].create({'name': 'Super Vendor'}) + cls.customer = cls.env['res.partner'].create({'name': 'Super Customer'}) + + cls.mto_product = cls.env['product.product'].create({ + 'name': 'SuperProduct', + 'type': 'product', + 'route_ids': [(6, 0, (cls.mto_route + cls.buy_route).ids)], + 'seller_ids': [(0, 0, { + 'partner_id': cls.vendor.id, + })], + }) + + def test_cancel_so_with_draft_po(self): + """ + Sell a MTO+Buy product -> a PO is generated + Cancel the SO -> an activity should be added to the PO + """ + so_form = Form(self.env['sale.order']) + so_form.partner_id = self.env.user.partner_id + with so_form.order_line.new() as line: + line.product_id = self.mto_product + so = so_form.save() + so.action_confirm() + + po = self.env['purchase.order'].search([('partner_id', '=', self.vendor.id)]) + + so._action_cancel() + + self.assertTrue(po.activity_ids) + self.assertIn(so.name, po.activity_ids.note) + + def test_qty_delivered_with_mto_and_done_quantity_change(self): + """ + MTO product P + Sell 10 x P. On the delivery, set the done quantity to 12, validate and + then set the done quantity to 10: the delivered qty of the SOL should + be 10 + """ + so = self.env['sale.order'].create({ + 'partner_id': self.customer.id, + 'order_line': [(0, 0, { + 'name': self.mto_product.name, + 'product_id': self.mto_product.id, + 'product_uom_qty': 10, + 'product_uom': self.mto_product.uom_id.id, + 'price_unit': 1, + })], + }) + so.action_confirm() + + delivery = so.picking_ids.filtered(lambda p: p.location_dest_id == self.customer_location) + sm = delivery.move_ids + sm.move_line_ids = [(5, 0, 0), (0, 0, { + 'location_id': sm.location_id.id, + 'location_dest_id': sm.location_dest_id.id, + 'product_id': sm.product_id.id, + 'quantity': 12, + 'company_id': sm.company_id.id, + 'product_uom_id': sm.product_uom.id, + 'picking_id': delivery.id, + })] + delivery.button_validate() + + self.assertEqual(delivery.state, 'done') + self.assertEqual(delivery.move_ids.move_line_ids.quantity, 12) + self.assertEqual(so.order_line.qty_delivered, 12) + + sm.move_line_ids.quantity = 10 + self.assertEqual(so.order_line.qty_delivered, 10) + + @freeze_time('2024-01-01') + def test_reordering_with_visibility_days(self): + """ + If reordering rules' visibility is set bigger than + DAYS_FROM_TODAY_TO_ORDER (plus lead time). Then the + order should be included in the calculation of quantity to order. + + ┌─ Today ┌── Scheduled Delivery + │ (2024-01-01) │ (2024-02-01) + │ │ aka commitment_date + │ │ + ▼ ▼ + ──────────────────────────────────────────► + time + ◄────────────────────────────► + DAYS_FROM_TODAY_TO_ORDER + + ◄────► + lead_time + """ + N_ORDERED_QTY = 666 + DAYS_FROM_TODAY_TO_ORDER = 30 + MONTH_FROM_TODAY = (datetime.today() + timedelta(days=DAYS_FROM_TODAY_TO_ORDER)).strftime('%Y-%m-%d') + + # Setup: Create a product with vendor + partner = self.env['res.partner'].create({'name': 'Azure Interior'}) + seller = self.env['product.supplierinfo'].create({ + 'partner_id': partner.id, + 'price': 1.0, + }) + product = self.env['product.product'].create({ + 'name': 'Dummy Product', + 'type': 'product', + 'seller_ids': [seller.id], + }) + + # Setup: Create sale order scheduled in the future + so = self.env['sale.order'].create({ + 'partner_id': self.customer.id, + 'commitment_date': MONTH_FROM_TODAY, + 'order_line': [(0, 0, { + 'name': product.name, + 'product_id': product.id, + 'price_unit': 1, + 'product_uom_qty': N_ORDERED_QTY, + })], + }) + so.action_confirm() # so.state: 'draft' -> 'sale' + + # Create Reordering rule and trigger recalculation + orderpoint_form = Form(self.env['stock.warehouse.orderpoint']) + orderpoint_form.product_id = product + orderpoint_form.visibility_days = DAYS_FROM_TODAY_TO_ORDER + orderpoint = orderpoint_form.save() + + self.assertEqual(orderpoint.qty_to_order, N_ORDERED_QTY, f"Order from {DAYS_FROM_TODAY_TO_ORDER} days from today NOT included into the qty_to_order calculation, despite having visibility days set {DAYS_FROM_TODAY_TO_ORDER}!") diff --git a/tests/test_unwanted_replenish_flow.py b/tests/test_unwanted_replenish_flow.py new file mode 100644 index 0000000..17bbac5 --- /dev/null +++ b/tests/test_unwanted_replenish_flow.py @@ -0,0 +1,135 @@ +from datetime import datetime, timedelta +from odoo import Command +from odoo.tests import common, Form, tagged + +@tagged('post_install', '-at_install') +class TestWarnUnwantedReplenish(common.TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.buy_route = cls.env.ref('purchase_stock.route_warehouse0_buy') + + # Create a vendor (& suppliers) and a customer + cls.vendor = cls.env['res.partner'].create(dict(name='Vendor')) + cls.customer = cls.env['res.partner'].create(dict(name='Customer')) + + cls.supplier_A = cls.env['product.supplierinfo'].create({ + 'partner_id' : cls.vendor.id, + 'min_qty' : 0.0, + 'price' : 10.0, + 'delay' : 0 + }) + + cls.supplier_B = cls.env['product.supplierinfo'].create({ + 'partner_id' : cls.vendor.id, + 'min_qty' : 0.0, + 'price' : 12.0, + 'delay' : 0 + }) + + # Create a "A" and a "B" Product : + # No Stock + # Partner/Customer Lead Time = 0 + # Manual reordering 0 0 + + cls.product_A = cls.env['product.product'].create({ + 'name': 'Product A', + 'type': 'product', + 'categ_id': cls.env.ref('product.product_category_all').id, + 'purchase_method': 'purchase', + 'invoice_policy': 'delivery', + 'standard_price': 5.0, + 'list_price': 10.0, + 'seller_ids': [Command.link(cls.supplier_A.id)], + 'route_ids': [Command.link(cls.buy_route.id)], + 'sale_delay' : 0, + }) + + cls.product_B = cls.env['product.product'].create({ + 'name': 'Product B', + 'type': 'product', + 'categ_id': cls.env.ref('product.product_category_all').id, + 'purchase_method': 'purchase', + 'invoice_policy': 'delivery', + 'standard_price': 6.0, + 'list_price': 12.0, + 'seller_ids': [Command.link(cls.supplier_B.id)], + 'route_ids': [Command.link(cls.buy_route.id)], + 'sale_delay': 0, + }) + + + orderpoint_form = Form(cls.env['stock.warehouse.orderpoint']) + orderpoint_form.product_id = cls.product_A + orderpoint_form.product_min_qty = 0.0 + orderpoint_form.product_max_qty = 0.0 + cls.orderpoint_A = orderpoint_form.save() + cls.orderpoint_A.trigger = 'manual' + + orderpoint_form = Form(cls.env['stock.warehouse.orderpoint']) + orderpoint_form.product_id = cls.product_B + orderpoint_form.product_min_qty = 0.0 + orderpoint_form.product_max_qty = 0.0 + cls.orderpoint_B = orderpoint_form.save() + cls.orderpoint_B.trigger = 'manual' + + # Create Sales + # For A and for B + # Delivered today + # Confirm SO + + cls.sale_order = cls.env['sale.order'].create({ + 'partner_id': cls.customer.id, + 'order_line': [ + Command.create({ + 'product_id': cls.product_A.id, + 'product_uom_qty': 10, + }), + Command.create({ + 'product_id': cls.product_B.id, + 'product_uom_qty': 10, + }), + ], + }) + + cls.sale_order.action_confirm() + + # Create PO for Product A + # Confirm PO with date planned : TODAY + # Incoming Picking : reschedule in one week + + cls.po_A = cls.env['purchase.order'].create({ + 'partner_id': cls.vendor.id, + 'order_line': [ + Command.create({ + 'name': cls.product_A.name, + 'product_id': cls.product_A.id, + 'product_qty': 10.0, + 'price_unit': 10.0, + 'date_planned': datetime.today(), + })], + }) + + cls.po_A.button_confirm() + + cls.picking_A = cls.po_A.picking_ids[0] + cls.picking_A.scheduled_date = (datetime.today() + timedelta(days=10)) + + def test_01_pre_updateA_post(self): + """ + TEST 1 + Replenishment -> + Product A + unwanted_replenish SHALL be TRUE + Product B + unwanted_replenish SHALL be FALSE + Product A + Modify Visible Days past 1 Week -> unwanted_replenish SHALL be FALSE + """ + self.assertTrue(self.orderpoint_A.unwanted_replenish, 'Orderpoint A not set to unwanted_replenish') + self.assertFalse(self.orderpoint_B.unwanted_replenish, 'Orderpoint B is set to unwanted_replenish') + #Update Orderpoint A + self.orderpoint_A.visibility_days = 10 + self.assertFalse(self.orderpoint_A.unwanted_replenish, 'Orderpoint A shall not be set to unwanted_replenish')