LaOsaCoop/Odoo16#127 add product_sale_price_from_pricelist_mrp

This commit is contained in:
Luis 2026-10-15 11:40:35 +02:00
parent 3be3de173e
commit ff7f743be4
15 changed files with 632 additions and 6 deletions

View file

@ -1,6 +1,6 @@
{ {
"name": "Product Sale Price from Pricelist", "name": "Product Sale Price from Pricelist",
"version": "16.0.1.1.0", "version": "16.0.1.2.0",
"category": "product", "category": "product",
"summary": "Set sale price from pricelist based on last purchase price", "summary": "Set sale price from pricelist based on last purchase price",
"description": """ "description": """

View file

@ -91,6 +91,12 @@ class ProductTemplate(models.Model):
(untaxed_price / template.last_purchase_price_received) - 1.0 (untaxed_price / template.last_purchase_price_received) - 1.0
) * 100.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): def _compute_theoritical_price(self):
pricelist_obj = self.env["product.pricelist"] pricelist_obj = self.env["product.pricelist"]
pricelist_id = ( pricelist_id = (
@ -119,14 +125,14 @@ class ProductTemplate(models.Model):
) )
% template.name % 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( tax_price = template.taxes_id.compute_all(
partial_price[template.product_variant_id.id]["value"] or 0.0, untaxed_price,
handle_price_include=False, handle_price_include=False,
) )
price_with_taxes = ( price_with_taxes = tax_price["taxes"][0]["amount"] + untaxed_price
tax_price["taxes"][0]["amount"]
+ partial_price[template.product_variant_id.id]["value"]
)
# Round to configurable step (defined on the pricelist) # Round to configurable step (defined on the pricelist)
step = pricelist.price_rounding_step step = pricelist.price_rounding_step
if step: if step:

View file

@ -0,0 +1 @@
from . import test_theoritical_price

View file

@ -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)

View 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.

View file

@ -0,0 +1 @@
from . import models

View 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,
}

View file

@ -0,0 +1,3 @@
from . import mrp_production
from . import product_product
from . import product_template

View file

@ -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

View file

@ -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

View file

@ -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

View 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.

View file

@ -0,0 +1 @@
from . import test_product_sale_price_from_pricelist_mrp

View file

@ -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)

View file

@ -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>