Skip to content

Commit f67a368

Browse files
maq-adhocrov-adhoc
authored andcommitted
[FIX] stock_currency_valuation: drop the dead v18 code and fix the AVCO report
Housekeeping found while reviewing this module against the v19 valuation flow. None of it depends on that work. Dead v18 block removed: ``wizard/stock_valuation_layer_revaluation.py``, its view, ``wizard/__init__.py`` and ``views/stock_valuation_layer.xml``. All four hang from ``stock.valuation.layer``, a model Odoo removed in v19, and none was reachable — the ``wizard`` package was never imported and neither XML was declared in the manifest. That double disconnection is why the module still installed. The revaluation wizard was the v18 home of the secondary-currency journal entry, so leaving it in place made it read as current code instead of a leftover. ``_set_value`` filled a ``lots_to_recompute`` set on every lot-valuated incoming move whose only consumer was a commented-out line, so the work was done for nothing on every valuation. Bug — the AVCO audit report lost the core's UoM conversion. This module's ``stock_avco_report`` view is a copy of the standard one with the secondary-currency columns added, and the copy had dropped ``sm.quantity * (um.factor / up.factor)`` along with the two ``uom_uom`` joins it needs. So quantities were added up in each move's own unit instead of the product's reference one: a receipt of 1 dozen reported 1 instead of 12. And since the report derives the unit cost as ``total_value / total_quantity``, the AVCO came out twelve times too expensive. It only shows when the move's unit differs from the product's, which is why it went unnoticed. The view is now byte-identical to the standard one except for the two ``value_in_currency`` columns, and both copied methods carry a maintenance note saying they cannot call ``super()`` so drift is silent — the view builds fine and the numbers come out wrong. Docs and tests: FUNCIONALIDADES.md listed as "implemented but not active" four things of which two were the v18 code above and two are ACTIVE, so landed costs in secondary currency gets the section it never had. TESTING.md documented two test files that do not exist. And ``test_replenishment_cost_average_in_currency.py`` was on disk but imported by no one, so the only coverage of ``replenishment_cost_type = 'average_in_currency'`` never ran. Verified: 18 tests green on a fresh database, and reverting only the UoM fix makes the two new tests fail with 1.0 != 12.0. Part-of: #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 727167f commit f67a368

12 files changed

Lines changed: 219 additions & 338 deletions

stock_currency_valuation/FUNCIONALIDADES.md

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@ code# Stock Currency Valuation - Referencia funcional
99
- `stock.picking`: cotización manual para ingresos de compra.
1010
- `product.value`: valor histórico en moneda secundaria.
1111
- `stock.quant`: valor secundario por quant.
12+
- `stock.landed.cost` y `stock.valuation.adjustment.lines`: costos de importación en moneda secundaria.
1213
- Vistas activas:
1314
- categoría de producto
1415
- picking
1516
- quants
1617
- producto/template
1718
- movimientos de stock
19+
- histórico de valores (`product.value`)
20+
- landed costs
1821
- Demo activa:
1922
- categoría hija de Furniture con valuación en moneda secundaria
2023
- producto demo en esa categoría
@@ -64,8 +67,9 @@ Archivo:
6467
- `value` desde `standard_price`
6568
- `value_in_currency` desde `standard_price_in_currency` (si aplica)
6669

67-
Archivo:
70+
Archivos:
6871
- `models/product_value.py`
72+
- `views/product_value_views.xml`
6973

7074
### 5) Movimientos de stock con valor secundario
7175

@@ -117,24 +121,19 @@ Archivos:
117121
Archivo:
118122
- `demo/stock_currency_valuation_demo.xml`
119123

120-
## Implementado pero no activo actualmente
121-
122-
Estas piezas existen en el módulo, pero hoy no se cargan porque están comentadas en imports o en el manifest:
124+
### 9) Landed costs en moneda secundaria
123125

124-
- Landed costs en moneda secundaria.
125-
- Vista de `product.value` extendida.
126-
- Vista de `stock.valuation.layer` extendida.
127-
- Wizard de revaluación con campos y asientos en moneda secundaria.
126+
- Extiende `stock.landed.cost` con:
127+
- `valuation_currency_id`
128+
- `currency_rate` / `inverse_currency_rate` (cotización manual del costo)
129+
- Extiende `stock.valuation.adjustment.lines` con:
130+
- `valuation_currency_id`
131+
- `former_cost_in_currency`
132+
- `additional_landed_cost_in_currency`
133+
- `final_cost_in_currency`
134+
- El valor en moneda secundaria del landed cost se descuenta del valor base del
135+
movimiento en `stock.move._set_value` para no contarlo dos veces.
128136

129137
Archivos:
130138
- `models/stock_landed_cost.py`
131139
- `views/stock_landed_cost_views.xml`
132-
- `views/product_value_views.xml`
133-
- `views/stock_valuation_layer.xml`
134-
- `wizard/stock_valuation_layer_revaluation.py`
135-
- `wizard/stock_valuation_layer_revaluation_views.xml`
136-
137-
Puntos de control:
138-
- `__init__.py` (wizard comentado)
139-
- `models/__init__.py` (landed cost comentado)
140-
- `__manifest__.py` (vistas/wizard comentadas)

stock_currency_valuation/TESTING.md

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -232,13 +232,12 @@ Organización propuesta del `tests/` (a crear):
232232
tests/
233233
__init__.py
234234
common.py # setUpClass: ARS/USD, categ, producto, rates
235-
test_purchase_receipt.py # Op 1, casos múltiples
236235
test_landed_cost_currency.py # Op 2 + casos rate distinto
237236
test_delivery_and_return.py # Op 4 + Op 5
238237
test_inventory_adjustment.py # Op 6 + Op 7 (valuación por AVCO en USD)
239238
test_combined_walkthrough.py # walkthrough Op 1→7 completo
240-
test_replenishment_in_currency.py # average_in_currency rule
241-
test_revaluation_product_value.py # standard_price_in_currency write + product.value
239+
test_replenishment_cost_average_in_currency.py # replenishment_cost_type = average_in_currency
240+
test_avco_report_uom.py # cantidad del reporte AVCO en UoM de referencia
242241
```
243242

244243
### `common.py` — fixture base
@@ -271,12 +270,31 @@ Un solo método largo que ejecuta Op 1 a Op 7 en orden, asertando después de ca
271270
- `test_lc_with_rate_different_from_picking` — disparidad esperada/documentada.
272271
- `test_lc_zero_in_currency_when_company_eq_valuation` — categoría con `valuation_currency = company.currency_id`.
273272

274-
### `test_revaluation_product_value.py`
273+
### `test_replenishment_cost_average_in_currency.py`
275274

276-
- `test_write_std_price_in_currency_only_creates_product_value` — actualmente FALLA por la rama dead-code (ver TESTING — caso 2.5). Dejar como `xfail` o marcar TODO.
277-
- `test_write_both_prices_creates_single_product_value`.
278-
- `test_disable_auto_revaluation_skips_product_value`.
279-
- `test_change_with_fifo_skips_product_value` — la guarda `cost_method == "fifo"` en `_change_standard_price`.
275+
- `test_average_in_currency_converts_using_valuation_currency_rate` — con
276+
`replenishment_cost_type = 'average_in_currency'`, el costo de reposición
277+
convierte `standard_price_in_currency` a la moneda del producto a la
278+
cotización del día, y sigue el cambio de cotización entre Día 1 y Día 2.
279+
- `test_average_in_currency_applies_replenishment_cost_rule_on_converted_amount`
280+
la regla de reposición se aplica sobre el importe ya convertido, no sobre el
281+
valor en moneda secundaria.
282+
283+
Regresión que cubre: la conversión usaba `fields.date.today()` (minúscula), que
284+
no existe en `odoo.fields`, así que leer `replenishment_cost` rompía con
285+
`AttributeError`.
286+
287+
### `test_avco_report_uom.py`
288+
289+
- `test_avco_report_quantity_in_product_uom` — una recepción de 1 docena informa
290+
cantidad 12 en el reporte de auditoría AVCO, no 1: la vista tiene que convertir a
291+
la UoM de referencia del producto.
292+
- `test_avco_report_unit_cost_uses_converted_quantity` — el costo unitario del
293+
reporte se calcula sobre la cantidad convertida.
294+
295+
Regresión que cubre: la vista SQL de este módulo es copia de la del core y había
296+
perdido la conversión de UoM, así que el AVCO salía 12 veces más caro en productos
297+
comprados en una unidad distinta a la de stock.
280298

281299
## Datos y precisión
282300

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1 @@
11
from . import models, report
2-
3-
# from . import wizard

stock_currency_valuation/models/stock_move.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ def _set_value(self, correction_quantity=None):
6363
# self.env.company puede no coincidir con move.company_id (batch multi-compañía,
6464
# jobs automatizados). Mezclarlos filtraría/recomputaría con la compañía equivocada.
6565
products_to_recompute_by_company = defaultdict(set)
66-
lots_to_recompute = set()
6766

6867
# sudo: stock.valuation.adjustment.lines sólo es legible por
6968
# stock.group_stock_manager, pero este cómputo interno de valuación
@@ -74,8 +73,6 @@ def _set_value(self, correction_quantity=None):
7473
if move.with_company(move.company_id).valuation_currency_id and move.value:
7574
if move.is_dropship or move.is_in:
7675
products_to_recompute_by_company[move.company_id.id].add(move.product_id.id)
77-
if move.product_id.lot_valuated:
78-
lots_to_recompute.update(move.move_line_ids.lot_id.ids)
7976

8077
lcs = landed_costs_by_move.get(move, self.env["stock.valuation.adjustment.lines"])
8178
lc_value = sum(lcs.mapped("additional_landed_cost"))
@@ -116,4 +113,3 @@ def _set_value(self, correction_quantity=None):
116113
# Recompute the standard price, con la compañía de cada move (no la ambiente)
117114
for company_id, product_ids in products_to_recompute_by_company.items():
118115
self.env["product.product"].browse(product_ids).with_company(company_id)._update_standard_price()
119-
# self.env['stock.lot'].browse(lots_to_recompute)._update_standard_price()

stock_currency_valuation/report/stock_avco_audit_report.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ class StockAverageCostReport(models.AbstractModel):
3232
)
3333

3434
def init(self):
35+
"""MAINTENANCE NOTE: this view is a COPY of the standard one in
36+
``stock_account/report/stock_avco_audit_report.py::init`` with the
37+
``value_in_currency`` column added to both legs of the UNION. It does not call
38+
``super()`` —a view cannot be extended column by column— so it has to be
39+
RE-SYNCED whenever Odoo touches theirs. Drift here is silent: the view builds
40+
fine and the numbers come out wrong. ``test_avco_report_quantity_in_product_uom``
41+
covers the last one that happened (the UoM conversion was missing)."""
3542
tools.drop_view_if_exists(self.env.cr, "stock_avco_report")
3643
query = """
3744
CREATE OR REPLACE VIEW stock_avco_report AS (
@@ -44,7 +51,7 @@ def init(self):
4451
sm.reference,
4552
CASE WHEN sm.is_in THEN sm.value ELSE -sm.value END AS value,
4653
CASE WHEN sm.is_in THEN sm.value_in_currency ELSE -sm.value_in_currency END AS value_in_currency,
47-
CASE WHEN sm.is_in THEN sm.quantity ELSE -sm.quantity END AS quantity,
54+
CASE WHEN sm.is_in THEN sm.quantity * (um.factor / up.factor) ELSE -sm.quantity * (um.factor / up.factor) END AS quantity,
4855
'stock.move' AS res_model_name,
4956
'Operation' AS description
5057
FROM
@@ -59,9 +66,14 @@ def init(self):
5966
product_category pc ON pt.categ_id = pc.id
6067
LEFT JOIN
6168
res_company company ON sm.company_id = company.id
69+
LEFT JOIN
70+
uom_uom um ON um.id = sm.product_uom
71+
LEFT JOIN
72+
uom_uom up ON up.id = pt.uom_id
6273
WHERE
6374
sm.state = 'done'
6475
AND (sm.is_in = TRUE OR sm.is_out = TRUE)
76+
-- Ignore moves for standard cost method. Only display the list of cost updates
6577
AND (
6678
(pt.categ_id IS NOT NULL AND pc.property_cost_method ->> company.id::text IN ('fifo', 'average'))
6779
OR (pt.categ_id IS NULL OR (pc.property_cost_method IS NULL OR pc.property_cost_method ->> company.id::text IS NULL) AND company.cost_method IN ('fifo', 'average'))
@@ -73,10 +85,10 @@ def init(self):
7385
pv.date,
7486
pv.user_id,
7587
pv.company_id,
76-
'Adjustment' AS reference,
88+
'Adjustment' AS reference, -- Set a fixed string for the reference
7789
pv.value,
7890
pv.value_in_currency,
79-
0 AS quantity,
91+
0 AS quantity, -- Set quantity to 0 as requested,
8092
'product.value' AS res_model_name,
8193
pv.description
8294
FROM
@@ -88,6 +100,10 @@ def init(self):
88100
self.env.cr.execute(query)
89101

90102
def _compute_cumulative_fields(self):
103+
"""MAINTENANCE NOTE: copy of the standard computation, carrying the
104+
secondary-currency amounts alongside the company-currency ones. Same deal as
105+
``init`` above: no ``super()``, so it has to be re-synced on upgrades.
106+
"""
91107
total_records_grouped = (
92108
self.env["stock.avco.report"]
93109
.search(

stock_currency_valuation/tests/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@
33
from . import test_inventory_adjustment
44
from . import test_landed_cost_currency
55
from . import test_delivery_and_return
6+
from . import test_replenishment_cost_average_in_currency
7+
from . import test_avco_report_uom
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
from freezegun import freeze_time
2+
from odoo import Command
3+
4+
from .common import TestStockCurrencyValuationCommon
5+
6+
7+
class TestAvcoReportUom(TestStockCurrencyValuationCommon):
8+
"""La cantidad del reporte de auditoría AVCO va en la UoM de REFERENCIA del
9+
producto, no en la del movimiento.
10+
11+
Regresión: la vista SQL de este módulo es una copia de la del core, y la copia
12+
había perdido la conversión ``sm.quantity * (um.factor / up.factor)`` junto con
13+
los dos JOIN sobre ``uom_uom``. Con el módulo instalado, una recepción de 1
14+
docena informaba cantidad 1 en vez de 12, y como el reporte calcula el costo
15+
unitario como ``total_value / total_quantity``, el AVCO salía 12 veces más caro.
16+
"""
17+
18+
def _receipt_in_uom(self, qty, uom, date_str):
19+
"""Recepción validada expresada en `uom`, que puede no ser la del producto."""
20+
with freeze_time(date_str):
21+
picking = self.env["stock.picking"].create(
22+
{
23+
"partner_id": self.vendor.id,
24+
"picking_type_id": self.picking_type_in.id,
25+
"location_id": self.supplier_location.id,
26+
"location_dest_id": self.stock_location.id,
27+
"move_ids": [
28+
Command.create(
29+
{
30+
"product_id": self.product.id,
31+
"product_uom_qty": qty,
32+
"product_uom": uom.id,
33+
"location_id": self.supplier_location.id,
34+
"location_dest_id": self.stock_location.id,
35+
}
36+
)
37+
],
38+
}
39+
)
40+
picking.action_confirm()
41+
move = picking.move_ids
42+
move.quantity = qty
43+
move.picked = True
44+
picking.button_validate()
45+
self.assertEqual(picking.state, "done")
46+
return picking, move
47+
48+
def _report_line(self, move):
49+
line = (
50+
self.env["stock.avco.report"]
51+
.with_company(self.company)
52+
.search([("product_id", "=", self.product.id), ("res_model_name", "=", "stock.move")])
53+
.filtered(lambda r: r.id == move.id)
54+
)
55+
self.assertEqual(len(line), 1, "Se esperaba una línea del reporte para el movimiento.")
56+
return line
57+
58+
def test_avco_report_quantity_in_product_uom(self):
59+
uom_dozen = self.env.ref("uom.product_uom_dozen")
60+
self.assertNotEqual(uom_dozen, self.product.uom_id, "El test necesita una UoM distinta a la del producto.")
61+
62+
self.product.standard_price = 100.0
63+
_picking, move = self._receipt_in_uom(1, uom_dozen, self.DAY_1)
64+
65+
line = self._report_line(move)
66+
# El oráculo es la conversión del propio ORM: la cantidad del reporte tiene que
67+
# estar en la UoM de referencia del producto, no en la del movimiento.
68+
expected = uom_dozen._compute_quantity(1, self.product.uom_id)
69+
self._assert_almost(line.quantity, expected)
70+
self.assertNotAlmostEqual(
71+
line.quantity, 1.0, places=2, msg="La cantidad quedó en la UoM del movimiento, sin convertir."
72+
)
73+
74+
def test_avco_report_unit_cost_uses_converted_quantity(self):
75+
uom_dozen = self.env.ref("uom.product_uom_dozen")
76+
self.product.standard_price = 100.0
77+
_picking, move = self._receipt_in_uom(1, uom_dozen, self.DAY_1)
78+
79+
line = self._report_line(move)
80+
# Consecuencia de la conversión: el costo unitario se calcula sobre la cantidad
81+
# convertida. Se compara contra los propios valores del reporte para no depender
82+
# de cómo se valorizó la recepción.
83+
self._assert_almost(line.avco_value, line.total_value / line.total_quantity)
84+
self._assert_almost(line.total_quantity, uom_dozen._compute_quantity(1, self.product.uom_id))
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from freezegun import freeze_time
2+
from odoo import fields
3+
4+
from .common import TestStockCurrencyValuationCommon
5+
6+
7+
class TestReplenishmentCostAverageInCurrency(TestStockCurrencyValuationCommon):
8+
"""replenishment_cost_type = 'average_in_currency': convierte
9+
standard_price_in_currency (moneda de valuación de la categoría) a la
10+
moneda del producto usando fields.Date.today().
11+
12+
Regresión: la conversión usaba `fields.date.today()` (minúscula), que no
13+
existe en odoo.fields y rompía con AttributeError apenas se leía
14+
replenishment_cost/replenishment_base_cost_on_currency.
15+
"""
16+
17+
def setUp(self):
18+
super().setUp()
19+
self.template = self.product.product_tmpl_id.with_company(self.company)
20+
self.template.replenishment_cost_type = "average_in_currency"
21+
22+
def test_average_in_currency_converts_using_valuation_currency_rate(self):
23+
self._product().standard_price_in_currency = 1000.0
24+
25+
with freeze_time(self.DAY_1):
26+
self.template.invalidate_recordset(["replenishment_cost", "replenishment_base_cost_on_currency"])
27+
expected = self.secondary_currency._convert(
28+
from_amount=1000.0,
29+
to_currency=self.template.currency_id,
30+
company=self.company,
31+
date=fields.Date.today(),
32+
)
33+
self._assert_almost(self.template.replenishment_base_cost_on_currency, expected)
34+
self._assert_almost(self.template.replenishment_cost, expected)
35+
36+
# Cambia la cotización (Día 2): el costo de reposición debe seguirla.
37+
with freeze_time(self.DAY_2):
38+
self.template.invalidate_recordset(["replenishment_cost", "replenishment_base_cost_on_currency"])
39+
expected_d2 = self.secondary_currency._convert(
40+
from_amount=1000.0,
41+
to_currency=self.template.currency_id,
42+
company=self.company,
43+
date=fields.Date.today(),
44+
)
45+
self.assertNotAlmostEqual(expected, expected_d2, places=2)
46+
self._assert_almost(self.template.replenishment_base_cost_on_currency, expected_d2)
47+
self._assert_almost(self.template.replenishment_cost, expected_d2)
48+
49+
def test_average_in_currency_applies_replenishment_cost_rule_on_converted_amount(self):
50+
rule = self.env["product.replenishment_cost.rule"].create(
51+
{
52+
"name": "Markup SCV",
53+
"item_ids": [
54+
(0, 0, {"name": "Markup", "percentage_amount": 10.0, "fixed_amount": 5.0}),
55+
],
56+
}
57+
)
58+
self.template.replenishment_cost_rule_id = rule
59+
self._product().standard_price_in_currency = 1000.0
60+
61+
with freeze_time(self.DAY_1):
62+
self.template.invalidate_recordset(["replenishment_cost", "replenishment_base_cost_on_currency"])
63+
base_cost = self.secondary_currency._convert(
64+
from_amount=1000.0,
65+
to_currency=self.template.currency_id,
66+
company=self.company,
67+
date=fields.Date.today(),
68+
)
69+
expected_cost = base_cost * 1.10 + 5.0
70+
71+
self._assert_almost(self.template.replenishment_base_cost_on_currency, base_cost)
72+
self._assert_almost(self.template.replenishment_cost, expected_cost)

stock_currency_valuation/views/stock_valuation_layer.xml

Lines changed: 0 additions & 29 deletions
This file was deleted.

stock_currency_valuation/wizard/__init__.py

Lines changed: 0 additions & 1 deletion
This file was deleted.

0 commit comments

Comments
 (0)