From 613f25e3fc68ce8bd9322c032867ce49fbda7295 Mon Sep 17 00:00:00 2001 From: Saran440 Date: Sat, 30 May 2026 19:09:38 +0700 Subject: [PATCH 1/2] [18.0][ADD] budget_control_stock --- budget_control_stock/README.rst | 145 ++++++ budget_control_stock/__init__.py | 4 + budget_control_stock/__manifest__.py | 23 + budget_control_stock/models/__init__.py | 19 + .../models/account_budget_move.py | 21 + budget_control_stock/models/account_move.py | 20 + .../models/account_move_line.py | 84 +++ .../models/budget_commit_forward.py | 53 ++ budget_control_stock/models/budget_control.py | 14 + budget_control_stock/models/budget_period.py | 38 ++ .../models/stock_budget_move.py | 51 ++ budget_control_stock/models/stock_move.py | 139 +++++ budget_control_stock/models/stock_picking.py | 96 ++++ .../models/stock_picking_type.py | 24 + budget_control_stock/pyproject.toml | 3 + budget_control_stock/readme/CONFIGURE.md | 23 + budget_control_stock/readme/CONTRIBUTORS.md | 1 + budget_control_stock/readme/DESCRIPTION.md | 19 + budget_control_stock/readme/USAGE.md | 20 + budget_control_stock/report/__init__.py | 4 + .../report/budget_common_monitoring.py | 18 + .../report/budget_monitor_report.py | 28 + .../security/ir.model.access.csv | 2 + .../static/description/icon.png | Bin 0 -> 9455 bytes .../static/description/index.html | 490 ++++++++++++++++++ budget_control_stock/tests/__init__.py | 3 + .../tests/test_budget_stock.py | 240 +++++++++ .../views/budget_commit_forward_view.xml | 27 + .../views/budget_control_view.xml | 23 + .../views/budget_period_view.xml | 18 + .../views/stock_budget_move_view.xml | 46 ++ .../views/stock_picking_type_views.xml | 21 + .../views/stock_picking_view.xml | 85 +++ 33 files changed, 1802 insertions(+) create mode 100644 budget_control_stock/README.rst create mode 100644 budget_control_stock/__init__.py create mode 100644 budget_control_stock/__manifest__.py create mode 100644 budget_control_stock/models/__init__.py create mode 100644 budget_control_stock/models/account_budget_move.py create mode 100644 budget_control_stock/models/account_move.py create mode 100644 budget_control_stock/models/account_move_line.py create mode 100644 budget_control_stock/models/budget_commit_forward.py create mode 100644 budget_control_stock/models/budget_control.py create mode 100644 budget_control_stock/models/budget_period.py create mode 100644 budget_control_stock/models/stock_budget_move.py create mode 100644 budget_control_stock/models/stock_move.py create mode 100644 budget_control_stock/models/stock_picking.py create mode 100644 budget_control_stock/models/stock_picking_type.py create mode 100644 budget_control_stock/pyproject.toml create mode 100644 budget_control_stock/readme/CONFIGURE.md create mode 100644 budget_control_stock/readme/CONTRIBUTORS.md create mode 100644 budget_control_stock/readme/DESCRIPTION.md create mode 100644 budget_control_stock/readme/USAGE.md create mode 100644 budget_control_stock/report/__init__.py create mode 100644 budget_control_stock/report/budget_common_monitoring.py create mode 100644 budget_control_stock/report/budget_monitor_report.py create mode 100644 budget_control_stock/security/ir.model.access.csv create mode 100644 budget_control_stock/static/description/icon.png create mode 100644 budget_control_stock/static/description/index.html create mode 100644 budget_control_stock/tests/__init__.py create mode 100644 budget_control_stock/tests/test_budget_stock.py create mode 100644 budget_control_stock/views/budget_commit_forward_view.xml create mode 100644 budget_control_stock/views/budget_control_view.xml create mode 100644 budget_control_stock/views/budget_period_view.xml create mode 100644 budget_control_stock/views/stock_budget_move_view.xml create mode 100644 budget_control_stock/views/stock_picking_type_views.xml create mode 100644 budget_control_stock/views/stock_picking_view.xml diff --git a/budget_control_stock/README.rst b/budget_control_stock/README.rst new file mode 100644 index 00000000..a917692a --- /dev/null +++ b/budget_control_stock/README.rst @@ -0,0 +1,145 @@ +======================= +Budget Control on Stock +======================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:4555fce4ea6a0cee91c8b799f9e6ff12636e3f539375ebea491e3465d9270356 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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_stock + :alt: ecosoft-odoo/budgeting + +|badge1| |badge2| |badge3| + +This module adds budget control on stock operations, so that goods +issued from inventory consume budget independently from the related +purchase or sale documents. + +When an outgoing transfer (delivery) of an operation type with +``Commit Budget`` enabled is confirmed, a ``stock.budget.move`` +commitment is created per analytic account. The committed amount is the +move quantity valued at the configured price source: the product +standard price (default) or the lot standard price. When the transfer is +validated, the stock journal entry is posted and recorded as budget +*actual* (from the stock valuation value), while the original stock +commitment is released, so the budget consumption moves smoothly from +*commitment* to *actual*. Cancelling or reverting the transfer removes +the commitment. + +The committed amount can be reviewed on the *Budget Commitment* tab of +the stock transfer and on the budget monitoring report. + +Open stock commitments (transfers confirmed but not yet validated at the +end of a budget period) are carried forward to the next period together +with the other commitment types of the standard *Budget Commit Forward* +document. + +.. 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: + +Configuration +============= + +To configure this module, you need to: + +#. Go to *Inventory > Configuration > Operation Types*. #. Open the +operation type that issues goods from inventory (for example *Delivery +Orders*) and enable ``Commit Budget``. Confirming a transfer of this +operation type will then create budget commitments. #. Choose the +``Budget Price Source`` used to value the commitment: + +- *Product Standard Price* (default): use ``product.standard_price``. +- *Lot Standard Price*: use ``lot.standard_price`` per reserved lot, for + FIFO / lot-based costing. + +#. Go to *Budgeting > Configuration > Budget Periods*, open the relevant +period and tick ``On Stock`` to control the budget against stock +commitments. When ``Control Budget`` is enabled the flag follows it by +default. + +Notes: + +- Stock moves must carry an analytic distribution (provided by the + *stock_analytic* dependency); commitments are created per analytic + account. +- Recording the related stock journal entry as budget *actual* relies on + the standard journal-entry budget flow, so make sure the stock journal + entries carry the analytic distribution of the moves. + +Usage +===== + +To use this module, you need to: + +#. Make sure the operation type and the budget period are configured +(see *Configuration*) and that the budget control sheet of the analytic +account is in *Controlled* status. #. Create an outgoing transfer +(delivery) whose stock moves carry an analytic distribution. #. Confirm +the transfer. For each analytic account, a budget commitment is created +from the configured price source (product or lot standard price). If the +budget is not sufficient and the period blocks over-budget transactions, +confirmation is refused. #. Review the committed amount on the *Budget +Commitment* tab of the transfer, on the budget control sheet (``Stock`` +column) or on the budget monitoring report. #. Validate the transfer. +The stock journal entry is posted and recorded as budget *actual*, and +the matching stock commitment is released, so consumption moves from +*commitment* to *actual*. + +Cancelling, reverting to draft or returning the transfer removes the +related commitment. + +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. + +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_stock/__init__.py b/budget_control_stock/__init__.py new file mode 100644 index 00000000..37e105d0 --- /dev/null +++ b/budget_control_stock/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import models +from . import report diff --git a/budget_control_stock/__manifest__.py b/budget_control_stock/__manifest__.py new file mode 100644 index 00000000..717d67b1 --- /dev/null +++ b/budget_control_stock/__manifest__.py @@ -0,0 +1,23 @@ +# 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 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_account", "stock_analytic"], + "data": [ + "security/ir.model.access.csv", + "views/stock_budget_move_view.xml", + "views/stock_picking_type_views.xml", + "views/stock_picking_view.xml", + "views/budget_period_view.xml", + "views/budget_control_view.xml", + # "views/budget_commit_forward_view.xml", + ], + "installable": True, + "maintainers": ["Saran440"], + "development_status": "Alpha", +} diff --git a/budget_control_stock/models/__init__.py b/budget_control_stock/models/__init__.py new file mode 100644 index 00000000..0f61cd9a --- /dev/null +++ b/budget_control_stock/models/__init__.py @@ -0,0 +1,19 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +# Master Data +from . import budget_period + +# Operation Data +from . import budget_control +from . import budget_commit_forward + +# Account Module +from . import account_budget_move +from . import account_move +from . import account_move_line + +# Stock Module +from . import stock_budget_move +from . import stock_picking_type +from . import stock_picking +from . import stock_move diff --git a/budget_control_stock/models/account_budget_move.py b/budget_control_stock/models/account_budget_move.py new file mode 100644 index 00000000..b4e62486 --- /dev/null +++ b/budget_control_stock/models/account_budget_move.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). + +from odoo import api, models + + +class AccountBudgetMove(models.Model): + _inherit = "account.budget.move" + + @api.depends("move_id") + def _compute_source_document(self): + res = super()._compute_source_document() + for rec in self.filtered(lambda r: r.move_line_id.stock_valuation_layer_ids): + if rec.source_document: + continue + stock_moves = rec.move_line_id.stock_valuation_layer_ids.mapped( + "stock_move_id" + ) + if stock_moves and stock_moves[0].picking_id: + rec.source_document = stock_moves[0].picking_id.display_name + return res diff --git a/budget_control_stock/models/account_move.py b/budget_control_stock/models/account_move.py new file mode 100644 index 00000000..a487e982 --- /dev/null +++ b/budget_control_stock/models/account_move.py @@ -0,0 +1,20 @@ +# Copyright 2026 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models + + +class AccountMove(models.Model): + _inherit = "account.move" + + def write(self, vals): + """Recompute stock commit when JE state changes. + + recompute_budget_move handles both re-commit and uncommit + (via _recommit_via_valuation_lines) in one pass. + """ + res = super().write(vals) + if vals.get("state") in ("draft", "posted", "cancel"): + stock_moves = self.mapped("stock_valuation_layer_ids.stock_move_id") + stock_moves.recompute_budget_move() + return res diff --git a/budget_control_stock/models/account_move_line.py b/budget_control_stock/models/account_move_line.py new file mode 100644 index 00000000..adfe43fa --- /dev/null +++ b/budget_control_stock/models/account_move_line.py @@ -0,0 +1,84 @@ +# Copyright 2026 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models + + +class AccountMoveLine(models.Model): + _inherit = "account.move.line" + + def _condition_skip_uncommit_stock(self, move): + """Skip if this account move line is not related to a stock valuation.""" + return not move.stock_valuation_layer_ids + + def _get_lot_uncommit_context(self, move): + """Return context for lot-based uncommit. + + Each stock valuation JE links to exactly one SVL (one per serial/lot). + Always scope the uncommit to the per-SVL qty so cancelling one JE + restores only that lot's commitment, not the full move qty. + For lot_price source, also override the unit price from the lot. + """ + svls = move.stock_valuation_layer_ids + if not svls: + return {} + svl = svls[0] + if not svl.lot_id: + return {} + stock_move = svl.stock_move_id + ctx = {"product_qty": abs(svl.quantity)} + if stock_move.picking_id.picking_type_id.budget_price_source == "lot_price": + ctx["budget_lot_price"] = svl.lot_id.standard_price + return ctx + + def uncommit_stock_budget(self): + """Uncommit the budget for related stock moves + when the stock valuation entry is in a valid state.""" + AnalyticAccount = self.env["account.analytic.account"] + + for ml in self: + move = ml.move_id + # Not related to stock valuation + if self._condition_skip_uncommit_stock(move): + continue + + # Get the stock.move from the valuation layer + stock_moves = move.stock_valuation_layer_ids.mapped("stock_move_id") + if not stock_moves: + continue + + if move.state == "posted": + stock_moves = stock_moves.filtered( + lambda m: m.amount_commit + and any(v > 0 for v in m.amount_commit.values()) + ) + if not stock_moves: + # 2-step delivery: SVL is on OUT move (no budget_commit), + # but the commit is on the upstream PICK move. + svl_moves = move.stock_valuation_layer_ids.mapped("stock_move_id") + stock_moves = svl_moves.mapped("move_orig_ids").filtered( + lambda m: m.amount_commit + and any(v > 0 for v in m.amount_commit.values()) + ) + if not stock_moves: + continue + + if ml.analytic_distribution: + analytic_accounts = { + int(aid): AnalyticAccount.browse(int(aid)) + for aid in ml.analytic_distribution + } + lot_ctx = self._get_lot_uncommit_context(move) + for analytic_id, _ in ml.analytic_distribution.items(): + for stock_move in stock_moves: + stock_move.with_context(**lot_ctx).commit_budget( + reverse=True, + account_move_line_id=ml.id, + date=ml.date_commit, + analytic_account_id=analytic_accounts[int(analytic_id)], + ) + else: # Cancel or draft, not commitment line + StockMove = self.env["stock.move"] + self.env[StockMove._budget_model()].search( + [("account_move_line_id", "=", ml.id)] + ).unlink() diff --git a/budget_control_stock/models/budget_commit_forward.py b/budget_control_stock/models/budget_commit_forward.py new file mode 100644 index 00000000..0bdc7539 --- /dev/null +++ b/budget_control_stock/models/budget_commit_forward.py @@ -0,0 +1,53 @@ +# 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 + + +class BudgetCommitForward(models.Model): + _inherit = "budget.commit.forward" + + stock = fields.Boolean( + default=True, + help="If checked, click review budget commitment will pull stock commitment", + ) + forward_stock_ids = fields.One2many( + comodel_name="budget.commit.forward.line", + inverse_name="forward_id", + string="Stock Moves", + domain=[("res_model", "=", "stock.move")], + ) + + def _get_budget_docline_model(self): + res = super()._get_budget_docline_model() + if self.stock: + res.append("stock.move") + return res + + def _get_document_number(self, doc): + if doc._name == "stock.move": + return f"{doc.picking_id._name},{doc.picking_id.id}" + return super()._get_document_number(doc) + + def _get_base_domain_extension(self, res_model): + """For module extension""" + if res_model == "stock.move": + return " AND a.state NOT IN ('cancel', 'draft')" + return super()._get_base_domain_extension(res_model) + + +class BudgetCommitForwardLine(models.Model): + _inherit = "budget.commit.forward.line" + + res_model = fields.Selection( + selection_add=[("stock.move", "Stock Move")], + ondelete={"stock.move": "cascade"}, + ) + document_id = fields.Reference( + selection_add=[("stock.move", "Stock Move")], + ondelete={"stock.move": "cascade"}, + ) + document_number = fields.Reference( + selection_add=[("stock.picking", "Stock Picking")], + ondelete={"stock.picking": "cascade"}, + ) diff --git a/budget_control_stock/models/budget_control.py b/budget_control_stock/models/budget_control.py new file mode 100644 index 00000000..f2dab3ae --- /dev/null +++ b/budget_control_stock/models/budget_control.py @@ -0,0 +1,14 @@ +# 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 + + +class BudgetControl(models.Model): + _inherit = "budget.control" + + amount_stock = fields.Monetary( + string="Stock", + compute="_compute_budget_info", + help="Sum of stock amount", + ) diff --git a/budget_control_stock/models/budget_period.py b/budget_control_stock/models/budget_period.py new file mode 100644 index 00000000..6812955c --- /dev/null +++ b/budget_control_stock/models/budget_period.py @@ -0,0 +1,38 @@ +# Copyright 2026 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class BudgetPeriod(models.Model): + _inherit = "budget.period" + + stock = fields.Boolean( + string="On Stock", + compute="_compute_control_stock", + store=True, + readonly=False, + help="Control budget on stock picking confirmed/validated", + ) + + def _budget_info_query(self): + query = super()._budget_info_query() + query["info_cols"]["amount_stock"] = ("75_st_commit", True) + return query + + @api.depends("control_budget") + def _compute_control_stock(self): + for rec in self: + rec.stock = rec.control_budget + + @api.model + def _get_eligible_budget_period(self, date=False, doc_type=False): + budget_period = super()._get_eligible_budget_period(date, doc_type) + # Get period control budget. + # if doctype is stock, check special control too. + if doc_type == "stock": + return budget_period.filtered( + lambda bp: (bp.control_budget and bp.stock) + or (not bp.control_budget and bp.stock) + ) + return budget_period diff --git a/budget_control_stock/models/stock_budget_move.py b/budget_control_stock/models/stock_budget_move.py new file mode 100644 index 00000000..74244267 --- /dev/null +++ b/budget_control_stock/models/stock_budget_move.py @@ -0,0 +1,51 @@ +# Copyright 2026 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class StockBudgetMove(models.Model): + _name = "stock.budget.move" + _inherit = ["base.budget.move"] + _description = "Stock Budget Moves" + + move_id = fields.Many2one( + comodel_name="stock.move", + readonly=True, + index=True, + help="Commit budget for this stock.move", + ) + picking_id = fields.Many2one( + comodel_name="stock.picking", + related="move_id.picking_id", + readonly=True, + store=True, + index=True, + ondelete="cascade", + ) + account_move_id = fields.Many2one( + comodel_name="account.move", + related="account_move_line_id.move_id", + store=True, + ) + account_move_line_id = fields.Many2one( + comodel_name="account.move.line", + readonly=True, + index=True, + help="Uncommit budget from this account.move.line", + ) + + @api.depends("picking_id") + def _compute_reference(self): + for rec in self: + rec.reference = ( + rec.reference if rec.reference else rec.picking_id.display_name + ) + + @api.depends("picking_id") + def _compute_source_document(self): + res = super()._compute_source_document() + for rec in self.filtered("picking_id"): + if not rec.source_document: + rec.source_document = rec.picking_id.origin or False + return res diff --git a/budget_control_stock/models/stock_move.py b/budget_control_stock/models/stock_move.py new file mode 100644 index 00000000..a0684017 --- /dev/null +++ b/budget_control_stock/models/stock_move.py @@ -0,0 +1,139 @@ +# Copyright 2026 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + +COMMIT_STATES = ["waiting", "confirmed", "assigned", "partially_available", "done"] + + +class StockMove(models.Model): + _name = "stock.move" + _inherit = ["stock.move", "budget.docline.mixin"] + _budget_date_commit_fields = ["picking_id.date_done", "picking_id.scheduled_date"] + _budget_move_model = "stock.budget.move" + _doc_rel = "picking_id" + + budget_move_ids = fields.One2many( + comodel_name="stock.budget.move", + inverse_name="move_id", + ) + account_id = fields.Many2one( + comodel_name="account.account", + compute="_compute_account_id", + ) + + def _compute_account_id(self): + for move in self: + move.account_id = move._get_stock_move_account() + + def _get_stock_move_account(self): + self.ensure_one() + fpos = ( + self.picking_id.partner_id.property_account_position_id + if self.picking_id and self.picking_id.partner_id + else False + ) + accounts = self.product_id.product_tmpl_id.get_product_accounts(fiscal_pos=fpos) + return accounts.get("expense") or accounts.get("stock_input") + + def recompute_budget_move(self): + budget_field = self._budget_field() + force_date_commit = self.env.context.get("force_date_commit", False) + for move in self: + st_date_commit = force_date_commit or move.date_commit + move[budget_field].unlink() + lot_lines = move.move_line_ids.filtered("lot_id") + if ( + move.picking_id.picking_type_id.budget_price_source == "lot_price" + and lot_lines + ): + for lot_line in lot_lines: + move.with_context( + force_date_commit=st_date_commit, + budget_lot_price=lot_line.lot_id.standard_price, + product_qty=lot_line.quantity_product_uom, + ).commit_budget() + elif move.product_id.tracking == "none" or lot_lines: + # Non-lot product: commit immediately. + # Lot-tracked product with lots reserved: commit using product qty. + # Lot-tracked product with no lots yet: defer to action_assign so + # lot-traced PO uncommit can balance the commit in the same pass. + move.with_context(force_date_commit=st_date_commit).commit_budget() + move.forward_commit() + # Re-apply uncommit for posted valuation JEs + move._recommit_via_valuation_lines() + + def _recommit_via_valuation_lines(self): + """Re-apply uncommit entries for posted valuation JEs after a recompute.""" + self.ensure_one() + # For 2-step delivery: PICK has no SVL; valuation is on OUT (dest) move. + svl_moves = self if self.stock_valuation_layer_ids else self.move_dest_ids + posted_je_lines = svl_moves.stock_valuation_layer_ids.filtered( + lambda svl: svl.account_move_id and svl.account_move_id.state == "posted" + ).mapped("account_move_id.line_ids") + if posted_je_lines: + posted_je_lines.uncommit_stock_budget() + + def _get_budget_price_unit(self): + self.ensure_one() + source = self.picking_id.picking_type_id.budget_price_source + if source == "lot_price": + lot_lines = self.move_line_ids.filtered("lot_id") + if lot_lines: + total_value = sum( + line.lot_id.standard_price * line.quantity_product_uom + for line in lot_lines + ) + total_qty = sum(line.quantity_product_uom for line in lot_lines) + if total_qty: + return total_value / total_qty + return self.price_unit or self.product_id.standard_price + + def _init_docline_budget_vals(self, budget_vals, analytic_id): + self.ensure_one() + if not budget_vals.get("amount_currency", False): + percent_analytic = self[self._budget_analytic_field].get(str(analytic_id)) + price = ( + self.env.context.get("budget_lot_price") + or self._get_budget_price_unit() + ) + product_qty = self.env.context.get("product_qty") or self.product_uom_qty + budget_vals["amount_currency"] = ( + price * product_qty * (percent_analytic / 100) + ) + # Document specific vals + budget_vals.update({"move_id": self.id}) + return super()._init_docline_budget_vals(budget_vals, analytic_id) + + def write(self, vals): + res = super().write(vals) + budget_trigger_fields = { + "product_uom_qty", + "product_id", + "analytic_distribution", + "price_unit", + } + if budget_trigger_fields & vals.keys(): + valid_moves = self.filtered(lambda m: m._valid_commit_state()) + if valid_moves: + valid_moves.recompute_budget_move() + BudgetPeriod = self.env["budget.period"] + BudgetPeriod.check_budget(valid_moves, doc_type="stock") + return res + + @api.depends("picking_id.picking_type_id.budget_commit", "state") + def _compute_can_commit(self): + res = super()._compute_can_commit() + no_commit = self.filtered( + lambda m: not m.picking_id.picking_type_id.budget_commit + ) + no_commit.update({"can_commit": False}) + return res + + def _valid_commit_state(self): + if not self.picking_id.picking_type_id.budget_commit: + return False + return self.state in COMMIT_STATES + + def _get_included_tax(self): + return False diff --git a/budget_control_stock/models/stock_picking.py b/budget_control_stock/models/stock_picking.py new file mode 100644 index 00000000..6d599f10 --- /dev/null +++ b/budget_control_stock/models/stock_picking.py @@ -0,0 +1,96 @@ +# 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 + + +class StockPicking(models.Model): + _inherit = "stock.picking" + _docline_rel = "move_ids" + _docline_type = "stock" + + budget_move_ids = fields.One2many( + comodel_name="stock.budget.move", + inverse_name="picking_id", + ) + + def recompute_budget_move(self): + self.mapped("move_ids").recompute_budget_move() + + def close_budget_move(self): + self.mapped("move_ids").close_budget_move() + + def write(self, vals): + """ + Uncommit the budget when the document state changes. + If the picking is canceled or moved to draft (ready), + all budget commitments will be deleted. + + State transitions: + - "done" = Validated (stock.move is done) + - "cancel" = Cancelled + """ + res = super().write(vals) + if vals.get("state") in ("done", "cancel", "draft"): + doclines = self.mapped("move_ids") + if vals.get("state") in ("cancel", "draft"): + doclines.write({"date_commit": False}) + doclines.recompute_budget_move() + if "move_ids" in vals or "move_ids_without_package" in vals: + BudgetPeriod = self.env["budget.period"] + for doc in self: + if doc.state not in ("cancel", "draft"): + doc.recompute_budget_move() + BudgetPeriod.check_budget(doc.move_ids, doc_type="stock") + return res + + def unlink(self): + # Compute commit again after unlink + moves = self.mapped("move_ids") + res = super().unlink() + moves._compute_commit() + return res + + def action_cancel(self): + res = super().action_cancel() + for doc in self: + doclines = doc.move_ids + doclines.write({"date_commit": False}) + doclines.recompute_budget_move() + return res + + def button_validate(self): + res = super().button_validate() + BudgetPeriod = self.env["budget.period"] + for doc in self: + BudgetPeriod.check_budget(doc.move_ids, doc_type="stock") + return res + + def action_confirm(self): + res = super().action_confirm() + # Skip budget check when DO is auto-created from SO confirmation. + # Budget Control may not be confirmed yet (user needs to set KPIs). + # Subsequent DO operations (validate, etc.) will enforce normally. + if self.env.context.get("skip_budget_commit"): + return res + BudgetPeriod = self.env["budget.period"] + for doc in self: + doc.recompute_budget_move() + BudgetPeriod.check_budget(doc.move_ids, doc_type="stock") + return res + + def action_assign(self): + res = super().action_assign() + BudgetPeriod = self.env["budget.period"] + budget_pickings = self.filtered(lambda p: p.picking_type_id.budget_commit) + for doc in budget_pickings: + doc.recompute_budget_move() + BudgetPeriod.check_budget(doc.move_ids, doc_type="stock") + return res + + def do_unreserve(self): + res = super().do_unreserve() + budget_pickings = self.filtered(lambda p: p.picking_type_id.budget_commit) + for doc in budget_pickings: + doc.recompute_budget_move() + return res diff --git a/budget_control_stock/models/stock_picking_type.py b/budget_control_stock/models/stock_picking_type.py new file mode 100644 index 00000000..055b5e03 --- /dev/null +++ b/budget_control_stock/models/stock_picking_type.py @@ -0,0 +1,24 @@ +# 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 + + +class StockPickingType(models.Model): + _inherit = "stock.picking.type" + + budget_commit = fields.Boolean( + string="Commit Budget", + default=False, + help="When enabled, stock moves of this operation type will commit budget.", + ) + budget_price_source = fields.Selection( + selection=[ + ("standard_price", "Product Standard Price"), + ("lot_price", "Lot Standard Price"), + ], + default="standard_price", + help="Source of unit price for budget commitment.\n" + "- Product Standard Price: use product.standard_price (default).\n" + "- Lot Standard Price: use lot.standard_price per reserved lot (for FIFO).", + ) diff --git a/budget_control_stock/pyproject.toml b/budget_control_stock/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/budget_control_stock/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/budget_control_stock/readme/CONFIGURE.md b/budget_control_stock/readme/CONFIGURE.md new file mode 100644 index 00000000..895ca294 --- /dev/null +++ b/budget_control_stock/readme/CONFIGURE.md @@ -0,0 +1,23 @@ +To configure this module, you need to: + +#. Go to *Inventory > Configuration > Operation Types*. +#. Open the operation type that issues goods from inventory (for example + *Delivery Orders*) and enable `Commit Budget`. Confirming a transfer of this + operation type will then create budget commitments. +#. Choose the `Budget Price Source` used to value the commitment: + + * *Product Standard Price* (default): use ``product.standard_price``. + * *Lot Standard Price*: use ``lot.standard_price`` per reserved lot, for + FIFO / lot-based costing. + +#. Go to *Budgeting > Configuration > Budget Periods*, open the relevant period + and tick `On Stock` to control the budget against stock commitments. When + `Control Budget` is enabled the flag follows it by default. + +Notes: + +* Stock moves must carry an analytic distribution (provided by the + *stock_analytic* dependency); commitments are created per analytic account. +* Recording the related stock journal entry as budget *actual* relies on the + standard journal-entry budget flow, so make sure the stock journal entries + carry the analytic distribution of the moves. diff --git a/budget_control_stock/readme/CONTRIBUTORS.md b/budget_control_stock/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..8d0d3315 --- /dev/null +++ b/budget_control_stock/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Saran Lim. \<\> diff --git a/budget_control_stock/readme/DESCRIPTION.md b/budget_control_stock/readme/DESCRIPTION.md new file mode 100644 index 00000000..5921813d --- /dev/null +++ b/budget_control_stock/readme/DESCRIPTION.md @@ -0,0 +1,19 @@ +This module adds budget control on stock operations, so that goods issued +from inventory consume budget independently from the related purchase or sale +documents. + +When an outgoing transfer (delivery) of an operation type with `Commit Budget` +enabled is confirmed, a ``stock.budget.move`` commitment is created per analytic +account. The committed amount is the move quantity valued at the configured price +source: the product standard price (default) or the lot standard price. When the +transfer is validated, the stock journal entry is posted and recorded as budget +*actual* (from the stock valuation value), while the original stock commitment is +released, so the budget consumption moves smoothly from *commitment* to *actual*. +Cancelling or reverting the transfer removes the commitment. + +The committed amount can be reviewed on the *Budget Commitment* tab of the +stock transfer and on the budget monitoring report. + +Open stock commitments (transfers confirmed but not yet validated at the end of +a budget period) are carried forward to the next period together with the other +commitment types of the standard *Budget Commit Forward* document. diff --git a/budget_control_stock/readme/USAGE.md b/budget_control_stock/readme/USAGE.md new file mode 100644 index 00000000..ba2a7d58 --- /dev/null +++ b/budget_control_stock/readme/USAGE.md @@ -0,0 +1,20 @@ +To use this module, you need to: + +#. Make sure the operation type and the budget period are configured (see + *Configuration*) and that the budget control sheet of the analytic account is + in *Controlled* status. +#. Create an outgoing transfer (delivery) whose stock moves carry an analytic + distribution. +#. Confirm the transfer. For each analytic account, a budget commitment is + created from the configured price source (product or lot standard price). If + the budget is not sufficient and the period blocks over-budget transactions, + confirmation is refused. +#. Review the committed amount on the *Budget Commitment* tab of the transfer, + on the budget control sheet (`Stock` column) or on the budget monitoring + report. +#. Validate the transfer. The stock journal entry is posted and recorded as + budget *actual*, and the matching stock commitment is released, so + consumption moves from *commitment* to *actual*. + +Cancelling, reverting to draft or returning the transfer removes the related +commitment. diff --git a/budget_control_stock/report/__init__.py b/budget_control_stock/report/__init__.py new file mode 100644 index 00000000..84fbcaec --- /dev/null +++ b/budget_control_stock/report/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import budget_common_monitoring +from . import budget_monitor_report diff --git a/budget_control_stock/report/budget_common_monitoring.py b/budget_control_stock/report/budget_common_monitoring.py new file mode 100644 index 00000000..7352c971 --- /dev/null +++ b/budget_control_stock/report/budget_common_monitoring.py @@ -0,0 +1,18 @@ +# Copyright 2026 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models + + +class BudgetCommonMonitoring(models.AbstractModel): + _inherit = "budget.common.monitoring" + + def _get_consumed_sources(self): + return super()._get_consumed_sources() + [ + { + "model": ("stock.move", "Stock Move"), + "type": ("75_st_commit", "ST Commit"), + "budget_move": ("stock_budget_move", "move_id"), + "source_doc": ("stock_picking", "picking_id"), + } + ] diff --git a/budget_control_stock/report/budget_monitor_report.py b/budget_control_stock/report/budget_monitor_report.py new file mode 100644 index 00000000..49931301 --- /dev/null +++ b/budget_control_stock/report/budget_monitor_report.py @@ -0,0 +1,28 @@ +# Copyright 2026 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, models +from odoo.tools import SQL + + +class BudgetMonitorReport(models.Model): + _inherit = "budget.monitor.report" + + @api.model + def _where_stock(self) -> SQL: + return SQL("") + + @api.model + def _get_sql(self) -> SQL: + select_st_query = self._select_statement("75_st_commit") + key_select_list = sorted(select_st_query.keys()) + select_st = ", ".join(select_st_query[x] for x in key_select_list) + query_string = super()._get_sql() + query_string = SQL( + query_string.code + + "UNION ALL (SELECT %(select_st)s %(from_st)s %(where_st)s)", + select_st=SQL(select_st), + from_st=self._from_statement("75_st_commit"), + where_st=self._where_stock(), + ) + return query_string diff --git a/budget_control_stock/security/ir.model.access.csv b/budget_control_stock/security/ir.model.access.csv new file mode 100644 index 00000000..fb8887fa --- /dev/null +++ b/budget_control_stock/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_stock_budget_move_user,access_stock_budget_move_user,model_stock_budget_move,base.group_user,1,1,1,1 diff --git a/budget_control_stock/static/description/icon.png b/budget_control_stock/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z 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 Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 diff --git a/budget_control_stock/static/description/index.html b/budget_control_stock/static/description/index.html new file mode 100644 index 00000000..b10067f6 --- /dev/null +++ b/budget_control_stock/static/description/index.html @@ -0,0 +1,490 @@ + + + + + +Budget Control on Stock + + + +
+

Budget Control on Stock

+ + +

Alpha License: AGPL-3 ecosoft-odoo/budgeting

+

This module adds budget control on stock operations, so that goods +issued from inventory consume budget independently from the related +purchase or sale documents.

+

When an outgoing transfer (delivery) of an operation type with +Commit Budget enabled is confirmed, a stock.budget.move +commitment is created per analytic account. The committed amount is the +move quantity valued at the configured price source: the product +standard price (default) or the lot standard price. When the transfer is +validated, the stock journal entry is posted and recorded as budget +actual (from the stock valuation value), while the original stock +commitment is released, so the budget consumption moves smoothly from +commitment to actual. Cancelling or reverting the transfer removes +the commitment.

+

The committed amount can be reviewed on the Budget Commitment tab of +the stock transfer and on the budget monitoring report.

+

Open stock commitments (transfers confirmed but not yet validated at the +end of a budget period) are carried forward to the next period together +with the other commitment types of the standard Budget Commit Forward +document.

+
+

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

+ +
+

Configuration

+

To configure this module, you need to:

+

#. Go to Inventory > Configuration > Operation Types. #. Open the +operation type that issues goods from inventory (for example Delivery +Orders) and enable Commit Budget. Confirming a transfer of this +operation type will then create budget commitments. #. Choose the +Budget Price Source used to value the commitment:

+
    +
  • Product Standard Price (default): use product.standard_price.
  • +
  • Lot Standard Price: use lot.standard_price per reserved lot, for +FIFO / lot-based costing.
  • +
+

#. Go to Budgeting > Configuration > Budget Periods, open the relevant +period and tick On Stock to control the budget against stock +commitments. When Control Budget is enabled the flag follows it by +default.

+

Notes:

+
    +
  • Stock moves must carry an analytic distribution (provided by the +stock_analytic dependency); commitments are created per analytic +account.
  • +
  • Recording the related stock journal entry as budget actual relies on +the standard journal-entry budget flow, so make sure the stock journal +entries carry the analytic distribution of the moves.
  • +
+
+
+

Usage

+

To use this module, you need to:

+

#. Make sure the operation type and the budget period are configured +(see Configuration) and that the budget control sheet of the analytic +account is in Controlled status. #. Create an outgoing transfer +(delivery) whose stock moves carry an analytic distribution. #. Confirm +the transfer. For each analytic account, a budget commitment is created +from the configured price source (product or lot standard price). If the +budget is not sufficient and the period blocks over-budget transactions, +confirmation is refused. #. Review the committed amount on the Budget +Commitment tab of the transfer, on the budget control sheet (Stock +column) or on the budget monitoring report. #. Validate the transfer. +The stock journal entry is posted and recorded as budget actual, and +the matching stock commitment is released, so consumption moves from +commitment to actual.

+

Cancelling, reverting to draft or returning the transfer removes the +related commitment.

+
+
+

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

+ +
+
+

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_stock/tests/__init__.py b/budget_control_stock/tests/__init__.py new file mode 100644 index 00000000..3c090333 --- /dev/null +++ b/budget_control_stock/tests/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import test_budget_stock diff --git a/budget_control_stock/tests/test_budget_stock.py b/budget_control_stock/tests/test_budget_stock.py new file mode 100644 index 00000000..95464747 --- /dev/null +++ b/budget_control_stock/tests/test_budget_stock.py @@ -0,0 +1,240 @@ +# 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 import Command +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 TestBudgetControlStock(get_budget_common_class()): + @classmethod + @freeze_time("2001-02-01") + def setUpClass(cls): + super().setUpClass() + # Create budget plan with 1 analytic + lines = [ + Command.create( + {"analytic_account_id": cls.costcenter1.id, "amount": 2400.0} + ) + ] + cls.budget_plan = cls.create_budget_plan( + cls, + name=f"Test - Plan {cls.budget_period.name}", + budget_period=cls.budget_period, + lines=lines, + ) + cls.budget_plan.action_confirm() + cls.budget_plan.action_create_update_budget_control() + cls.budget_plan.action_done() + + # Refresh data + cls.budget_plan.invalidate_recordset() + + cls.budget_control = cls.budget_plan.budget_control_ids + cls.budget_control.template_line_ids = [ + cls.template_line1.id, + cls.template_line2.id, + cls.template_line3.id, + ] + + # Test item created for 3 kpi x 4 quarters = 12 budget items + cls.budget_control.prepare_budget_control_matrix() + assert len(cls.budget_control.line_ids) == 12 + # Assign budget.control amount: KPI1 = 100x4=400, KPI2=800, KPI3=1,200 + cls.budget_control.line_ids.filtered(lambda x: x.kpi_id == cls.kpi1).write( + {"amount": 100} + ) + cls.budget_control.line_ids.filtered(lambda x: x.kpi_id == cls.kpi2).write( + {"amount": 200} + ) + cls.budget_control.line_ids.filtered(lambda x: x.kpi_id == cls.kpi3).write( + {"amount": 300} + ) + + # Warehouse and picking type + cls.warehouse = cls.env["stock.warehouse"].search([], limit=1) + cls.picking_type = cls.warehouse.out_type_id + cls.picking_type.budget_commit = True + cls.location_src = cls.picking_type.default_location_src_id + cls.location_dest = cls.env.ref("stock.stock_location_customers") + + def _create_picking(self, picking_lines): + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type.id, + "location_id": self.location_src.id, + "location_dest_id": self.location_dest.id, + "move_ids_without_package": [ + Command.create( + { + "name": line["product_id"].name, + "product_id": line["product_id"].id, + "product_uom_qty": line["product_qty"], + "product_uom": line["product_id"].uom_id.id, + "price_unit": line["price_unit"], + "analytic_distribution": line["analytic_distribution"], + "location_id": self.location_src.id, + "location_dest_id": self.location_dest.id, + } + ) + for line in picking_lines + ], + } + ) + return picking + + @freeze_time("2001-02-01") + def test_01_budget_stock(self): + """ + On Stock Picking + (1) Confirm picking with amount exceeding budget -> raises UserError + (2) Confirm picking within budget -> succeeds and automatically commits budget. + (3) Modify picking lines to exceed budget -> raises UserError + (4) Modify picking lines within budget -> + automatically recomputes and adjusts commitment. + (5) Cancel picking -> budget commitment is deleted. + """ + self.budget_control.action_submit() + self.budget_control.action_done() + self.assertAlmostEqual(self.budget_control.amount_budget, 2400.0) + + # Prepare Stock Picking (exceeds budget) + analytic_distribution = {str(self.costcenter1.id): 100} + picking = self._create_picking( + [ + { + "product_id": self.product1, # KPI1 + "product_qty": 1, + "price_unit": 401, # Exceeds budget of KPI1 (400) + "analytic_distribution": analytic_distribution, + } + ] + ) + + self.budget_period.control_budget = True + self.budget_period.control_level = "analytic_kpi" + + # (1) Confirm picking with amount exceeding budget -> should fail + with self.assertRaisesRegex(UserError, "Budget not sufficient"): + picking.action_confirm() + + # Adjust price to be within budget + picking.move_ids_without_package[0].write({"price_unit": 300}) + # (2) Confirm picking within budget -> should succeed and auto commit + picking.action_confirm() + self.assertIn(picking.state, ["confirmed", "assigned", "waiting"]) + self.assertAlmostEqual(self.budget_control.amount_stock, 300.0) + + # (3) Modify picking lines to exceed budget -> should fail + with self.assertRaisesRegex(UserError, "Budget not sufficient"): + picking.write( + { + "move_ids_without_package": [ + Command.update( + picking.move_ids_without_package[0].id, {"price_unit": 500} + ) + ] + } + ) + + # (4) Modify picking lines within budget -> automatically recomputes + picking.write( + { + "move_ids_without_package": [ + Command.update( + picking.move_ids_without_package[0].id, {"price_unit": 350} + ) + ] + } + ) + self.assertAlmostEqual(self.budget_control.amount_stock, 350.0) + + # (5) Cancel picking -> budget commitment is deleted + picking.action_cancel() + self.assertAlmostEqual(self.budget_control.amount_stock, 0.0) + + @freeze_time("2001-02-01") + def test_02_budget_stock_no_control(self): + """ + (1) stock control enabled -> amount exceeds budget -> UserError + (2) budget_period.stock=False -> stock control disabled -> no error + """ + self.budget_control.action_submit() + self.budget_control.action_done() + self.assertAlmostEqual(self.budget_control.amount_budget, 2400.0) + + self.budget_period.control_budget = True + self.assertTrue(self.budget_period.stock) + self.budget_period.control_level = "analytic_kpi" + analytic_distribution = {str(self.costcenter1.id): 100} + picking = self._create_picking( + [ + { + "product_id": self.product1, # KPI1 = 401 -> error + "product_qty": 1, + "price_unit": 401, + "analytic_distribution": analytic_distribution, + } + ] + ) + + # (1) stock control enabled -> budget check fails + with self.assertRaisesRegex(UserError, "Budget not sufficient"): + picking.action_confirm() + + # (2) disable stock control specifically -> no error + self.budget_period.stock = False + picking.action_confirm() + self.assertIn(picking.state, ["confirmed", "assigned", "waiting"]) + + @freeze_time("2001-02-01") + def test_03_budget_stock_recompute_close(self): + """ + (1) Confirm two-line picking -> commits budget + (2) Explicit recompute -> same amount + (3) close_budget_move -> clears commitment + """ + self.budget_control.action_submit() + self.budget_control.action_done() + self.assertAlmostEqual(self.budget_control.amount_budget, 2400.0) + + self.budget_period.control_budget = True + self.budget_period.control_level = "analytic" + analytic_distribution = {str(self.costcenter1.id): 100} + picking = self._create_picking( + [ + { + "product_id": self.product1, # KPI1 = 2*150 = 300 + "product_qty": 2, + "price_unit": 150, + "analytic_distribution": analytic_distribution, + }, + { + "product_id": self.product2, # KPI2 = 4*100 = 400 + "product_qty": 4, + "price_unit": 100, + "analytic_distribution": analytic_distribution, + }, + ] + ) + picking.action_confirm() + # Budget Created + self.assertTrue(picking.budget_move_ids) + self.budget_control.invalidate_recordset() + # Stock commit = (2*150) + (4*100) = 700 + self.assertAlmostEqual(self.budget_control.amount_stock, 700.0) + + # Recompute -> same result + picking.recompute_budget_move() + self.budget_control.invalidate_recordset() + self.assertAlmostEqual(self.budget_control.amount_stock, 700.0) + + # Close -> clears commitment + picking.close_budget_move() + self.budget_control.invalidate_recordset() + self.assertAlmostEqual(self.budget_control.amount_stock, 0.0) diff --git a/budget_control_stock/views/budget_commit_forward_view.xml b/budget_control_stock/views/budget_commit_forward_view.xml new file mode 100644 index 00000000..b37962c7 --- /dev/null +++ b/budget_control_stock/views/budget_commit_forward_view.xml @@ -0,0 +1,27 @@ + + + + view.budget.commit.forward.form + budget.commit.forward + + 40 + +
+
+ +
+
+ + + + + +
+
+
diff --git a/budget_control_stock/views/budget_control_view.xml b/budget_control_stock/views/budget_control_view.xml new file mode 100644 index 00000000..1072da84 --- /dev/null +++ b/budget_control_stock/views/budget_control_view.xml @@ -0,0 +1,23 @@ + + + + budget.control.view.list + budget.control + + + + + + + + + budget.control.view.form + budget.control + + + + + + + + diff --git a/budget_control_stock/views/budget_period_view.xml b/budget_control_stock/views/budget_period_view.xml new file mode 100644 index 00000000..93a8fefb --- /dev/null +++ b/budget_control_stock/views/budget_period_view.xml @@ -0,0 +1,18 @@ + + + + budget.period.view.form + budget.period + + + + + + + + diff --git a/budget_control_stock/views/stock_budget_move_view.xml b/budget_control_stock/views/stock_budget_move_view.xml new file mode 100644 index 00000000..8758e9de --- /dev/null +++ b/budget_control_stock/views/stock_budget_move_view.xml @@ -0,0 +1,46 @@ + + + + + view.stock.budget.move.form + stock.budget.move + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
diff --git a/budget_control_stock/views/stock_picking_type_views.xml b/budget_control_stock/views/stock_picking_type_views.xml new file mode 100644 index 00000000..0e1b2cd5 --- /dev/null +++ b/budget_control_stock/views/stock_picking_type_views.xml @@ -0,0 +1,21 @@ + + + + stock.picking.type.form.budget + stock.picking.type + + + + + + + + + diff --git a/budget_control_stock/views/stock_picking_view.xml b/budget_control_stock/views/stock_picking_view.xml new file mode 100644 index 00000000..054c52b5 --- /dev/null +++ b/budget_control_stock/views/stock_picking_view.xml @@ -0,0 +1,85 @@ + + + + view.picking.form + stock.picking + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + +
+
+
+
+ + + stock.move.view.form + stock.move + + + + + + + +
From e391b71b01e8d88551b19623bf28b7c4e5de9f9c Mon Sep 17 00:00:00 2001 From: Saran440 Date: Sat, 30 May 2026 19:46:53 +0700 Subject: [PATCH 2/2] [FIX] report dimension --- budget_plan_detail/__manifest__.py | 2 +- .../report/budget_monitor_report.py | 35 +++++++++---------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/budget_plan_detail/__manifest__.py b/budget_plan_detail/__manifest__.py index 4591d539..191a44c3 100644 --- a/budget_plan_detail/__manifest__.py +++ b/budget_plan_detail/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Budget Plan - Details", "summary": "Allocated budget details", - "version": "18.0.1.1.3", + "version": "18.0.1.1.4", "category": "Accounting", "license": "AGPL-3", "author": "Ecosoft, Odoo Community Association (OCA)", diff --git a/budget_plan_detail/report/budget_monitor_report.py b/budget_plan_detail/report/budget_monitor_report.py index b1b7bed4..2bfb19e0 100644 --- a/budget_plan_detail/report/budget_monitor_report.py +++ b/budget_plan_detail/report/budget_monitor_report.py @@ -9,8 +9,9 @@ class BudgetMonitorReport(models.Model): # Budget def _select_budget(self): select_budget_query = super()._select_budget() - # Find analytic tag dimension (if any) - dimension_fields = self._get_dimension_fields("budget.plan.line.detail") + # Canonical dimension set: budget.plan.line.detail always holds them all. + # Sort so every UNION branch emits dimensions in the same order. + dimension_fields = sorted(self._get_dimension_fields("budget.plan.line.detail")) formatted_dimension_fields = "" if dimension_fields: formatted_dimension_fields = ", " + ", ".join( @@ -26,24 +27,22 @@ def _select_budget(self): def _select_statement(self, amount_type): select_statement = super()._select_statement(amount_type) - # Find analytic tag dimension (from budget plan line detail) - budget_dimension_fields = self._get_dimension_fields("budget.plan.line.detail") + # Canonical, ordered dimension set (reference = budget.plan.line.detail). + # Every branch must emit exactly these columns in the same order so the + # UNION column count and positions line up. A given budget_move may hold + # only a subset of the dimensions (e.g. created before its model + # existed); emit a. when present, null otherwise. + dimension_fields = sorted(self._get_dimension_fields("budget.plan.line.detail")) formatted_dimension_fields = "" - - # Find analytic tag dimension (from each budget_move) - parts = self._get_from_amount_types()[amount_type].split() - if parts[0].upper() == "FROM" and parts[2] == "a": - table_name = parts[1].replace("_", ".") - dimension_fields = self._get_dimension_fields(table_name) - if dimension_fields: - formatted_dimension_fields = ", " + ", ".join( - f"a.{f} as {f}" for f in dimension_fields - ) - - # For case: not installed budget_plan_detail_* but install budget_control_* - if budget_dimension_fields and not formatted_dimension_fields: + if dimension_fields: + source_fields = set() + parts = self._get_from_amount_types()[amount_type].split() + if parts[0].upper() == "FROM" and parts[2] == "a": + table_name = parts[1].replace("_", ".") + source_fields = set(self._get_dimension_fields(table_name)) formatted_dimension_fields = ", " + ", ".join( - f"null::integer as {f}" for f in budget_dimension_fields + (f"a.{f}" if f in source_fields else "null::integer") + f" as {f}" + for f in dimension_fields ) select_statement[80] = ( f"a.fund_id, a.fund_group_id {formatted_dimension_fields}"