Skip to content

Commit 3215c7f

Browse files
maq-adhocrov-adhoc
authored andcommitted
[ADD] stock_currency_valuation: secondary currency on the v19 valuation flow
Extends the valuation flow ``stock_account_ux`` introduces —value adjustments, manual valuation of selected moves, periodic closing— so a company valuing its stock in a second currency sees and books that valuation too. Declares the dependency on that module: it is ``auto_install`` so it was always there, but without the declaration Odoo guarantees no load order, and an override whose ``super()`` is not yet there fails. ``product.value`` gains ``previous_value_in_currency`` and ``delta_in_currency``, twins of the pair kept in company currency, with the same semantics: the previous value is captured when the adjustment is created and never recomputed, because by then the adjustment has already been applied to the move and to the product's cost. They show on the list, the search and the form next to their counterparts. Two bugs of this module surfaced on the way. The ``create`` filled a missing ``value`` with the product's ``standard_price``, which on an adjustment recorded ON A MOVE is wrong by a whole dimension —there ``value`` is the move's TOTAL— so adjusting only the secondary amount dropped a receipt of 4 units at 25 from 100 to 25. And the secondary amount never reached the move at all: nothing overrode the core's ``_get_manual_value``, so the "New Value in Currency" field of the "Adjust Valuation" dialog wrote a record no computation read. It now has its mirror, with priority over the AVCO and rate branches, returning ``None`` rather than ``0`` for "no adjustment" so an explicit correction to zero is honoured. With that in place an adjustment that moved only the secondary amount does change what the move is worth, so it counts as a revaluation and leaves the Stock Moves component — the criterion agreed for the valuation flow (task 64440, clarification Q2), reached by overriding ``_is_revaluation``. While the amount did not reach the move, that override would have reclassified something that moved nothing. Both valuation entries carry ``currency_id`` and ``amount_currency`` and add up to zero PER currency. The manual wizard accumulates the secondary balance under the same key as the company one and shows it on the draft, with the wizard-level total set only when every line shares a currency — a draft can gather products valued in different ones, and then a single total states nothing. The periodic closing takes the variation as inventory value minus what is booked, through the twins ``stock_value_in_currency`` and ``stock_accounting_value_in_currency``, and prorates it when the entry is split per product. Two limits, deliberate: an account gathering products valued in DIFFERENT secondary currencies is left in company currency, since a journal item carries one currency and picking one would be wrong; and the location-reclassification ``extra_balance`` is not netted from the secondary variation, having no twin in that currency. The second is an accounting call rather than a technical one. The column is not retroactive: entries posted before this carry no secondary amount and cannot be rebuilt. Verified: 49 tests green on a fresh database, each behaviour change checked in both directions. Four of these tests passed without proving anything until the reverted run exposed them — a move adjustment where the recomputed average happens to equal the manual value, a multi-company read taken on a recordset that still carried the other company, and a per-product split over a product with no cost, where there is nothing to prorate. Balance is asserted per currency: summing ``amount_currency`` across currencies comes out at zero even when one of them is wrong. Inert without a secondary currency, which is the majority scenario: ``stock_account_ux`` is ``auto_install`` and runs in plenty of databases where nobody values stock in a second currency. With no category using one, the two twins return nothing, the closing vals are handed back untouched, both entries stay whole in company currency with ``amount_currency`` equal to the balance, the draft offers no secondary total, and the ``_is_revaluation`` override answers exactly what the base criterion answers. Of those five tests only one is independently discriminating —breaking the twin makes it fail— and the other four are absence assertions: a product with no secondary currency has ``total_value_in_currency`` at zero, so even a broken twin leaves the balance at zero and the annotation never fires. Their value comes from being paired with the presence tests in the closing and wizard classes, which prove the machinery DOES fire when a currency is configured. Together they bracket the behaviour; on their own they do not. Worth recording, because it corrects what the code looks like it does: inertness is guaranteed by the twins discarding products with no currency, NOT by the currency filter in ``_get_valuation_currency_by_account``. Simplifying those twins in a later refactor on the assumption that the filter covers it would break the majority case silently. The limitations are documented in FUNCIONALIDADES.md, this module's equivalent of a README, including one the criterion did not ask for and this merge needs: the data is stored but the valuation report does not show the secondary currency yet. The fixture clears the valuation currency of every OTHER category. This module's own demo data puts one on a demo category, and that category shares the company's default valuation account with the fixture's one; an account gathering two secondary currencies is deliberately left in company currency, so on a database WITH demo data —the runbot build, among others— the closing carried no secondary amount and three tests measured nothing. Verified both ways on a fresh database with demo: 3 failed without this, 0 failed of 54 with it. A valuation account is only stated in a secondary currency when EVERY product on it is valued in that same one. Products with no secondary currency disqualify it just like two different currencies do, and that is the frequent case, not the exotic one: one category valued in dollars and the whole rest of the catalogue, with no currency, on the default valuation account. The closing line is split per product and the secondary amount was shared among all of them, so products valued in no second currency took a slice that was not theirs and the one that is kept a fraction of its own value — measured on a database with demo data, of 100 belonging to a single product that product kept 14,49 and the rest went to a dozen furniture products. Practical consequence, documented in FUNCIONALIDADES.md: a category valued in a secondary currency needs its own valuation account, which is what the test fixture now sets up. The shares are also rounded as a whole through the new ``_balance_valuation_extra_vals`` seam, with the leftover landing on the largest one. Rounding each share on its own left the entry off by a cent in the secondary currency —three products sharing 100 take 33,33 each— and it posted anyway, because in company currency it balances. closes #1004 Related: ingadhoc/account-financial-tools#984 Related: ingadhoc/product#937 Related: ingadhoc/miscellaneous#436 Signed-off-by: Camila Vives <cav@adhoc.inc>
1 parent e711280 commit 3215c7f

24 files changed

Lines changed: 1459 additions & 12 deletions

stock_currency_valuation/FUNCIONALIDADES.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,56 @@ Archivo:
137137
Archivos:
138138
- `models/stock_landed_cost.py`
139139
- `views/stock_landed_cost_views.xml`
140+
141+
## Limitaciones y caveats
142+
143+
Cosas que el módulo **no** hace, o hace de una forma que conviene saber antes de
144+
reportarlas como error.
145+
146+
### La valuación en moneda secundaria de la contabilidad aplica sólo hacia adelante
147+
148+
El balance inicial y la variación en moneda secundaria salen del `amount_currency` de
149+
los apuntes de valuación. Los asientos posteados **antes** de esta versión no lo tienen,
150+
y no se puede reconstruir: exigiría revaluar asientos ya contabilizados a una cotización
151+
que nadie registró. Así que el histórico queda sin importe en moneda secundaria, y se
152+
muestra así en vez de estimarlo.
153+
154+
### El reporte de valuación todavía no muestra la moneda secundaria
155+
156+
Los datos ya se guardan —los asientos del cierre y del wizard llevan `amount_currency`,
157+
y `product.value` guarda el valor previo y el delta en moneda—, pero el reporte de
158+
valuación de inventario sigue mostrando sólo la moneda de compañía. Las tres secciones en
159+
moneda secundaria y el filtro por moneda de valuación quedan para una segunda etapa.
160+
161+
### La cuenta de valuación tiene que ser de una sola moneda
162+
163+
Un apunte contable lleva **una** moneda. Si una misma cuenta de valuación junta productos
164+
valuados en monedas secundarias distintas, no hay forma de expresar las dos en la línea
165+
del cierre, así que esa línea queda en moneda de compañía en lugar de elegir una. Mismo
166+
criterio en el borrador del wizard: el total en moneda aparece sólo si todas las líneas
167+
coinciden.
168+
169+
Los productos **sin** moneda secundaria descalifican la cuenta igual, y ese es el caso
170+
frecuente: una categoría valuada en dólares y todo el resto del catálogo, sin moneda, en
171+
la cuenta de valuación por defecto. La línea del cierre se parte en una línea por producto
172+
y el importe en moneda se reparte entre **todas**, así que los productos que no se valúan
173+
en esa moneda se llevan una parte que no les corresponde y el que sí queda con una
174+
fracción de su propio valor (medido sobre una base con demo: de 100 en moneda secundaria
175+
de un solo producto, ese producto se quedaba con 14,49 y el resto se repartía entre una
176+
docena de productos de mobiliario).
177+
178+
**Implicancia práctica:** una categoría valuada en moneda secundaria necesita su **propia
179+
cuenta de valuación**. Mientras comparta la cuenta con categorías sin moneda, el cierre y
180+
el wizard trabajan esa cuenta en moneda de compañía — no se pierde información contable,
181+
pero la columna en moneda secundaria queda vacía para esa cuenta.
182+
183+
### Las reclasificaciones de ubicación no se netean del lado en moneda
184+
185+
El `extra_balance` que el cierre netea por reclasificaciones de ubicación es hoy un
186+
concepto sólo en moneda de compañía. La variación en moneda secundaria se calcula como
187+
valor de inventario menos lo contabilizado, sin ese neteo.
188+
189+
### Cambiar la moneda de valuación de una categoría no es un flujo soportado
190+
191+
Se define una vez al implementar la categoría. Los `product.value` históricos **no** se
192+
recalculan si cambia: cada uno queda pineado a la moneda vigente cuando se registró.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
from . import models, report
1+
from . import models, report, wizard

stock_currency_valuation/__manifest__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"images": [],
1010
"depends": [
1111
"stock_account",
12+
"stock_account_ux",
1213
"stock_landed_costs",
1314
"product_replenishment_cost",
1415
],
@@ -21,6 +22,7 @@
2122
"views/product.xml",
2223
"views/stock_move_views.xml",
2324
"views/product_value_views.xml",
25+
"views/stock_move_valuation_views.xml",
2426
],
2527
"demo": [
2628
"demo/stock_currency_valuation_demo.xml",

stock_currency_valuation/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@
66
from . import product_value
77
from . import stock_quant
88
from . import stock_landed_cost
9+
from . import res_company

stock_currency_valuation/models/product_value.py

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,30 @@ class ProductValue(models.Model):
66

77
valuation_currency_id = fields.Many2one("res.currency", compute="_compute_valuation_currency_id", store=True)
88
value_in_currency = fields.Monetary(string="Value in Currency", currency_field="valuation_currency_id")
9+
previous_value_in_currency = fields.Monetary(
10+
string="Previous Value in Currency",
11+
currency_field="valuation_currency_id",
12+
readonly=True,
13+
copy=False,
14+
help="Value in the secondary currency in force right before this adjustment. "
15+
"Captured when it is recorded, because afterwards it can no longer be rebuilt.",
16+
)
17+
delta_in_currency = fields.Monetary(
18+
string="Delta in Currency",
19+
currency_field="valuation_currency_id",
20+
compute="_compute_delta_in_currency",
21+
help="Variation this adjustment introduced in the secondary currency, in the SAME "
22+
"unit as the Value in Currency field: the move total when the adjustment is on a "
23+
"move, the unit price when it is a product or lot price change.",
24+
)
25+
26+
@api.depends("value_in_currency", "previous_value_in_currency")
27+
def _compute_delta_in_currency(self):
28+
"""Twin of ``delta`` in the secondary currency. Same reason for keeping a captured
29+
``previous_value_in_currency`` instead of deriving it: once the record is saved the
30+
adjustment has already been applied, so there is nothing left to subtract from."""
31+
for product_value in self:
32+
product_value.delta_in_currency = product_value.value_in_currency - product_value.previous_value_in_currency
933

1034
@api.depends("company_id", "move_id", "lot_id", "product_id")
1135
def _compute_valuation_currency_id(self):
@@ -29,6 +53,7 @@ def create(self, vals_list):
2953
for vals in vals_list:
3054
# Obtener el producto según el contexto
3155
product = None
56+
move = self.env["stock.move"]
3257
if vals.get("product_id"):
3358
product = self.env["product.product"].browse(vals["product_id"])
3459
elif vals.get("lot_id"):
@@ -52,14 +77,46 @@ def create(self, vals_list):
5277
company = self.env["res.company"].browse(company_id)
5378
product_with_company = product.with_company(company)
5479

55-
# Si no está definido value, usar standard_price del producto.
80+
# Si no está definido value, tomar el default que corresponde al TIPO de
81+
# ajuste. Sobre un movimiento, ``value`` es el valor TOTAL del movimiento y
82+
# no un precio unitario (ver el docstring del modelo en stock_account, y
83+
# ``stock.move._get_manual_value``, que lo escribe derecho en
84+
# ``move.value``): defaultearlo al standard_price ponía un precio unitario
85+
# donde va un total y dejaba el movimiento valuado al costo de una unidad.
86+
# Sólo un cambio de precio de producto o lote defaultea al standard_price.
87+
#
5688
# Ojo: chequear sólo ausencia de la clave, no falsy — un 0 explícito
5789
# (p.ej. una corrección manual a cero vía value_manual) es un valor
58-
# válido y no debe pisarse con el standard_price vigente.
90+
# válido y no debe pisarse.
5991
if "value" not in vals:
60-
vals["value"] = product_with_company.standard_price
92+
vals["value"] = move.value if move else product_with_company.standard_price
6193

62-
# Idem para value_in_currency, si el producto tiene moneda de valuación.
94+
# Idem para value_in_currency, con el mismo criterio por tipo de ajuste.
6395
if "value_in_currency" not in vals and product_with_company.valuation_currency_id:
64-
vals["value_in_currency"] = product_with_company.standard_price_in_currency
96+
vals["value_in_currency"] = (
97+
move.value_in_currency if move else product_with_company.standard_price_in_currency
98+
)
99+
# Igual que el previous_value en moneda de compañía: se resuelve ANTES de delegar,
100+
# porque el create del core dispara _set_value() / _update_standard_price() y deja
101+
# el valor nuevo tanto en el movimiento como en el producto.
102+
vals_list = [
103+
vals
104+
if "previous_value_in_currency" in vals
105+
else dict(vals, previous_value_in_currency=self._get_previous_value_in_currency(vals))
106+
for vals in vals_list
107+
]
65108
return super().create(vals_list)
109+
110+
@api.model
111+
def _get_previous_value_in_currency(self, vals):
112+
"""Secondary-currency twin of ``product.value._get_previous_value``.
113+
114+
For a price change it reads the amount off the RECORD that
115+
``_get_previous_product_value`` returns, which is what that seam exists for (task
116+
58212): the company scope and the date bound of that search are not repeated here.
117+
For an adjustment on a move the previous value is the move's own, as there is no
118+
earlier adjustment to read it off.
119+
"""
120+
if vals.get("move_id"):
121+
return self.env["stock.move"].browse(vals["move_id"]).value_in_currency
122+
return self._get_previous_product_value(vals).value_in_currency
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
from collections import defaultdict
2+
3+
from odoo import models
4+
from odoo.fields import Domain
5+
6+
7+
class ResCompany(models.Model):
8+
_inherit = "res.company"
9+
10+
def stock_value_in_currency(self, accounts_by_product=None, at_date=None):
11+
"""Inventory value per ``(valuation account, secondary currency)``.
12+
13+
Twin of ``stock_value``, which answers the same in company currency. Keyed by
14+
currency as well because one valuation account can gather products valued in
15+
different secondary currencies; products without one contribute nothing.
16+
"""
17+
self.ensure_one()
18+
value_by_account = defaultdict(float)
19+
if not accounts_by_product:
20+
accounts_by_product = self.with_context(prefetch_fields=False)._get_accounts_by_product()
21+
for product, accounts in accounts_by_product.items():
22+
scoped = product.with_company(self)
23+
currency = scoped.valuation_currency_id
24+
if not currency:
25+
continue
26+
value_by_account[accounts["valuation"], currency] += scoped.with_context(
27+
to_date=at_date
28+
).total_value_in_currency
29+
return value_by_account
30+
31+
def stock_accounting_value_in_currency(self, accounts_by_product=None, at_date=None):
32+
"""Booked value per ``(valuation account, secondary currency)``, off the
33+
``amount_currency`` of the posted journal items.
34+
35+
Twin of ``stock_accounting_value``. It only sees what was booked WITH a secondary
36+
amount, so entries posted before this module did carry none and contribute zero —
37+
the column is not retroactive, and that is documented rather than estimated.
38+
39+
Lines expressed in the company currency are left out on purpose: their
40+
``amount_currency`` mirrors the balance and adding it would double the company
41+
figure into a secondary total.
42+
"""
43+
self.ensure_one()
44+
if not accounts_by_product:
45+
accounts_by_product = self._get_accounts_by_product()
46+
account_data = defaultdict(float)
47+
currencies = self.env["res.currency"]
48+
accounts = self.env["account.account"]
49+
for product, product_accounts in accounts_by_product.items():
50+
currency = product.with_company(self).valuation_currency_id
51+
if not currency:
52+
continue
53+
currencies |= currency
54+
accounts |= product_accounts["valuation"]
55+
if not (currencies and accounts):
56+
return account_data
57+
domain = Domain(
58+
[
59+
("account_id", "in", accounts.ids),
60+
("company_id", "=", self.id),
61+
("parent_state", "=", "posted"),
62+
("currency_id", "in", currencies.ids),
63+
]
64+
)
65+
if at_date:
66+
domain &= Domain([("date", "<=", at_date)])
67+
grouped = self.env["account.move.line"]._read_group(
68+
domain, ["account_id", "currency_id"], ["amount_currency:sum"]
69+
)
70+
for account, currency, amount in grouped:
71+
account_data[account, currency] += amount
72+
return account_data
73+
74+
def _get_valuation_currency_by_account(self, accounts_by_product):
75+
"""The single secondary currency of each valuation account, or nothing.
76+
77+
A journal item carries ONE currency, so an account gathering products valued in
78+
different secondary currencies cannot state them all. Rather than pick one and be
79+
wrong, such an account is left out and its closing line stays in company currency
80+
— the same "one or none" rule the valuation wizard applies to its draft.
81+
82+
Products with NO secondary currency disqualify the account just the same, and that
83+
is the common case rather than the exotic one: a category is valued in a second
84+
currency while the rest of the catalogue, on the same default valuation account,
85+
is not. The closing line of an account is split into one line per product and the
86+
secondary amount is shared out across ALL of them, so those products take a slice
87+
of an amount that is not theirs and the one actually valued in that currency is
88+
left with a fraction of its own value. Measured on a database with demo data: of
89+
100 in secondary currency belonging to a single product, that product kept 14,49
90+
and the rest went to a dozen furniture products valued in no second currency at
91+
all. An account that mixes them cannot be stated in one currency, so it stays in
92+
company currency until it holds only products valued in the same one — in
93+
practice, giving the category its own valuation account.
94+
"""
95+
currencies_by_account = defaultdict(lambda: self.env["res.currency"])
96+
mixed_accounts = self.env["account.account"]
97+
for product, accounts in accounts_by_product.items():
98+
currency = product.with_company(self).valuation_currency_id
99+
if currency:
100+
currencies_by_account[accounts["valuation"]] |= currency
101+
else:
102+
mixed_accounts |= accounts["valuation"]
103+
return {
104+
account: currencies
105+
for account, currencies in currencies_by_account.items()
106+
if len(currencies) == 1 and account not in mixed_accounts
107+
}
108+
109+
def _annotate_valuation_vals(self, vals_list, accounts_by_product, at_date=None):
110+
"""Put the secondary amount on the closing vals, before they are split per product.
111+
112+
The variation being booked is inventory value minus what is already booked, the
113+
same shape ``_get_stock_valuation_account_vals`` uses in company currency. The
114+
location-reclassification ``extra_balance`` is NOT netted here: it is a
115+
company-currency notion today, with no secondary twin, so netting it would mix
116+
units.
117+
118+
Each pair of vals carries the amount with the sign of its own leg, so the entry
119+
adds up to zero in the secondary currency too. ``_get_valuation_val_extra_vals``
120+
prorates it afterwards when the line is split per product.
121+
"""
122+
vals_list = super()._annotate_valuation_vals(vals_list, accounts_by_product, at_date=at_date)
123+
if not vals_list:
124+
return vals_list
125+
currency_by_account = self._get_valuation_currency_by_account(accounts_by_product)
126+
if not currency_by_account:
127+
return vals_list
128+
inventory = self.stock_value_in_currency(accounts_by_product, at_date)
129+
booked = self.stock_accounting_value_in_currency(accounts_by_product, at_date)
130+
Account = self.env["account.account"]
131+
# Walked TWO BY TWO: the vals come as pairs —valuation leg plus counterpart, as
132+
# ``_prepare_inventory_aml_vals`` returns them— and BOTH have to be annotated.
133+
# Annotating only the valuation leg moves it to the secondary currency and leaves
134+
# its counterpart alone in the company one, so neither group adds up to zero.
135+
for first, second in zip(vals_list[0::2], vals_list[1::2]):
136+
legs = {}
137+
for vals in (first, second):
138+
account = Account.browse(vals["account_id"])
139+
if account in currency_by_account:
140+
legs[account] = vals
141+
# A pair that cannot be told apart is left alone rather than annotated wrongly:
142+
# an odd tail, or both legs being valuation accounts (one account's counterpart
143+
# is another product's valuation account).
144+
if len(legs) != 1:
145+
continue
146+
account = next(iter(legs))
147+
currency = currency_by_account[account]
148+
balance_in_currency = inventory.get((account, currency), 0.0) - booked.get((account, currency), 0.0)
149+
if currency.is_zero(balance_in_currency):
150+
continue
151+
amount = abs(balance_in_currency)
152+
for vals in (first, second):
153+
# Each leg carries the amount with the sign of its own balance, so the pair
154+
# nets to zero in the secondary currency exactly as it does in the company
155+
# one.
156+
vals["currency_id"] = currency.id
157+
vals["amount_currency"] = amount if vals["debit"] else -amount
158+
return vals_list
159+
160+
def _get_valuation_val_extra_vals(self, vals, balance, net):
161+
"""Prorate the secondary amount with the SAME denominator as the balance.
162+
163+
Only ``debit`` / ``credit`` are re-split by the base, and every other key is copied
164+
verbatim onto every product line — right for the account or the label, wrong for an
165+
amount: N lines would each carry the full secondary amount, the entry would still
166+
add up in company currency, and would not in the other one.
167+
168+
Left unrounded on purpose: ``_balance_valuation_extra_vals`` rounds the whole split
169+
at once, which is the only place the cent lost between the shares can be seen.
170+
"""
171+
res = super()._get_valuation_val_extra_vals(vals, balance, net)
172+
if vals.get("amount_currency") and net:
173+
res["amount_currency"] = vals["amount_currency"] * balance / net
174+
return res
175+
176+
def _balance_valuation_extra_vals(self, vals, product_vals):
177+
"""Round every share and give the leftover to the largest one, so the split adds up
178+
to the secondary amount of the line it came from, to the cent.
179+
180+
Rounding each share on its own leaves the entry off by a cent in the secondary
181+
currency —three products sharing 100 take 33,33 each and 0,01 goes missing— and
182+
nothing downstream catches it: the entry balances in company currency, so it posts.
183+
The leftover goes to the largest share because that is where it is worth least in
184+
relative terms.
185+
"""
186+
product_vals = super()._balance_valuation_extra_vals(vals, product_vals)
187+
amount_currency = vals.get("amount_currency")
188+
currency = self.env["res.currency"].browse(vals.get("currency_id"))
189+
if not (amount_currency and currency):
190+
return product_vals
191+
shares = [share for share in product_vals if share.get("amount_currency")]
192+
if not shares:
193+
return product_vals
194+
for share in shares:
195+
share["amount_currency"] = currency.round(share["amount_currency"])
196+
leftover = currency.round(amount_currency - sum(share["amount_currency"] for share in shares))
197+
if not currency.is_zero(leftover):
198+
largest = max(shares, key=lambda share: abs(share["amount_currency"]))
199+
largest["amount_currency"] = currency.round(largest["amount_currency"] + leftover)
200+
return product_vals

0 commit comments

Comments
 (0)