From 0bfea89a9406cfe62c70a8b1b302de9bd850a4cc Mon Sep 17 00:00:00 2001 From: Saran440 Date: Fri, 10 Jul 2026 21:33:13 +0700 Subject: [PATCH] [FIX] budget_control_stock / purchase_stock --- .../models/account_move.py | 11 +-- .../tests/test_budget_purchase_stock.py | 97 +++++++++++++++++++ budget_control_stock/models/account_move.py | 25 ++++- .../tests/test_budget_stock.py | 67 +++++++++++++ 4 files changed, 191 insertions(+), 9 deletions(-) diff --git a/budget_control_purchase_stock/models/account_move.py b/budget_control_purchase_stock/models/account_move.py index bba13457..38f87146 100644 --- a/budget_control_purchase_stock/models/account_move.py +++ b/budget_control_purchase_stock/models/account_move.py @@ -18,9 +18,7 @@ def _onchange_purchase_auto_complete(self): @api.model_create_multi def create(self, vals_list): - """For bills created with PO lines (e.g., import), apply the - not_affect_budget flag based on PO line analytics. - """ + """Apply not_affect_budget to bills created from stock_done PO lines.""" moves = super().create(vals_list) for move in moves: if move.move_type in ("in_invoice", "in_refund"): @@ -28,7 +26,7 @@ def create(self, vals_list): return moves def write(self, vals): - """If PO lines or invoice lines change, re-evaluate not_affect_budget.""" + """Re-evaluate not_affect_budget when bill PO lines change.""" res = super().write(vals) if "line_ids" in vals and self.move_type in ("in_invoice", "in_refund"): self._compute_not_affect_budget_from_po() @@ -54,10 +52,7 @@ def _check_not_affect_budget_cascade(self): move.not_affect_budget = True def _compute_not_affect_budget_from_po(self): - """For vendor bills with PO lines: if any PO line analytic is - configured as stock_done, set not_affect_budget=True on the - matching bill line(s). Manual bills (no PO) are not touched. - """ + """Flag vendor bill lines from stock_done PO lines as not_affect_budget.""" self.ensure_one() if self.move_type not in ("in_invoice", "in_refund"): return diff --git a/budget_control_purchase_stock/tests/test_budget_purchase_stock.py b/budget_control_purchase_stock/tests/test_budget_purchase_stock.py index e23483eb..ea6e24c6 100644 --- a/budget_control_purchase_stock/tests/test_budget_purchase_stock.py +++ b/budget_control_purchase_stock/tests/test_budget_purchase_stock.py @@ -819,3 +819,100 @@ def test_06_account_move_not_affect_budget_with_stock_done(self): bill.invoice_date = bill.date bill.action_post() self.assertFalse(bill.invoice_line_ids.budget_move_ids) + + @freeze_time("2001-02-01") + def test_07_receipt_svl_je_not_affect_budget(self): + """ + The SVL JE generated by validating a receipt (incoming picking) + whose picking type does not commit budget must be flagged + not_affect_budget, so it does not record actual budget. + The actual budget belongs to the delivery order (outgoing), whose + picking type commits budget and is left untouched. + + Flow (default config, no stock_done needed): + 1. PO + receipt (validate done) -> SVL JE is flagged not_affect_budget + (header + lines) because the receipt's picking type has + budget_commit=False. + 2. amount_actual stays 0 (receipt JE does not commit actual). + 3. amount_purchase stays as the PO commitment (undiminished). + 4. A delivery order (budget_commit=True) still records actual normally. + """ + self.budget_control.action_submit() + self.budget_control.action_done() + self.budget_period.control_budget = True + self.budget_period.control_level = "analytic" + + analytic_distribution = {str(self.costcenter1.id): 100} + + # PO: qty=2, price=50 -> commit=100 + purchase = self._create_purchase( + [ + { + "product_id": self.product_lot, + "product_qty": 2, + "price_unit": 50, + "analytic_distribution": analytic_distribution, + } + ] + ) + purchase = purchase.with_context(force_date_commit=purchase.date_order) + purchase.button_confirm() + self.budget_control.invalidate_recordset() + self.assertAlmostEqual(self.budget_control.amount_purchase, 100.0) + self.assertAlmostEqual(self.budget_control.amount_actual, 0.0) + + # Validate receipt with lots -> SVL JE is created & posted. + Lot = self.env["stock.lot"] + l1 = Lot.create( + { + "name": "SVL-001", + "product_id": self.product_lot.id, + "standard_price": 50.0, + } + ) + l2 = Lot.create( + { + "name": "SVL-002", + "product_id": self.product_lot.id, + "standard_price": 50.0, + } + ) + self._validate_receipt_with_lots(purchase, [l1, l2]) + + # The receipt's SVL JE(s) are flagged not_affect_budget. + receipt = purchase.picking_ids.filtered( + lambda p: p.picking_type_code == "incoming" + ) + svl_jes = receipt.move_ids.stock_valuation_layer_ids.account_move_id + self.assertTrue(svl_jes) + for je in svl_jes: + self.assertTrue(je.not_affect_budget) + self.assertTrue(all(je.line_ids.mapped("not_affect_budget"))) + self.assertFalse(je.line_ids.budget_move_ids) + + # Receipt JE does not record actual; PO commitment untouched. + self.budget_control.invalidate_recordset() + self.assertAlmostEqual(self.budget_control.amount_actual, 0.0) + self.assertAlmostEqual(self.budget_control.amount_purchase, 100.0) + + # Delivery order (budget_commit=True) still records actual normally. + self.picking_type_out.budget_commit = True + do = self._create_delivery( + [ + { + "product_id": self.product_lot, + "product_qty": 2, + "price_unit": 50, + "analytic_distribution": analytic_distribution, + } + ] + ) + do.action_confirm() + self._assign_lots_to_delivery(do, [l1, l2]) + do.with_context(skip_backorder=True).button_validate() + do_svl_jes = do.move_ids.stock_valuation_layer_ids.account_move_id + self.assertTrue(do_svl_jes) + for je in do_svl_jes: + self.assertFalse(je.not_affect_budget) + self.budget_control.invalidate_recordset() + self.assertAlmostEqual(self.budget_control.amount_actual, 100.0) diff --git a/budget_control_stock/models/account_move.py b/budget_control_stock/models/account_move.py index 6835c31b..7e7ae8af 100644 --- a/budget_control_stock/models/account_move.py +++ b/budget_control_stock/models/account_move.py @@ -1,12 +1,19 @@ # 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 +from odoo import api, models class AccountMove(models.Model): _inherit = "account.move" + @api.model_create_multi + def create(self, vals_list): + """Flag SVL JEs of non-commit picking types as not_affect_budget.""" + moves = super().create(vals_list) + moves._compute_not_affect_budget_for_svl() + return moves + def write(self, vals): """Recompute stock commit when JE state changes. @@ -21,4 +28,20 @@ def write(self, vals): lambda m: m.picking_id.picking_type_id.budget_commit ) stock_moves.recompute_budget_move() + self._compute_not_affect_budget_for_svl() return res + + def _compute_not_affect_budget_for_svl(self): + """Flag SVL JE of a picking type without budget_commit as not_affect_budget.""" + for move in self: + if move.move_type != "entry": + continue + stock_move = move.stock_move_id + # A picking type that does not commit budget (e.g. receipts) should + # not record actual via its valuation JE either, mirroring the rule + # already applied to stock.move (see StockMove._compute_can_commit). + not_affect = bool( + stock_move and not stock_move.picking_id.picking_type_id.budget_commit + ) + if move.not_affect_budget != not_affect: + move.not_affect_budget = not_affect diff --git a/budget_control_stock/tests/test_budget_stock.py b/budget_control_stock/tests/test_budget_stock.py index 659d8e72..fc00bfce 100644 --- a/budget_control_stock/tests/test_budget_stock.py +++ b/budget_control_stock/tests/test_budget_stock.py @@ -643,3 +643,70 @@ def test_09_unlink_confirmed_picking_clears_budget(self): [("move_id", "=", False), ("picking_id", "=", False)] ) self.assertFalse(orphan) + + @freeze_time("2001-02-01") + def test_10_svl_je_not_affect_budget_by_picking_type(self): + """ + The SVL JE's not_affect_budget flag mirrors the picking type's + budget_commit setting. + + (1) Picking type WITHOUT budget_commit: validating creates an SVL JE + that is flagged not_affect_budget and records no actual. + (2) Picking type WITH budget_commit: validating creates an SVL JE + that is NOT flagged and records actual normally. + """ + self.budget_control.action_submit() + self.budget_control.action_done() + self.budget_period.control_budget = True + self.budget_period.control_level = "analytic" + analytic_dist = {str(self.costcenter1.id): 100} + + # --- (1) Picking type WITHOUT budget_commit -> not_affect_budget --- + self.picking_type.budget_commit = False + self._set_qty_on_hand(self.product_std, 1) + do_no_commit = self._create_picking( + [ + { + "product_id": self.product_std, + "product_qty": 1, + "price_unit": 100, + "analytic_distribution": analytic_dist, + } + ] + ) + do_no_commit.action_confirm() + do_no_commit.action_assign() + do_no_commit.with_context(skip_backorder=True).button_validate() + self.assertEqual(do_no_commit.state, "done") + je_no_commit = do_no_commit.move_ids.stock_valuation_layer_ids.account_move_id + self.assertTrue(je_no_commit) + self.assertTrue(je_no_commit.not_affect_budget) + # No stock commit (no budget_commit on the type) and no actual. + self.assertFalse(do_no_commit.budget_move_ids) + self.assertFalse(je_no_commit.line_ids.budget_move_ids) + + # --- (2) Picking type WITH budget_commit -> NOT not_affect_budget --- + self.picking_type.budget_commit = True + self._set_qty_on_hand(self.product_std, 1) + do_commit = self._create_picking( + [ + { + "product_id": self.product_std, + "product_qty": 1, + "price_unit": 100, + "analytic_distribution": analytic_dist, + } + ] + ) + do_commit.action_confirm() + self.budget_control.invalidate_recordset() + self.assertAlmostEqual(self.budget_control.amount_stock, 100.0) + do_commit.action_assign() + do_commit.with_context(skip_backorder=True).button_validate() + self.assertEqual(do_commit.state, "done") + je_commit = do_commit.move_ids.stock_valuation_layer_ids.account_move_id + self.assertTrue(je_commit) + self.assertFalse(je_commit.not_affect_budget) + # After validate: JE posted -> stock commit becomes actual. + self.budget_control.invalidate_recordset() + self.assertAlmostEqual(self.budget_control.amount_actual, 100.0)