Skip to content

Commit 4a00957

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 4a00957

3 files changed

Lines changed: 202 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: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
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+
from odoo.exceptions import UserError
23+
from odoo.models import Command
24+
from odoo.tests import tagged
25+
26+
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
27+
from odoo.addons.operating_unit.tests.common import OperatingUnitCommon
28+
29+
30+
@tagged("post_install", "-at_install")
31+
class TestCreateInvoiceJournalOu(
32+
AccountTestInvoicingCommon, OperatingUnitCommon
33+
):
34+
35+
@classmethod
36+
def setUpClass(cls):
37+
super().setUpClass()
38+
39+
(cls.ou1 | cls.b2b | cls.b2c).sudo().write(
40+
{"company_id": cls.company.id}
41+
)
42+
43+
cls.env.user.sudo().write(
44+
{
45+
"groups_id": [
46+
Command.link(
47+
cls.env.ref(
48+
"operating_unit.group_manager_operating_unit"
49+
).id
50+
),
51+
],
52+
"operating_unit_ids": [
53+
Command.link(cls.ou1.id),
54+
Command.link(cls.b2b.id),
55+
],
56+
"default_operating_unit_id": cls.ou1.id,
57+
"company_ids": [Command.link(cls.company.id)],
58+
"company_id": cls.company.id,
59+
}
60+
)
61+
62+
cls.user1.write(
63+
{
64+
"groups_id": [
65+
(
66+
3,
67+
cls.env.ref(
68+
"operating_unit.group_manager_operating_unit"
69+
).id,
70+
),
71+
Command.link(
72+
cls.env.ref(
73+
"operating_unit.group_multi_operating_unit"
74+
).id
75+
),
76+
Command.link(
77+
cls.env.ref("account.group_account_invoice").id
78+
),
79+
Command.link(
80+
cls.env.ref("sales_team.group_sale_salesman").id
81+
),
82+
],
83+
"assigned_operating_unit_ids": [(6, 0, [cls.b2b.id])],
84+
"default_operating_unit_id": cls.b2b.id,
85+
"company_id": cls.company.id,
86+
"company_ids": [Command.link(cls.company.id)],
87+
}
88+
)
89+
90+
Journal = cls.env["account.journal"].sudo()
91+
# OU1 sale journal first → lower id → it would be the
92+
# "default" sale journal Odoo picks when there's no filter.
93+
cls.sale_journal_ou1 = Journal.create(
94+
{
95+
"name": "Sales OU1 (test_invoice_journal)",
96+
"code": "TIJS1",
97+
"type": "sale",
98+
"company_id": cls.company.id,
99+
"operating_unit_id": cls.ou1.id,
100+
}
101+
)
102+
cls.sale_journal_b2b = Journal.create(
103+
{
104+
"name": "Sales B2B (test_invoice_journal)",
105+
"code": "TIJSB",
106+
"type": "sale",
107+
"company_id": cls.company.id,
108+
"operating_unit_id": cls.b2b.id,
109+
}
110+
)
111+
112+
cls.team_b2b = cls.env["crm.team"].create(
113+
{
114+
"name": "B2B Team (test_invoice_journal)",
115+
"company_id": cls.company.id,
116+
"operating_unit_id": cls.b2b.id,
117+
"user_id": cls.user1.id,
118+
}
119+
)
120+
cls.env["crm.team.member"].create(
121+
{
122+
"user_id": cls.user1.id,
123+
"crm_team_id": cls.team_b2b.id,
124+
}
125+
)
126+
cls.user1.invalidate_recordset(["sale_team_id"])
127+
128+
cls.product = cls.env["product.product"].create(
129+
{
130+
"name": "Test Product (test_invoice_journal)",
131+
"list_price": 100.0,
132+
"type": "consu",
133+
}
134+
)
135+
136+
def test_create_invoice_from_b2b_sale_order_uses_b2b_journal(self):
137+
env_user1 = self.env(user=self.user1)
138+
order = env_user1["sale.order"].create(
139+
{
140+
"partner_id": self.partner1.id,
141+
"team_id": self.team_b2b.id,
142+
"operating_unit_id": self.b2b.id,
143+
"order_line": [
144+
(
145+
0,
146+
0,
147+
{
148+
"product_id": self.product.id,
149+
"product_uom_qty": 1,
150+
"price_unit": 100.0,
151+
},
152+
)
153+
],
154+
}
155+
)
156+
self.assertEqual(order.operating_unit_id, self.b2b)
157+
158+
order.action_confirm()
159+
for line in order.order_line:
160+
line.qty_delivered = line.product_uom_qty
161+
162+
try:
163+
invoices = order._create_invoices()
164+
except UserError as e:
165+
self.fail(
166+
"Creating invoice from B2B sale order raised "
167+
"UserError: %s" % e
168+
)
169+
self.assertEqual(len(invoices), 1)
170+
invoice = invoices[0]
171+
self.assertEqual(invoice.operating_unit_id, self.b2b)
172+
self.assertEqual(
173+
invoice.journal_id.operating_unit_id,
174+
self.b2b,
175+
"Invoice journal must belong to B2B (the order's OU).",
176+
)

0 commit comments

Comments
 (0)