Skip to content

Commit 1c034e9

Browse files
committed
[FIX] sale_operating_unit: strict journal selection for OU sale orders
_compute_journal_id used to search with a domain that mixes operating_unit_id=<so.ou> and operating_unit_id=False in a single OR clause and then took limit=1 without an explicit order. When the database returned a no-OU journal first, the SO's journal_id was silently set to it, and the invoice created from that SO inherited the wrong journal, breaking account_operating_unit's _check_journal_operating_unit downstream. This change performs two separate searches: first prefer journals whose operating_unit_id exactly matches the SO's OU; only fall back to a no-OU journal if no matching one exists. The no-OU fallback is preserved for deployments that legitimately use a shared journal across OUs. Includes a TDD regression test that creates a B2B sale order in a multi-OU company where an OU1 sale journal would otherwise be picked first, and asserts that the resulting invoice journal belongs to B2B.
1 parent 8a6ec74 commit 1c034e9

3 files changed

Lines changed: 185 additions & 14 deletions

File tree

sale_operating_unit/models/sale_order.py

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -39,28 +39,39 @@ def _compute_team_id(self):
3939
@api.depends("operating_unit_id")
4040
def _compute_journal_id(self):
4141
res = super()._compute_journal_id()
42+
Journal = self.env["account.journal"]
4243
for sale in self:
4344
if not sale.journal_id or (
4445
sale.journal_id
4546
and sale.operating_unit_id
4647
and sale.journal_id.operating_unit_id != sale.operating_unit_id
4748
):
48-
sale.journal_id = (
49-
self.env["account.journal"]
50-
.search(
51-
[
52-
"|",
53-
("operating_unit_id", "=", sale.operating_unit_id.id),
54-
("operating_unit_id", "=", False),
55-
"|",
56-
("company_id", "=", sale.company_id.id),
57-
("company_id", "=", False),
58-
("type", "=", "sale"),
59-
],
49+
base_domain = [
50+
("type", "=", "sale"),
51+
"|",
52+
("company_id", "=", sale.company_id.id),
53+
("company_id", "=", False),
54+
]
55+
# Strict: prefer a journal whose OU matches. Only fall
56+
# back to a no-OU journal if no matching one exists.
57+
# The previous OR-based domain mixed both and could
58+
# pick a no-OU journal even when a matching one
59+
# existed, which then trips
60+
# account_operating_unit._check_journal_operating_unit
61+
# downstream when the invoice is created.
62+
journal = Journal
63+
if sale.operating_unit_id:
64+
journal = Journal.search(
65+
base_domain
66+
+ [("operating_unit_id", "=", sale.operating_unit_id.id)],
6067
limit=1,
6168
)
62-
.id
63-
)
69+
if not journal:
70+
journal = Journal.search(
71+
base_domain + [("operating_unit_id", "=", False)],
72+
limit=1,
73+
)
74+
sale.journal_id = journal.id
6475
return res
6576

6677
@api.constrains("team_id", "operating_unit_id")
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html).
22

3+
from . import test_create_invoice_journal_ou
34
from . import test_sale_operating_unit
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
# © 2026 BITVAX
2+
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html).
3+
"""Bug: when a sale order with an Operating Unit is invoiced, the
4+
resulting invoice can land on a journal that does not belong to the
5+
order's OU.
6+
7+
Root cause: ``sale_operating_unit._compute_journal_id`` searches for
8+
the journal with a domain that mixes ``operating_unit_id = <ou>`` and
9+
``operating_unit_id = False`` in the same OR clause, then takes
10+
``limit=1`` without an explicit order. If the database happens to
11+
return a no-OU journal first, the SO's ``journal_id`` is set to it,
12+
the invoice is created with that journal, and
13+
``account_operating_unit._check_journal_operating_unit`` either
14+
raises (when the no-OU journal actually has a different OU) or — more
15+
subtly — silently produces an invoice whose journal does not match
16+
the move's OU.
17+
18+
The fix is to perform two searches: first prefer journals matching
19+
the SO's OU; only fall back to a no-OU journal if no matching one
20+
exists.
21+
"""
22+
23+
from odoo.exceptions import UserError
24+
from odoo.models import Command
25+
from odoo.tests import tagged
26+
27+
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
28+
from odoo.addons.operating_unit.tests.common import OperatingUnitCommon
29+
30+
31+
@tagged("post_install", "-at_install")
32+
class TestCreateInvoiceJournalOu(AccountTestInvoicingCommon, OperatingUnitCommon):
33+
@classmethod
34+
def setUpClass(cls):
35+
super().setUpClass()
36+
37+
(cls.ou1 | cls.b2b | cls.b2c).sudo().write({"company_id": cls.company.id})
38+
39+
cls.env.user.sudo().write(
40+
{
41+
"groups_id": [
42+
Command.link(
43+
cls.env.ref("operating_unit.group_manager_operating_unit").id
44+
),
45+
],
46+
"operating_unit_ids": [
47+
Command.link(cls.ou1.id),
48+
Command.link(cls.b2b.id),
49+
],
50+
"default_operating_unit_id": cls.ou1.id,
51+
"company_ids": [Command.link(cls.company.id)],
52+
"company_id": cls.company.id,
53+
}
54+
)
55+
56+
cls.user1.write(
57+
{
58+
"groups_id": [
59+
(
60+
3,
61+
cls.env.ref("operating_unit.group_manager_operating_unit").id,
62+
),
63+
Command.link(
64+
cls.env.ref("operating_unit.group_multi_operating_unit").id
65+
),
66+
Command.link(cls.env.ref("account.group_account_invoice").id),
67+
Command.link(cls.env.ref("sales_team.group_sale_salesman").id),
68+
],
69+
"assigned_operating_unit_ids": [(6, 0, [cls.b2b.id])],
70+
"default_operating_unit_id": cls.b2b.id,
71+
"company_id": cls.company.id,
72+
"company_ids": [Command.link(cls.company.id)],
73+
}
74+
)
75+
76+
Journal = cls.env["account.journal"].sudo()
77+
# OU1 sale journal first → lower id → it would be the
78+
# "default" sale journal Odoo picks when there's no filter.
79+
cls.sale_journal_ou1 = Journal.create(
80+
{
81+
"name": "Sales OU1 (test_invoice_journal)",
82+
"code": "TIJS1",
83+
"type": "sale",
84+
"company_id": cls.company.id,
85+
"operating_unit_id": cls.ou1.id,
86+
}
87+
)
88+
cls.sale_journal_b2b = Journal.create(
89+
{
90+
"name": "Sales B2B (test_invoice_journal)",
91+
"code": "TIJSB",
92+
"type": "sale",
93+
"company_id": cls.company.id,
94+
"operating_unit_id": cls.b2b.id,
95+
}
96+
)
97+
98+
cls.team_b2b = cls.env["crm.team"].create(
99+
{
100+
"name": "B2B Team (test_invoice_journal)",
101+
"company_id": cls.company.id,
102+
"operating_unit_id": cls.b2b.id,
103+
"user_id": cls.user1.id,
104+
}
105+
)
106+
cls.env["crm.team.member"].create(
107+
{
108+
"user_id": cls.user1.id,
109+
"crm_team_id": cls.team_b2b.id,
110+
}
111+
)
112+
cls.user1.invalidate_recordset(["sale_team_id"])
113+
114+
cls.product = cls.env["product.product"].create(
115+
{
116+
"name": "Test Product (test_invoice_journal)",
117+
"list_price": 100.0,
118+
"type": "consu",
119+
}
120+
)
121+
122+
def test_create_invoice_from_b2b_sale_order_uses_b2b_journal(self):
123+
env_user1 = self.env(user=self.user1)
124+
order = env_user1["sale.order"].create(
125+
{
126+
"partner_id": self.partner1.id,
127+
"team_id": self.team_b2b.id,
128+
"operating_unit_id": self.b2b.id,
129+
"order_line": [
130+
(
131+
0,
132+
0,
133+
{
134+
"product_id": self.product.id,
135+
"product_uom_qty": 1,
136+
"price_unit": 100.0,
137+
},
138+
)
139+
],
140+
}
141+
)
142+
self.assertEqual(order.operating_unit_id, self.b2b)
143+
144+
order.action_confirm()
145+
for line in order.order_line:
146+
line.qty_delivered = line.product_uom_qty
147+
148+
try:
149+
invoices = order._create_invoices()
150+
except UserError as e:
151+
self.fail(f"Creating invoice from B2B sale order raised UserError: {e}")
152+
self.assertEqual(len(invoices), 1)
153+
invoice = invoices[0]
154+
self.assertEqual(invoice.operating_unit_id, self.b2b)
155+
self.assertEqual(
156+
invoice.journal_id.operating_unit_id,
157+
self.b2b,
158+
"Invoice journal must belong to B2B (the order's OU).",
159+
)

0 commit comments

Comments
 (0)