Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 3 additions & 8 deletions budget_control_purchase_stock/models/account_move.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,15 @@ 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"):
move._compute_not_affect_budget_from_po()
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()
Expand All @@ -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
Expand Down
97 changes: 97 additions & 0 deletions budget_control_purchase_stock/tests/test_budget_purchase_stock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
25 changes: 24 additions & 1 deletion budget_control_stock/models/account_move.py
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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
67 changes: 67 additions & 0 deletions budget_control_stock/tests/test_budget_stock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading