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 00000000..3a0328b5 Binary files /dev/null and b/budget_control_stock/static/description/icon.png differ 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 + + + + + + + +
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}"