diff --git a/budget_control_sale_stock/README.rst b/budget_control_sale_stock/README.rst new file mode 100644 index 00000000..1d8021a6 --- /dev/null +++ b/budget_control_sale_stock/README.rst @@ -0,0 +1,83 @@ +================================= +Budget Control on Sale with Stock +================================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:479b9ffbc2cb8822ece2e276c8e1252ba1c2b76e32bbd889cec520fbd551b46e + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-ecosoft--odoo%2Fbudgeting-lightgray.png?logo=github + :target: https://github.com/ecosoft-odoo/budgeting/tree/18.0/budget_control_sale_stock + :alt: ecosoft-odoo/budgeting + +|badge1| |badge2| |badge3| + +This module is a bridge between budget_control_stock and sale_stock. + +When a Sale Order is confirmed, the system auto-creates a Delivery Order +(DO). At this point, the Budget Control may not yet be confirmed (the +user needs to set KPIs first). This module bypasses the stock budget +commit check during SO confirmation so the DO can be created without a +budget error. + +After SO confirmation, all subsequent DO operations (validate, +unreserve, etc.) enforce the budget check normally, requiring the user +to confirm the Budget Control before the DO can be processed. + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Ecosoft + +Contributors +------------ + +- Saran Lim saranl@ecosoft.co.th + +Maintainers +----------- + +.. |maintainer-Saran440| image:: https://github.com/Saran440.png?size=40px + :target: https://github.com/Saran440 + :alt: Saran440 + +Current maintainer: + +|maintainer-Saran440| + +This module is part of the `ecosoft-odoo/budgeting `_ project on GitHub. + +You are welcome to contribute. diff --git a/budget_control_sale_stock/__init__.py b/budget_control_sale_stock/__init__.py new file mode 100644 index 00000000..69f7babd --- /dev/null +++ b/budget_control_sale_stock/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import models diff --git a/budget_control_sale_stock/__manifest__.py b/budget_control_sale_stock/__manifest__.py new file mode 100644 index 00000000..88f4f330 --- /dev/null +++ b/budget_control_sale_stock/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2026 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Budget Control on Sale with Stock", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "author": "Ecosoft, Odoo Community Association (OCA)", + "website": "https://github.com/ecosoft-odoo/budgeting", + "depends": [ + "budget_control_stock", + "sale_stock", + "sale_project", + "sale_margin", + "sale_stock_analytic", + ], + "data": ["views/budget_control_views.xml", "views/sale_order_views.xml"], + "installable": True, + "maintainers": ["Saran440"], + "development_status": "Alpha", +} diff --git a/budget_control_sale_stock/models/__init__.py b/budget_control_sale_stock/models/__init__.py new file mode 100644 index 00000000..4740cb79 --- /dev/null +++ b/budget_control_sale_stock/models/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import budget_control +from . import sale_order diff --git a/budget_control_sale_stock/models/budget_control.py b/budget_control_sale_stock/models/budget_control.py new file mode 100644 index 00000000..5f7049a3 --- /dev/null +++ b/budget_control_sale_stock/models/budget_control.py @@ -0,0 +1,56 @@ +# Copyright 2026 Ecosoft Co., Ltd. () +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + + +class BudgetControl(models.Model): + _inherit = "budget.control" + + sale_order_ids = fields.Many2many( + comodel_name="sale.order", + relation="budget_control_sale_order_rel", + column1="budget_control_id", + column2="sale_order_id", + string="Sale Orders", + copy=False, + ) + sale_order_count = fields.Integer(compute="_compute_sale_order_count") + sale_price = fields.Float( + compute="_compute_sale_fields", + store=True, + ) + gross_profit = fields.Float( + compute="_compute_sale_fields", + store=True, + ) + gross_profit_percent = fields.Float( + compute="_compute_sale_fields", + store=True, + ) + + @api.depends("sale_order_ids") + def _compute_sale_order_count(self): + for rec in self: + rec.sale_order_count = len(rec.sale_order_ids) + + @api.depends("sale_order_ids.amount_total", "allocated_amount") + def _compute_sale_fields(self): + for rec in self: + sale_price = sum(rec.sale_order_ids.mapped("amount_total")) + profit = sale_price - rec.allocated_amount + rec.sale_price = sale_price + rec.gross_profit = profit + rec.gross_profit_percent = ( + (profit / sale_price * 100) if sale_price else 0.0 + ) + + def action_open_sale_order(self): + self.ensure_one() + return { + "name": self.env._("Sale Orders"), + "type": "ir.actions.act_window", + "res_model": "sale.order", + "view_mode": "list,form", + "domain": [("id", "in", self.sale_order_ids.ids)], + } diff --git a/budget_control_sale_stock/models/sale_order.py b/budget_control_sale_stock/models/sale_order.py new file mode 100644 index 00000000..ae68a5e6 --- /dev/null +++ b/budget_control_sale_stock/models/sale_order.py @@ -0,0 +1,116 @@ +# Copyright 2026 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models +from odoo.exceptions import UserError +from odoo.fields import Command + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + budget_control_id = fields.Many2one( + comodel_name="budget.control", + index=True, + copy=False, + ) + + def action_confirm(self): + """Bypass stock budget commit check during SO confirmation. + + When SO is confirmed, the system auto-creates a Delivery Order (DO). + Budget Control may not yet be confirmed at this point (user needs to + set KPIs first). The bypass context prevents the budget check from + failing during DO creation. Subsequent DO operations (validate, etc.) + will enforce the budget check normally, forcing the user to confirm + the Budget Control before proceeding. + """ + return super( + SaleOrder, self.with_context(skip_budget_commit=True) + ).action_confirm() + + def action_create_budget_control(self): + """Manual create budget control""" + self.ensure_one() + self._create_project_budget_control() + return True + + def _action_confirm(self): + res = super()._action_confirm() + self.filtered( + lambda rec: not rec.budget_control_id + )._create_project_budget_control() + return res + + def _create_project_budget_control(self): + BudgetPeriod = self.env["budget.period"] + BudgetControl = self.env["budget.control"] + for order in self: + project = order.project_id + if not project or not project.account_id: + continue + date = order.date_order.date() if order.date_order else fields.Date.today() + budget_period = BudgetPeriod.search( + [("bm_date_from", "<=", date), ("bm_date_to", ">=", date)], + limit=1, + ) + if not budget_period: + raise UserError( + self.env._( + f"No budget period found for date {date} " + f"on sale order {order.name}." + ) + ) + existing = BudgetControl.search( + [ + ("analytic_account_id", "=", project.account_id.id), + ("budget_period_id", "=", budget_period.id), + ("state", "!=", "cancel"), + ], + limit=1, + ) + if existing: + # New SO on same analytic+period: add to existing budget. + # Same SO re-confirmed (reset to draft): skip to avoid + # doubling the allocated amount. + if order not in existing.sale_order_ids: + add_amount = sum( + line.purchase_price * line.product_uom_qty + for line in order.order_line + ) + existing.write( + { + "allocated_amount": existing.allocated_amount + add_amount, + "sale_order_ids": [Command.link(order.id)], + } + ) + existing.action_draft() + order.budget_control_id = existing.id + else: + vals = order._prepare_budget_control_vals(project, budget_period) + budget_control = BudgetControl.create(vals) + order.budget_control_id = budget_control.id + + def _prepare_budget_control_vals(self, project, budget_period): + self.ensure_one() + allocated_amount = sum( + line.purchase_price * line.product_uom_qty for line in self.order_line + ) + return { + "name": project.name, + "analytic_account_id": project.account_id.id, + "budget_period_id": budget_period.id, + "plan_date_range_type_id": budget_period.plan_date_range_type_id.id, + "currency_id": self.company_id.currency_id.id, + "allocated_amount": allocated_amount, + "sale_order_ids": [Command.link(self.id)], + } + + def action_open_budget_control(self): + self.ensure_one() + return { + "type": "ir.actions.act_window", + "res_model": "budget.control", + "view_mode": "form", + "res_id": self.budget_control_id.id, + } diff --git a/budget_control_sale_stock/pyproject.toml b/budget_control_sale_stock/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/budget_control_sale_stock/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/budget_control_sale_stock/readme/CONTRIBUTORS.md b/budget_control_sale_stock/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..ccd0cbcc --- /dev/null +++ b/budget_control_sale_stock/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Saran Lim diff --git a/budget_control_sale_stock/readme/DESCRIPTION.md b/budget_control_sale_stock/readme/DESCRIPTION.md new file mode 100644 index 00000000..e246c186 --- /dev/null +++ b/budget_control_sale_stock/readme/DESCRIPTION.md @@ -0,0 +1,10 @@ +This module is a bridge between budget_control_stock and sale_stock. + +When a Sale Order is confirmed, the system auto-creates a Delivery Order (DO). +At this point, the Budget Control may not yet be confirmed (the user needs to +set KPIs first). This module bypasses the stock budget commit check during SO +confirmation so the DO can be created without a budget error. + +After SO confirmation, all subsequent DO operations (validate, unreserve, etc.) +enforce the budget check normally, requiring the user to confirm the Budget +Control before the DO can be processed. diff --git a/budget_control_sale_stock/static/description/index.html b/budget_control_sale_stock/static/description/index.html new file mode 100644 index 00000000..54eca452 --- /dev/null +++ b/budget_control_sale_stock/static/description/index.html @@ -0,0 +1,432 @@ + + + + + +Budget Control on Sale with Stock + + + +
+

Budget Control on Sale with Stock

+ + +

Alpha License: AGPL-3 ecosoft-odoo/budgeting

+

This module is a bridge between budget_control_stock and sale_stock.

+

When a Sale Order is confirmed, the system auto-creates a Delivery Order +(DO). At this point, the Budget Control may not yet be confirmed (the +user needs to set KPIs first). This module bypasses the stock budget +commit check during SO confirmation so the DO can be created without a +budget error.

+

After SO confirmation, all subsequent DO operations (validate, +unreserve, etc.) enforce the budget check normally, requiring the user +to confirm the Budget Control before the DO can be processed.

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Ecosoft
  • +
+
+ +
+

Maintainers

+

Current maintainer:

+

Saran440

+

This module is part of the ecosoft-odoo/budgeting project on GitHub.

+

You are welcome to contribute.

+
+
+
+ + diff --git a/budget_control_sale_stock/tests/__init__.py b/budget_control_sale_stock/tests/__init__.py new file mode 100644 index 00000000..d5483297 --- /dev/null +++ b/budget_control_sale_stock/tests/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import test_budget_control_sale_stock diff --git a/budget_control_sale_stock/tests/test_budget_control_sale_stock.py b/budget_control_sale_stock/tests/test_budget_control_sale_stock.py new file mode 100644 index 00000000..6e22b439 --- /dev/null +++ b/budget_control_sale_stock/tests/test_budget_control_sale_stock.py @@ -0,0 +1,206 @@ +# Copyright 2026 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from freezegun import freeze_time + +from odoo.exceptions import UserError +from odoo.tests import tagged + +from odoo.addons.budget_control.tests.common import get_budget_common_class + + +@tagged("post_install", "-at_install") +class TestBudgetControlSaleStock(get_budget_common_class()): + @classmethod + @freeze_time("2001-02-01") + def setUpClass(cls): + super().setUpClass() + cls.customer = cls.Partner.create({"name": "Test Customer"}) + # Set cost price on products (purchase_price defaults from standard_price) + cls.product1.write({"standard_price": 50.0}) + cls.product2.write({"standard_price": 80.0}) + # Project linked to costcenter1 analytic account + cls.project = cls.env["project.project"].create( + { + "name": "Test Sales Project", + "account_id": cls.costcenter1.id, + } + ) + + def _create_sale_order(self, project=None, lines=None): + """Create SO with optional project and order lines.""" + vals = {"partner_id": self.customer.id} + if project: + vals["project_id"] = project.id + order = self.env["sale.order"].create(vals) + if lines: + for lv in lines: + lv["order_id"] = order.id + self.env["sale.order.line"].create(lv) + return order + + @freeze_time("2001-02-01") + def test_01_so_confirm_creates_budget_control(self): + """ + Full flow: confirm SO with project -> BC auto-created. + Computed fields (sale_price, gross_profit, gross_profit_percent) correct. + Action buttons return correct actions. + Re-confirm (reset to draft) -> same BC, no double allocated_amount. + """ + sale = self._create_sale_order( + project=self.project, + lines=[ + { + "product_id": self.product1.id, + "product_uom_qty": 2, + "price_unit": 150.0, + "purchase_price": 50.0, + }, + { + "product_id": self.product2.id, + "product_uom_qty": 1, + "price_unit": 200.0, + "purchase_price": 80.0, + }, + ], + ) + self.assertFalse(sale.budget_control_id) + + sale.action_confirm() + self.assertEqual(sale.state, "sale") + self.assertTrue(sale.budget_control_id) + + bc = sale.budget_control_id + # allocated_amount = (50*2) + (80*1) = 180 + self.assertAlmostEqual(bc.allocated_amount, 180.0) + self.assertIn(sale, bc.sale_order_ids) + self.assertEqual(bc.sale_order_count, 1) + + # sale_price = sum of SO amount_total + self.assertAlmostEqual(bc.sale_price, sale.amount_total) + # gross_profit = sale_price - allocated_amount + expected_profit = sale.amount_total - 180.0 + self.assertAlmostEqual(bc.gross_profit, expected_profit) + if sale.amount_total: + expected_pct = expected_profit / sale.amount_total * 100 + self.assertAlmostEqual(bc.gross_profit_percent, expected_pct, places=2) + + # action_open_budget_control from SO -> opens BC form + action = sale.action_open_budget_control() + self.assertEqual(action["res_model"], "budget.control") + self.assertEqual(action["res_id"], bc.id) + + # action_open_sale_order from BC -> lists linked SOs + action2 = bc.action_open_sale_order() + self.assertEqual(action2["res_model"], "sale.order") + self.assertIn(sale.id, action2["domain"][0][2]) + + # Re-confirm: cancel -> draft -> confirm -> same BC, no doubling + sale.action_cancel() + sale.action_draft() + sale.action_confirm() + self.assertEqual(sale.budget_control_id, bc) + # allocated_amount unchanged - same SO already in sale_order_ids + self.assertAlmostEqual(bc.allocated_amount, 180.0) + + @freeze_time("2001-02-01") + def test_02_second_so_same_analytic_accumulates(self): + """ + Two SOs on same analytic+period -> second SO adds to existing BC. + sale_order_count reflects both. + """ + sale1 = self._create_sale_order( + project=self.project, + lines=[ + { + "product_id": self.product1.id, + "product_uom_qty": 2, + "price_unit": 100.0, + "purchase_price": 50.0, + }, + ], + ) + sale2 = self._create_sale_order( + project=self.project, + lines=[ + { + "product_id": self.product2.id, + "product_uom_qty": 3, + "price_unit": 100.0, + "purchase_price": 80.0, + }, + ], + ) + + sale1.action_confirm() + bc = sale1.budget_control_id + # (50*2) = 100 + self.assertAlmostEqual(bc.allocated_amount, 100.0) + + sale2.action_confirm() + # sale2 must link to same existing BC (same analytic + period) + self.assertEqual(sale2.budget_control_id, bc) + # accumulated: 100 + (80*3) = 340 + self.assertAlmostEqual(bc.allocated_amount, 340.0) + self.assertEqual(bc.sale_order_count, 2) + + @freeze_time("2001-02-01") + def test_03_error_and_skip_cases(self): + """ + (1) SO without project -> confirm -> no BC created (skip silently) + (2) SO with project but date outside all budget periods -> UserError + """ + # (1) No project -> skip silently, no BC + sale_no_project = self._create_sale_order( + lines=[ + { + "product_id": self.product1.id, + "product_uom_qty": 1, + "price_unit": 100.0, + "purchase_price": 50.0, + }, + ], + ) + sale_no_project.action_confirm() + self.assertFalse(sale_no_project.budget_control_id) + + # (2) date outside budget period -> + # UserError from _create_project_budget_control + sale_out = self._create_sale_order( + project=self.project, + lines=[ + { + "product_id": self.product1.id, + "product_uom_qty": 1, + "price_unit": 100.0, + "purchase_price": 50.0, + }, + ], + ) + sale_out.write({"date_order": "1999-06-15 00:00:00"}) + with self.assertRaisesRegex(UserError, "No budget period"): + sale_out.action_confirm() + + @freeze_time("2001-02-01") + def test_04_manual_create_budget_control(self): + """ + Manual action_create_budget_control on draft SO -> BC created and linked. + """ + sale = self._create_sale_order( + project=self.project, + lines=[ + { + "product_id": self.product1.id, + "product_uom_qty": 1, + "price_unit": 100.0, + "purchase_price": 60.0, + }, + ], + ) + self.assertFalse(sale.budget_control_id) + + result = sale.action_create_budget_control() + self.assertTrue(result) + self.assertTrue(sale.budget_control_id) + # allocated_amount = 60*1 = 60 + self.assertAlmostEqual(sale.budget_control_id.allocated_amount, 60.0) diff --git a/budget_control_sale_stock/views/budget_control_views.xml b/budget_control_sale_stock/views/budget_control_views.xml new file mode 100644 index 00000000..34e76c73 --- /dev/null +++ b/budget_control_sale_stock/views/budget_control_views.xml @@ -0,0 +1,33 @@ + + + + budget.control.view.form + budget.control + + + + + + + + + + + + state != 'draft' + + + + diff --git a/budget_control_sale_stock/views/sale_order_views.xml b/budget_control_sale_stock/views/sale_order_views.xml new file mode 100644 index 00000000..ff3adaf6 --- /dev/null +++ b/budget_control_sale_stock/views/sale_order_views.xml @@ -0,0 +1,30 @@ + + + + sale.order.form + sale.order + + + + + + + +