LaOsaCoop/Odoo16#127 add product_sale_price_from_pricelist_mrp
This commit is contained in:
parent
3be3de173e
commit
ff7f743be4
15 changed files with 632 additions and 6 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "Product Sale Price from Pricelist",
|
||||
"version": "16.0.1.1.0",
|
||||
"version": "16.0.1.2.0",
|
||||
"category": "product",
|
||||
"summary": "Set sale price from pricelist based on last purchase price",
|
||||
"description": """
|
||||
|
|
|
|||
|
|
@ -91,6 +91,12 @@ class ProductTemplate(models.Model):
|
|||
(untaxed_price / template.last_purchase_price_received) - 1.0
|
||||
) * 100.0
|
||||
|
||||
def _get_theoritical_price_surcharge(self):
|
||||
"""Untaxed amount added to the pricelist price, after the margin and
|
||||
before taxes and rounding. Hook for other modules."""
|
||||
self.ensure_one()
|
||||
return 0.0
|
||||
|
||||
def _compute_theoritical_price(self):
|
||||
pricelist_obj = self.env["product.pricelist"]
|
||||
pricelist_id = (
|
||||
|
|
@ -119,14 +125,14 @@ class ProductTemplate(models.Model):
|
|||
)
|
||||
% template.name
|
||||
)
|
||||
untaxed_price = (
|
||||
partial_price[template.product_variant_id.id]["value"] or 0.0
|
||||
) + template._get_theoritical_price_surcharge()
|
||||
tax_price = template.taxes_id.compute_all(
|
||||
partial_price[template.product_variant_id.id]["value"] or 0.0,
|
||||
untaxed_price,
|
||||
handle_price_include=False,
|
||||
)
|
||||
price_with_taxes = (
|
||||
tax_price["taxes"][0]["amount"]
|
||||
+ partial_price[template.product_variant_id.id]["value"]
|
||||
)
|
||||
price_with_taxes = tax_price["taxes"][0]["amount"] + untaxed_price
|
||||
# Round to configurable step (defined on the pricelist)
|
||||
step = pricelist.price_rounding_step
|
||||
if step:
|
||||
|
|
|
|||
1
product_sale_price_from_pricelist/tests/__init__.py
Normal file
1
product_sale_price_from_pricelist/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import test_theoritical_price
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestTheoriticalPrice(TransactionCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
pricelist = cls.env["product.pricelist"].create(
|
||||
{
|
||||
"name": "Automatic",
|
||||
"item_ids": [
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"applied_on": "3_global",
|
||||
"compute_price": "formula",
|
||||
"base": "last_purchase_price",
|
||||
"price_discount": -20.0,
|
||||
},
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
cls.env["ir.config_parameter"].sudo().set_param(
|
||||
"product_sale_price_from_pricelist.product_pricelist_automatic",
|
||||
pricelist.id,
|
||||
)
|
||||
tax = cls.env["account.tax"].create(
|
||||
{
|
||||
"name": "IVA 10%",
|
||||
"amount": 10.0,
|
||||
"amount_type": "percent",
|
||||
"type_tax_use": "sale",
|
||||
"price_include": False,
|
||||
"country_id": cls.env.ref("base.es").id,
|
||||
}
|
||||
)
|
||||
cls.template = cls.env["product.template"].create(
|
||||
{
|
||||
"name": "Bulk product",
|
||||
"detailed_type": "product",
|
||||
"taxes_id": [(6, 0, tax.ids)],
|
||||
"list_price": 1.0,
|
||||
"last_purchase_price_received": 2.0,
|
||||
}
|
||||
)
|
||||
|
||||
def test_theoritical_price_without_surcharge(self):
|
||||
self.assertEqual(self.template._get_theoritical_price_surcharge(), 0.0)
|
||||
self.template._compute_theoritical_price()
|
||||
self.assertAlmostEqual(self.template.list_price_theoritical, 2.0 * 1.2 * 1.1)
|
||||
self.assertTrue(self.template.last_purchase_price_updated)
|
||||
26
product_sale_price_from_pricelist_mrp/README.md
Normal file
26
product_sale_price_from_pricelist_mrp/README.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# Product Sale Price from Pricelist - Manufacturing
|
||||
|
||||
Bridge between `product_sale_price_from_pricelist` and manufacturing. Manufactured products (e.g. bulk products packed in jars by the shop) get their sale price from their bill of materials, following the usual flow: price updated warning → "Update Sales Price" → print labels.
|
||||
|
||||
## Features
|
||||
|
||||
- **Last purchase price from BoM**: the last purchase price of a manufactured product is the sum of the last purchase prices of its non-service components, with UoM conversion and BoM quantity.
|
||||
- **Surcharge after margin**: service components of the BoM (e.g. "Packaging surcharge") are added to the sale price after the pricelist margin and before taxes:
|
||||
`theoretical price = (components last purchase price × margin + surcharge) + taxes`, then rounded.
|
||||
- **When it is computed**:
|
||||
- When a manufacturing order is done.
|
||||
- With the standard "Compute Price from BoM" button or list action.
|
||||
- When the last purchase price of a surcharge service changes, the theoretical price of the products using it is recomputed.
|
||||
- **Cost is not changed**: `standard_price` is still computed by Odoo as usual.
|
||||
|
||||
## Configuration
|
||||
|
||||
1. Create a service product for the surcharge and set its **Last purchase price** (editable for services).
|
||||
2. Create the manufactured product with taxes and a BoM: bulk component + surcharge service.
|
||||
3. After installing, select existing manufactured products and run **Compute Price from BoM**.
|
||||
|
||||
## Notes
|
||||
|
||||
- Products with *Manual update* calculation type are not changed.
|
||||
- Only products with a single variant are handled.
|
||||
- If a manufactured product has no taxes, its theoretical price is skipped (warning in the log) so the manufacturing order is never blocked.
|
||||
1
product_sale_price_from_pricelist_mrp/__init__.py
Normal file
1
product_sale_price_from_pricelist_mrp/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import models
|
||||
23
product_sale_price_from_pricelist_mrp/__manifest__.py
Normal file
23
product_sale_price_from_pricelist_mrp/__manifest__.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
|
||||
{
|
||||
"name": "Product Sale Price from Pricelist - Manufacturing",
|
||||
"version": "16.0.1.0.0",
|
||||
"category": "Manufacturing",
|
||||
"license": "AGPL-3",
|
||||
"summary": "Compute last purchase price and sale surcharge of manufactured "
|
||||
"products from their bill of materials",
|
||||
"author": "Criptomart",
|
||||
"website": "https://github.com/criptomart/odoo-addons",
|
||||
"depends": [
|
||||
"product_sale_price_from_pricelist",
|
||||
"mrp_account",
|
||||
],
|
||||
"data": [
|
||||
"views/product_views.xml",
|
||||
],
|
||||
"installable": True,
|
||||
"application": False,
|
||||
"auto_install": False,
|
||||
}
|
||||
3
product_sale_price_from_pricelist_mrp/models/__init__.py
Normal file
3
product_sale_price_from_pricelist_mrp/models/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from . import mrp_production
|
||||
from . import product_product
|
||||
from . import product_template
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class MrpProduction(models.Model):
|
||||
_inherit = "mrp.production"
|
||||
|
||||
def button_mark_done(self):
|
||||
res = super().button_mark_done()
|
||||
# It may return a wizard without finishing the MO; the wizard calls
|
||||
# button_mark_done again.
|
||||
for production in self.filtered(lambda p: p.state == "done"):
|
||||
production.product_id.with_company(
|
||||
production.company_id
|
||||
)._update_last_purchase_price_from_bom()
|
||||
return res
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import models
|
||||
from odoo.tools import float_compare, float_round
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProductProduct(models.Model):
|
||||
_inherit = "product.product"
|
||||
|
||||
def _get_bom_price_split(self):
|
||||
"""Return (components_price, surcharge) per unit of product from its
|
||||
BoM, based on last_purchase_price_received of the components. Service
|
||||
components make up the surcharge. Return None if there is no BoM."""
|
||||
self.ensure_one()
|
||||
bom = (
|
||||
self.env["mrp.bom"]
|
||||
.sudo()
|
||||
._bom_find(self, company_id=self.env.company.id)
|
||||
.get(self)
|
||||
)
|
||||
if not bom:
|
||||
return None
|
||||
components_price = surcharge = 0.0
|
||||
for line in bom.bom_line_ids:
|
||||
if line._skip_bom_line(self):
|
||||
continue
|
||||
component = line.product_id
|
||||
price = (
|
||||
component.uom_id._compute_price(
|
||||
component.last_purchase_price_received, line.product_uom_id
|
||||
)
|
||||
* line.product_qty
|
||||
)
|
||||
if component.type == "service":
|
||||
surcharge += price
|
||||
else:
|
||||
components_price += price
|
||||
return tuple(
|
||||
bom.product_uom_id._compute_price(total / bom.product_qty, self.uom_id)
|
||||
for total in (components_price, surcharge)
|
||||
)
|
||||
|
||||
def _update_last_purchase_price_from_bom(self):
|
||||
"""Set last_purchase_price_received from the BoM and recompute the
|
||||
theoritical sale price."""
|
||||
digits = self.env["decimal.precision"].precision_get("Product Price")
|
||||
templates = self.env["product.template"]
|
||||
for product in self:
|
||||
template = product.product_tmpl_id
|
||||
if template.last_purchase_price_compute_type == "manual_update":
|
||||
continue
|
||||
if template.product_variant_count != 1:
|
||||
_logger.warning(
|
||||
"Skip last purchase price from BoM for %s: "
|
||||
"product has several variants",
|
||||
template.display_name,
|
||||
)
|
||||
continue
|
||||
split = product._get_bom_price_split()
|
||||
if split is None:
|
||||
continue
|
||||
price = float_round(split[0], precision_digits=digits)
|
||||
if float_compare(
|
||||
price, product.last_purchase_price_received, precision_digits=digits
|
||||
):
|
||||
product.last_purchase_price_received = price
|
||||
templates |= template
|
||||
templates._compute_theoritical_price_from_bom()
|
||||
|
||||
def button_bom_cost(self):
|
||||
res = super().button_bom_cost()
|
||||
self._update_last_purchase_price_from_bom()
|
||||
return res
|
||||
|
||||
def action_bom_cost(self):
|
||||
res = super().action_bom_cost()
|
||||
self._update_last_purchase_price_from_bom()
|
||||
return res
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import models
|
||||
from odoo.tools import float_compare
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProductTemplate(models.Model):
|
||||
_inherit = "product.template"
|
||||
|
||||
def _get_theoritical_price_surcharge(self):
|
||||
surcharge = super()._get_theoritical_price_surcharge()
|
||||
if self.product_variant_count == 1:
|
||||
split = self.product_variant_id._get_bom_price_split()
|
||||
if split:
|
||||
surcharge += split[1]
|
||||
return surcharge
|
||||
|
||||
def _compute_theoritical_price_from_bom(self):
|
||||
"""Like _compute_theoritical_price, but never raise: it runs when an
|
||||
MO is done or a surcharge service is saved, which must not be
|
||||
blocked by a misconfigured manufactured product."""
|
||||
pricelist_id = (
|
||||
self.env["ir.config_parameter"]
|
||||
.sudo()
|
||||
.get_param("product_sale_price_from_pricelist.product_pricelist_automatic")
|
||||
)
|
||||
if not self or not pricelist_id:
|
||||
return
|
||||
without_taxes = self.filtered(lambda t: not t.taxes_id)
|
||||
for template in without_taxes:
|
||||
_logger.warning(
|
||||
"Skip theoritical price for %s: no taxes defined",
|
||||
template.display_name,
|
||||
)
|
||||
(self - without_taxes)._compute_theoritical_price()
|
||||
|
||||
def _get_bom_parent_templates(self):
|
||||
"""Templates whose active BoMs use any variant of self as component."""
|
||||
lines = (
|
||||
self.env["mrp.bom.line"]
|
||||
.sudo()
|
||||
.search(
|
||||
[
|
||||
("product_id", "in", self.product_variant_ids.ids),
|
||||
("bom_id.company_id", "in", [False, self.env.company.id]),
|
||||
]
|
||||
)
|
||||
)
|
||||
return self.browse(lines.bom_id.product_tmpl_id.ids)
|
||||
|
||||
def write(self, vals):
|
||||
if "last_purchase_price_received" not in vals:
|
||||
return super().write(vals)
|
||||
digits = self.env["decimal.precision"].precision_get("Product Price")
|
||||
services = self.filtered(lambda t: t.type == "service")
|
||||
previous = {t.id: t.last_purchase_price_received for t in services}
|
||||
res = super().write(vals)
|
||||
changed = services.filtered(
|
||||
lambda t: float_compare(
|
||||
previous[t.id],
|
||||
t.last_purchase_price_received,
|
||||
precision_digits=digits,
|
||||
)
|
||||
)
|
||||
if changed:
|
||||
changed._get_bom_parent_templates()._compute_theoritical_price_from_bom()
|
||||
return res
|
||||
120
product_sale_price_from_pricelist_mrp/specs/requirements.md
Normal file
120
product_sale_price_from_pricelist_mrp/specs/requirements.md
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
# Especificación: product_sale_price_from_pricelist_mrp
|
||||
|
||||
## Resumen
|
||||
|
||||
Módulo puente entre `product_sale_price_from_pricelist` y la fabricación (`mrp_account`).
|
||||
|
||||
Caso de uso (La Osa): la cooperativa envasa productos a granel en botes propios (p. ej. "Lentejas 1 kg (bote)") mediante órdenes de fabricación (OF), para llevar stock separado de granel y de botes. El bote se vende en el TPV con su propio código de barras y un recargo por la elaboración. El tarro retornable se gestiona aparte como fianza con `pos_container_deposit` y queda fuera de este módulo.
|
||||
|
||||
El módulo de precios de venta calcula el PVP a partir de `last_purchase_price_received`, que solo se actualiza al recibir compras. Un producto fabricado nunca se compra, así que su último precio de compra nunca se actualizaría. Este módulo:
|
||||
|
||||
1. Calcula el `last_purchase_price_received` del producto fabricado a partir de su lista de materiales (LdM), usando el `last_purchase_price_received` de sus componentes no servicio. Se recalcula **al finalizar una OF** del producto y con el botón "Calcular coste desde LdM".
|
||||
2. Suma un **recargo fijo después del margen**: los componentes de tipo servicio de la LdM (p. ej. "Recargo elaboración") no entran en la base del margen, sino que se suman al precio ya calculado por la tarifa:
|
||||
`PVP = (último precio de compra del granel × margen de tarifa + recargo) + impuestos`, redondeado según la tarifa.
|
||||
|
||||
Así el producto fabricado entra en el flujo habitual: aviso de precio actualizado → "Actualizar PVP" → impresión de etiquetas.
|
||||
|
||||
**El coste (`standard_price`) no se modifica.** Sigue calculándose como en Odoo estándar (botón "Calcular coste desde LdM" y valoración de las OF). Todo cálculo nuevo se escribe únicamente en `last_purchase_price_received` y `list_price_theoritical`.
|
||||
|
||||
Va en un módulo aparte porque hay clientes que usan `product_sale_price_from_pricelist` sin fabricación. Requiere un **hook mínimo en el módulo base** (ver RF-05) que no cambia su comportamiento.
|
||||
|
||||
## Requisitos funcionales
|
||||
|
||||
- **RF-01**: Último precio de compra desde la LdM
|
||||
- Para un producto con LdM (tipo `normal` o `phantom`), su `last_purchase_price_received` es:
|
||||
`Σ (last_purchase_price_received del componente convertido a la UdM de la línea × cantidad de la línea) / cantidad de la LdM`, convertido de la UdM de la LdM a la UdM del producto.
|
||||
- **Solo se suman los componentes que no son de tipo `service`.** Los servicios forman el recargo (RF-04).
|
||||
- Se usa la LdM que devuelve `mrp.bom._bom_find(product, company_id=...)` y se respetan las líneas excluidas por variante (`bom_line._skip_bom_line(product)`).
|
||||
- Se usan las cantidades de la LdM, no las consumidas realmente en la OF. Si quien envasa corrige los kg consumidos, el PVP no se ve afectado.
|
||||
- Se ignoran las operaciones o centros de trabajo y los subproductos.
|
||||
- El resultado se redondea con la precisión `Product Price`.
|
||||
- Criterio de aceptación: Lentejas granel (UdM kg, último precio 2,50) y servicio Recargo (último precio 0,40). Bote con LdM: 1 kg de lentejas + 1 unidad de recargo. El último precio de compra del bote debe ser 2,50.
|
||||
|
||||
- **RF-02**: Recálculo al finalizar una OF
|
||||
- Cuando una OF pasa a estado `done`, se recalcula según RF-01 el `last_purchase_price_received` del producto fabricado. Después se llama a `_compute_theoritical_price()` para que quede marcado `last_purchase_price_updated` si el PVP teórico cambia.
|
||||
- Criterio de aceptación: Lentejas con último precio 2,50 y bote con último precio 2,50. Recibir una compra de lentejas a 2,80: el bote no cambia. Fabricar y finalizar una OF de 10 botes: el bote pasa a último precio 2,80, con precio teórico recalculado y `last_purchase_price_updated = True`.
|
||||
|
||||
- **RF-03**: Botón "Calcular coste desde LdM"
|
||||
- Al pulsar el botón (`button_bom_cost`) o la acción de servidor de lista (`action_bom_cost`), además del comportamiento estándar sobre `standard_price`, se aplica lo mismo que en RF-02. Sirve para recálculos puntuales y para la carga inicial tras instalar.
|
||||
- Criterio de aceptación: Cambiar la LdM del bote (p. ej. 0,5 kg en vez de 1 kg) y pulsar el botón. `standard_price` se calcula igual que sin el módulo y `last_purchase_price_received` refleja la nueva LdM.
|
||||
|
||||
- **RF-04**: Recargo fijo después del margen
|
||||
- El recargo de un producto fabricado es `Σ (last_purchase_price_received del componente servicio × cantidad de la línea) / cantidad de la LdM`, convertido a la UdM del producto. Es un importe sin impuestos.
|
||||
- En `_compute_theoritical_price()`, el recargo se suma al precio sin impuestos devuelto por la tarifa automática, **antes** de calcular impuestos y redondear.
|
||||
- Criterio de aceptación: Lentejas a 2,50, margen de tarifa 20 %, recargo 0,40, IVA 4 %, sin redondeo. PVP teórico del bote = (2,50 × 1,20 + 0,40) × 1,04 = 3,536.
|
||||
|
||||
- **RF-05**: Hook en el módulo base
|
||||
- En `product_sale_price_from_pricelist`, `product.template._compute_theoritical_price()` debe sumar `template._get_theoritical_price_surcharge()` al precio sin impuestos de la tarifa, antes de impuestos y redondeo. La implementación base devuelve `0.0`, así que **los clientes sin este módulo no ven ningún cambio**.
|
||||
- Este módulo hereda `_get_theoritical_price_surcharge()` y devuelve el recargo de RF-04 (0,0 si no hay LdM).
|
||||
- Criterio de aceptación: con el módulo base solo, el precio teórico de cualquier producto es idéntico antes y después del cambio.
|
||||
|
||||
- **RF-06**: Cambio del importe del recargo
|
||||
- En la ficha de producto, `last_purchase_price_received` pasa a ser editable cuando el producto es de tipo `service`. Para el resto de tipos sigue siendo de solo lectura.
|
||||
- Al cambiar el `last_purchase_price_received` de un servicio, se llama a `_compute_theoritical_price()` de los productos cuyas LdM activas lo contienen. Así quedan marcados para "Actualizar PVP" sin esperar a una OF. Su `last_purchase_price_received` no cambia.
|
||||
- Criterio de aceptación: Cambiar el último precio del servicio "Recargo" de 0,40 a 0,50. Todos los botes que lo usan quedan con `last_purchase_price_updated = True` y precio teórico 0,10 € más alto antes de impuestos.
|
||||
|
||||
- **RF-07**: Productos en actualización manual
|
||||
- Si el producto fabricado tiene `last_purchase_price_compute_type = "manual_update"`, no se modifica su `last_purchase_price_received` (RF-02, RF-03). El recargo tampoco aplica, porque el módulo base ya no calcula precio teórico para estos productos.
|
||||
- Criterio de aceptación: Bote en `manual_update`. Finalizar una OF con lentejas a otro precio. El último precio de compra del bote no cambia.
|
||||
|
||||
- **RF-08**: No bloquear la finalización de OF
|
||||
- `_compute_theoritical_price()` lanza un `UserError` si el producto no tiene impuestos. En RF-02 y RF-06 se omite el cálculo del precio teórico para productos sin impuestos y se registra un aviso en el log, para que no se impida finalizar una OF ni guardar el servicio. El `last_purchase_price_received` sí se actualiza.
|
||||
- Criterio de aceptación: Bote sin impuestos. Finalizar una OF del bote. La OF se finaliza sin error y el último precio del bote se actualiza.
|
||||
|
||||
- **RF-09**: Tests unitarios
|
||||
- Tests automatizados que cubran RF-01 a RF-08, conversión de UdM (lentejas en kg, línea de LdM en g) y LdM con `product_qty` distinto de 1.
|
||||
- En el módulo base: test de no regresión de RF-05 (precio teórico sin cambios con el hook a 0).
|
||||
- Criterio de aceptación: `./odoo-bin -c laosa.conf --test-tags product_sale_price_from_pricelist,product_sale_price_from_pricelist_mrp --stop-after-init` pasa sin errores.
|
||||
|
||||
## Modelo de datos propuesto
|
||||
|
||||
No se crean modelos ni campos nuevos.
|
||||
|
||||
- `product.template` en **`product_sale_price_from_pricelist`** (módulo base):
|
||||
- Nuevo `_get_theoritical_price_surcharge()` que devuelve `0.0`.
|
||||
- En `_compute_theoritical_price()`: `untaxed = partial_price[...]["value"] + template._get_theoritical_price_surcharge()`. Usar `untaxed` en `compute_all` y en la suma posterior.
|
||||
- Subir versión del módulo base.
|
||||
|
||||
- `product.product` (heredado en este módulo):
|
||||
- `_get_bom_price_split()`: devuelve `(precio_componentes, recargo)` según RF-01 y RF-04, o `None` si no hay LdM. Implementación paralela a `_compute_bom_price` de `mrp_account/models/product.py`, cambiando `standard_price` por `last_purchase_price_received` y separando las líneas de servicio.
|
||||
- `_update_last_purchase_price_from_bom()`: aplica RF-01, RF-07 y RF-08 sobre `self` (escritura si cambia + precio teórico).
|
||||
- `button_bom_cost()` y `action_bom_cost()`: `super()` y después `_update_last_purchase_price_from_bom()`. Las versiones de `product.template` ya delegan en las variantes.
|
||||
|
||||
- `product.template` (heredado en este módulo):
|
||||
- `_get_theoritical_price_surcharge()`: recargo de RF-04 para `product_variant_id`.
|
||||
- `write()`: si cambia `last_purchase_price_received` en un template de tipo `service`, recalcula el precio teórico de los productos padre (RF-06).
|
||||
|
||||
- `mrp.production` (heredado):
|
||||
- `button_mark_done()`: tras `super()`, para las producciones de `self` en estado `done`, llamar a `product_id._update_last_purchase_price_from_bom()`. `button_mark_done` puede devolver un asistente (backorder o consumo) sin finalizar la OF; el filtro por estado cubre ese caso, porque el asistente vuelve a llamar a `button_mark_done`.
|
||||
|
||||
## Vistas necesarias
|
||||
|
||||
- Herencia de `product_sale_price_from_pricelist.product_view_inherit_list_price_auto`: sustituir `readonly="1"` de `last_purchase_price_received` por `attrs="{'readonly': [('detailed_type', '!=', 'service')]}"` (RF-06).
|
||||
- No se necesitan vistas nuevas.
|
||||
|
||||
## Seguridad
|
||||
|
||||
- Sin grupos ni ACLs nuevos.
|
||||
- En RF-06 y en `_get_theoritical_price_surcharge()`, la búsqueda de LdM se hace con `sudo()`. Así funciona para usuarios sin permisos de fabricación (p. ej. quien pulsa "Actualizar PVP").
|
||||
|
||||
## Integraciones / Dependencias
|
||||
|
||||
- **Dependencias directas**: `product_sale_price_from_pricelist`, `mrp_account` (contiene `button_bom_cost` / `action_bom_cost`; depende de `mrp` y `stock_account`).
|
||||
- **Sin `auto_install`**: se instala a mano solo en los clientes que lo necesiten.
|
||||
- **Cambio en el módulo base** (RF-05): compatible hacia atrás para todos los clientes.
|
||||
- **Sin relación con**: `pos_container_deposit` (la fianza es un producto aparte con precio fijo) y `pos_mrp` (solo afecta a kits en el TPV y al coste anglosajón).
|
||||
|
||||
## Notas técnicas
|
||||
|
||||
- **Multicompañía**: `last_purchase_price_received` es `company_dependent`. Todo el cálculo y la escritura se hacen con `with_company(production.company_id)` en RF-02 y con `self.env.company` en RF-03 y RF-06. Las LdM se filtran por `company_id in (False, company.id)`.
|
||||
- **Variantes**: el campo vive en el template. Igual que `mrp_account.product_template.button_bom_cost`, solo se actualizan templates con `product_variant_count == 1`. El resto se omite con aviso en el log.
|
||||
- **Desfase aceptado**: entre la entrada de granel a un precio nuevo y la siguiente OF, el bote mantiene el precio anterior. Lo ha pedido así el cliente; si se quiere antes, se usa el botón (RF-03).
|
||||
- **LdM anidadas**: no hay propagación recursiva. Cada nivel se actualiza al finalizar su propia OF o con el botón.
|
||||
- **Impuestos del recargo**: el recargo se suma antes de impuestos, así que lleva el mismo IVA que el producto fabricado.
|
||||
- **`configured_sale_margin`**: no incluye el recargo. Sigue mostrando el margen de la tarifa sobre el último precio de compra, que es lo correcto.
|
||||
|
||||
## Fuera de alcance
|
||||
|
||||
- Modificar `standard_price` o la valoración de stock.
|
||||
- Recalcular el bote al recibir compras del granel (descartado por el cliente; posible ampliación futura).
|
||||
- Operaciones o centros de trabajo y subproductos en el cálculo.
|
||||
1
product_sale_price_from_pricelist_mrp/tests/__init__.py
Normal file
1
product_sale_price_from_pricelist_mrp/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import test_product_sale_price_from_pricelist_mrp
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
|
||||
from odoo.tests import Form
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestProductSalePriceFromPricelistMrp(TransactionCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.uom_unit = cls.env.ref("uom.product_uom_unit")
|
||||
cls.uom_kg = cls.env.ref("uom.product_uom_kgm")
|
||||
cls.uom_gram = cls.env.ref("uom.product_uom_gram")
|
||||
|
||||
# Automatic pricelist: last purchase price + 20 %
|
||||
cls.pricelist = cls.env["product.pricelist"].create(
|
||||
{
|
||||
"name": "Automatic",
|
||||
"item_ids": [
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"applied_on": "3_global",
|
||||
"compute_price": "formula",
|
||||
"base": "last_purchase_price",
|
||||
"price_discount": -20.0,
|
||||
},
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
cls.env["ir.config_parameter"].sudo().set_param(
|
||||
"product_sale_price_from_pricelist.product_pricelist_automatic",
|
||||
cls.pricelist.id,
|
||||
)
|
||||
cls.tax = cls.env["account.tax"].create(
|
||||
{
|
||||
"name": "IVA 4%",
|
||||
"amount": 4.0,
|
||||
"amount_type": "percent",
|
||||
"type_tax_use": "sale",
|
||||
"price_include": False,
|
||||
"country_id": cls.env.ref("base.es").id,
|
||||
}
|
||||
)
|
||||
|
||||
cls.lentils = cls.env["product.product"].create(
|
||||
{
|
||||
"name": "Lentils bulk",
|
||||
"detailed_type": "product",
|
||||
"uom_id": cls.uom_kg.id,
|
||||
"uom_po_id": cls.uom_kg.id,
|
||||
"taxes_id": [(6, 0, cls.tax.ids)],
|
||||
"standard_price": 2.0,
|
||||
"last_purchase_price_received": 2.50,
|
||||
}
|
||||
)
|
||||
cls.surcharge = cls.env["product.product"].create(
|
||||
{
|
||||
"name": "Packaging surcharge",
|
||||
"detailed_type": "service",
|
||||
"standard_price": 0.0,
|
||||
"last_purchase_price_received": 0.40,
|
||||
}
|
||||
)
|
||||
cls.jar = cls.env["product.product"].create(
|
||||
{
|
||||
"name": "Lentils 1 kg jar",
|
||||
"detailed_type": "product",
|
||||
"taxes_id": [(6, 0, cls.tax.ids)],
|
||||
"list_price": 1.0,
|
||||
}
|
||||
)
|
||||
cls.bom = cls.env["mrp.bom"].create(
|
||||
{
|
||||
"product_tmpl_id": cls.jar.product_tmpl_id.id,
|
||||
"product_qty": 1.0,
|
||||
"type": "normal",
|
||||
"bom_line_ids": [
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"product_id": cls.lentils.id,
|
||||
"product_qty": 1.0,
|
||||
"product_uom_id": cls.uom_kg.id,
|
||||
},
|
||||
),
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"product_id": cls.surcharge.id,
|
||||
"product_qty": 1.0,
|
||||
"product_uom_id": cls.uom_unit.id,
|
||||
},
|
||||
),
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
def _produce(self, product, qty=1.0):
|
||||
mo_form = Form(self.env["mrp.production"])
|
||||
mo_form.product_id = product
|
||||
mo_form.product_qty = qty
|
||||
mo = mo_form.save()
|
||||
mo.action_confirm()
|
||||
mo_form = Form(mo)
|
||||
mo_form.qty_producing = qty
|
||||
mo = mo_form.save()
|
||||
mo.button_mark_done()
|
||||
self.assertEqual(mo.state, "done")
|
||||
return mo
|
||||
|
||||
def test_bom_price_split(self):
|
||||
"""RF-01 / RF-04: services are the surcharge, the rest the base."""
|
||||
base, surcharge = self.jar._get_bom_price_split()
|
||||
self.assertAlmostEqual(base, 2.50)
|
||||
self.assertAlmostEqual(surcharge, 0.40)
|
||||
self.assertIsNone(self.lentils._get_bom_price_split())
|
||||
|
||||
def test_bom_uom_and_qty(self):
|
||||
"""RF-01: UoM conversion and BoM quantity other than 1."""
|
||||
self.bom.product_qty = 2.0
|
||||
self.bom.bom_line_ids.filtered(
|
||||
lambda l: l.product_id == self.lentils
|
||||
).write({"product_qty": 1000.0, "product_uom_id": self.uom_gram.id})
|
||||
base, surcharge = self.jar._get_bom_price_split()
|
||||
self.assertAlmostEqual(base, 1.25)
|
||||
self.assertAlmostEqual(surcharge, 0.20)
|
||||
|
||||
def test_mo_done_updates_last_purchase_price(self):
|
||||
"""RF-02: the jar takes the bulk price only when an MO is done."""
|
||||
self.jar.button_bom_cost()
|
||||
self.lentils.last_purchase_price_received = 2.80
|
||||
self.assertAlmostEqual(self.jar.last_purchase_price_received, 2.50)
|
||||
self._produce(self.jar, 2.0)
|
||||
self.assertAlmostEqual(self.jar.last_purchase_price_received, 2.80)
|
||||
self.assertAlmostEqual(
|
||||
self.jar.list_price_theoritical, (2.80 * 1.2 + 0.40) * 1.04, places=2
|
||||
)
|
||||
self.assertTrue(self.jar.last_purchase_price_updated)
|
||||
|
||||
def test_surcharge_after_margin(self):
|
||||
"""RF-04 / RF-05: surcharge added after margin, before taxes."""
|
||||
self.jar.button_bom_cost()
|
||||
# Taxes are rounded to the currency: 3.40 + 0.14
|
||||
self.assertAlmostEqual(self.jar.list_price_theoritical, 3.54)
|
||||
|
||||
def test_button_bom_cost(self):
|
||||
"""RF-03 / RF-04: button keeps standard cost and sets last price."""
|
||||
self.bom.bom_line_ids.filtered(
|
||||
lambda l: l.product_id == self.lentils
|
||||
).write({"product_qty": 500.0, "product_uom_id": self.uom_gram.id})
|
||||
self.jar.product_tmpl_id.button_bom_cost()
|
||||
self.assertAlmostEqual(self.jar.standard_price, 1.0)
|
||||
self.assertAlmostEqual(self.jar.last_purchase_price_received, 1.25)
|
||||
|
||||
def test_action_bom_cost(self):
|
||||
"""RF-03: list server action does the same as the button."""
|
||||
self.jar.product_tmpl_id.action_bom_cost()
|
||||
self.assertAlmostEqual(self.jar.standard_price, 2.0)
|
||||
self.assertAlmostEqual(self.jar.last_purchase_price_received, 2.50)
|
||||
|
||||
def test_surcharge_change_flags_parents(self):
|
||||
"""RF-06: changing the surcharge service recomputes parent prices."""
|
||||
self.jar.button_bom_cost()
|
||||
self.jar.product_tmpl_id.action_update_list_price()
|
||||
self.assertFalse(self.jar.last_purchase_price_updated)
|
||||
self.surcharge.last_purchase_price_received = 0.50
|
||||
self.assertTrue(self.jar.last_purchase_price_updated)
|
||||
self.assertAlmostEqual(
|
||||
self.jar.list_price_theoritical, (2.50 * 1.2 + 0.50) * 1.04
|
||||
)
|
||||
self.assertAlmostEqual(self.jar.last_purchase_price_received, 2.50)
|
||||
|
||||
def test_manual_update_untouched(self):
|
||||
"""RF-07: manual_update products keep their last purchase price."""
|
||||
self.jar.last_purchase_price_compute_type = "manual_update"
|
||||
self.jar.last_purchase_price_received = 9.0
|
||||
self._produce(self.jar)
|
||||
self.jar.button_bom_cost()
|
||||
self.assertAlmostEqual(self.jar.last_purchase_price_received, 9.0)
|
||||
|
||||
def test_no_taxes_does_not_block_mo(self):
|
||||
"""RF-08: a product without taxes does not block the MO."""
|
||||
self.jar.taxes_id = False
|
||||
self.lentils.last_purchase_price_received = 3.0
|
||||
self._produce(self.jar)
|
||||
self.assertAlmostEqual(self.jar.last_purchase_price_received, 3.0)
|
||||
|
||||
def test_product_without_bom(self):
|
||||
"""RF-05: no surcharge for products without BoM."""
|
||||
self.lentils.product_tmpl_id._compute_theoritical_price()
|
||||
self.assertAlmostEqual(self.lentils.list_price_theoritical, 2.50 * 1.2 * 1.04)
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="product_template_form_view_last_purchase_price_service" model="ir.ui.view">
|
||||
<field name="name">product.template.form.last.purchase.price.service</field>
|
||||
<field name="model">product.template</field>
|
||||
<field
|
||||
name="inherit_id"
|
||||
ref="product_sale_price_from_pricelist.product_view_inherit_list_price_auto"
|
||||
/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="last_purchase_price_received" position="attributes">
|
||||
<attribute name="readonly" />
|
||||
<attribute name="attrs">
|
||||
{'readonly': [('detailed_type', '!=', 'service')]}
|
||||
</attribute>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Loading…
Add table
Add a link
Reference in a new issue