diff --git a/product_sale_price_from_pricelist/__manifest__.py b/product_sale_price_from_pricelist/__manifest__.py index f65a0da..e2d20f3 100644 --- a/product_sale_price_from_pricelist/__manifest__.py +++ b/product_sale_price_from_pricelist/__manifest__.py @@ -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": """ diff --git a/product_sale_price_from_pricelist/models/product_template.py b/product_sale_price_from_pricelist/models/product_template.py index 449baf6..a3098c7 100644 --- a/product_sale_price_from_pricelist/models/product_template.py +++ b/product_sale_price_from_pricelist/models/product_template.py @@ -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: diff --git a/product_sale_price_from_pricelist/tests/__init__.py b/product_sale_price_from_pricelist/tests/__init__.py new file mode 100644 index 0000000..cc00c09 --- /dev/null +++ b/product_sale_price_from_pricelist/tests/__init__.py @@ -0,0 +1 @@ +from . import test_theoritical_price diff --git a/product_sale_price_from_pricelist/tests/test_theoritical_price.py b/product_sale_price_from_pricelist/tests/test_theoritical_price.py new file mode 100644 index 0000000..07b97a4 --- /dev/null +++ b/product_sale_price_from_pricelist/tests/test_theoritical_price.py @@ -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) diff --git a/product_sale_price_from_pricelist_mrp/README.md b/product_sale_price_from_pricelist_mrp/README.md new file mode 100644 index 0000000..39e8141 --- /dev/null +++ b/product_sale_price_from_pricelist_mrp/README.md @@ -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. diff --git a/product_sale_price_from_pricelist_mrp/__init__.py b/product_sale_price_from_pricelist_mrp/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/product_sale_price_from_pricelist_mrp/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/product_sale_price_from_pricelist_mrp/__manifest__.py b/product_sale_price_from_pricelist_mrp/__manifest__.py new file mode 100644 index 0000000..84845da --- /dev/null +++ b/product_sale_price_from_pricelist_mrp/__manifest__.py @@ -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, +} diff --git a/product_sale_price_from_pricelist_mrp/models/__init__.py b/product_sale_price_from_pricelist_mrp/models/__init__.py new file mode 100644 index 0000000..dab9384 --- /dev/null +++ b/product_sale_price_from_pricelist_mrp/models/__init__.py @@ -0,0 +1,3 @@ +from . import mrp_production +from . import product_product +from . import product_template diff --git a/product_sale_price_from_pricelist_mrp/models/mrp_production.py b/product_sale_price_from_pricelist_mrp/models/mrp_production.py new file mode 100644 index 0000000..a63af63 --- /dev/null +++ b/product_sale_price_from_pricelist_mrp/models/mrp_production.py @@ -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 diff --git a/product_sale_price_from_pricelist_mrp/models/product_product.py b/product_sale_price_from_pricelist_mrp/models/product_product.py new file mode 100644 index 0000000..e6459b2 --- /dev/null +++ b/product_sale_price_from_pricelist_mrp/models/product_product.py @@ -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 diff --git a/product_sale_price_from_pricelist_mrp/models/product_template.py b/product_sale_price_from_pricelist_mrp/models/product_template.py new file mode 100644 index 0000000..44cf13a --- /dev/null +++ b/product_sale_price_from_pricelist_mrp/models/product_template.py @@ -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 diff --git a/product_sale_price_from_pricelist_mrp/specs/requirements.md b/product_sale_price_from_pricelist_mrp/specs/requirements.md new file mode 100644 index 0000000..b676cd0 --- /dev/null +++ b/product_sale_price_from_pricelist_mrp/specs/requirements.md @@ -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. diff --git a/product_sale_price_from_pricelist_mrp/tests/__init__.py b/product_sale_price_from_pricelist_mrp/tests/__init__.py new file mode 100644 index 0000000..499d85b --- /dev/null +++ b/product_sale_price_from_pricelist_mrp/tests/__init__.py @@ -0,0 +1 @@ +from . import test_product_sale_price_from_pricelist_mrp diff --git a/product_sale_price_from_pricelist_mrp/tests/test_product_sale_price_from_pricelist_mrp.py b/product_sale_price_from_pricelist_mrp/tests/test_product_sale_price_from_pricelist_mrp.py new file mode 100644 index 0000000..fb03453 --- /dev/null +++ b/product_sale_price_from_pricelist_mrp/tests/test_product_sale_price_from_pricelist_mrp.py @@ -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) diff --git a/product_sale_price_from_pricelist_mrp/views/product_views.xml b/product_sale_price_from_pricelist_mrp/views/product_views.xml new file mode 100644 index 0000000..bf2f38b --- /dev/null +++ b/product_sale_price_from_pricelist_mrp/views/product_views.xml @@ -0,0 +1,19 @@ + + + + product.template.form.last.purchase.price.service + product.template + + + + + + {'readonly': [('detailed_type', '!=', 'service')]} + + + + +