[ADD] product_sale_price_from_pricelist: server action to bulk-correct purchase price from supplierinfo

- Add action_sync_purchase_price_from_supplierinfo on product.template:
  iterates variants, applies discount chain from main supplierinfo
  (according to last_purchase_price_compute_type), writes net price to
  last_purchase_price_received, recomputes theoretical sale price.
  Skips manual_update variants and products without a supplier.
  Returns a display_notification with updated/skipped counts.
- Register it as ir.actions.server bound to product.template list view.
- Add test_sync_action.py: 10 tests covering all discount types, skip
  cases, theoretical price recompute, notification type, idempotency,
  and bulk multi-template execution.
- Fix test_no_update_with_zero_quantity: update assertion to reflect that
  PO confirmation now syncs the cost via supplierinfo (intended behavior).
This commit is contained in:
GitHub Copilot 2026-06-12 16:30:15 +02:00
parent 9484f8c349
commit b484e3dc2e
5 changed files with 266 additions and 6 deletions

View file

@ -5,6 +5,7 @@
from odoo import api
from odoo import fields
from odoo import models
from odoo.tools import float_compare
class ProductTemplate(models.Model):
@ -172,3 +173,52 @@ class ProductTemplate(models.Model):
def action_update_list_price(self):
"""Delegate to product variants."""
return self.product_variant_ids.action_update_list_price()
def action_sync_purchase_price_from_supplierinfo(self):
"""Bulk-correct last_purchase_price_received from the main supplierinfo,
applying discounts according to last_purchase_price_compute_type."""
updated = 0
skipped_manual = 0
skipped_no_seller = 0
for product in self.product_variant_ids:
if product.last_purchase_price_compute_type == "manual_update":
skipped_manual += 1
continue
seller = product._get_main_supplierinfo()
if not seller:
skipped_no_seller += 1
continue
net_price = product._compute_supplierinfo_net_price(seller)
if (
float_compare(
product.last_purchase_price_received, net_price, precision_digits=2
)
!= 0
):
product.write({"last_purchase_price_received": net_price})
product._recompute_theoritical_price_safe()
updated += 1
parts = []
if updated:
parts.append(self.env._("Updated: %(n)s", n=updated))
if skipped_manual:
parts.append(self.env._("Skipped (manual update): %(n)s", n=skipped_manual))
if skipped_no_seller:
parts.append(
self.env._("Skipped (no supplier): %(n)s", n=skipped_no_seller)
)
message = "\n".join(parts) if parts else self.env._("Nothing to correct.")
return {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": self.env._("Sync Purchase Price"),
"message": message,
"type": "success" if updated else "warning",
"sticky": False,
"next": {"type": "ir.actions.act_window_close"},
},
}

View file

@ -6,3 +6,4 @@ from . import test_pricelist
from . import test_res_config
from . import test_theoretical_price
from . import test_supplierinfo_sync
from . import test_sync_action

View file

@ -269,22 +269,26 @@ class TestStockMove(TransactionCase):
self.assertEqual(product_dozen.last_purchase_price_received, 10.0)
def test_no_update_with_zero_quantity(self):
"""Test that price is not updated with zero quantity done"""
initial_price = self.product.last_purchase_price_received
"""Zero-quantity receipt must not change last_purchase_price_received.
Confirming the PO creates a supplierinfo line which syncs the cost to
15.0 (no discounts). The stock-move path must NOT overwrite that value
when no units are actually received.
"""
purchase_order = self._create_purchase_order(self.product, qty=10, price=15.0)
purchase_order.button_confirm()
# Supplierinfo sync sets the price to 15.0 after PO confirmation.
price_after_confirm = self.product.last_purchase_price_received
picking = purchase_order.picking_ids[0]
picking.action_assign()
# Set quantity done to 0
for move in picking.move_ids:
move.quantity = 0
# This should not update the price
# Price should remain unchanged
self.assertEqual(self.product.last_purchase_price_received, initial_price)
# Stock move with qty=0 must leave the price untouched.
self.assertEqual(self.product.last_purchase_price_received, price_after_confirm)
def test_manual_update_type_no_automatic_update(self):
"""Test that manual update type prevents automatic price updates"""

View file

@ -0,0 +1,194 @@
# Copyright (C) 2020: Criptomart (https://criptomart.net)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged("post_install", "-at_install")
class TestSyncPurchasePriceAction(TransactionCase):
"""Cover action_sync_purchase_price_from_supplierinfo on product.template.
The action is used to bulk-correct last_purchase_price_received for products
whose cost was written without applying supplier discounts.
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.has_discounts = hasattr(cls.env["product.supplierinfo"], "discount1")
cls.tax = cls.env["account.tax"].create(
{
"name": "Test Tax 21% (Action)",
"amount": 21.0,
"amount_type": "percent",
"type_tax_use": "sale",
}
)
cls.pricelist = cls.env["product.pricelist"].create(
{
"name": "Test Pricelist (Action)",
"currency_id": cls.env.company.currency_id.id,
}
)
cls.env["product.pricelist.item"].create(
{
"pricelist_id": cls.pricelist.id,
"compute_price": "formula",
"base": "last_purchase_price",
"price_markup": 50.0,
"applied_on": "3_global",
}
)
cls.env["ir.config_parameter"].sudo().set_param(
"product_sale_price_from_pricelist.product_pricelist_automatic",
str(cls.pricelist.id),
)
cls.supplier = cls.env["res.partner"].create(
{"name": "Test Supplier (Action)", "supplier_rank": 1}
)
cls.uom_unit = cls.env.ref("uom.product_uom_unit")
def setUp(self):
super().setUp()
if not self.has_discounts:
self.skipTest("purchase_triple_discount module not installed")
def _make_product(self, name, compute_type, cost=0.0):
product = self.env["product.product"].create(
{
"name": name,
"uom_id": self.uom_unit.id,
"uom_po_id": self.uom_unit.id,
"list_price": 10.0,
"standard_price": cost,
"taxes_id": [(6, 0, [self.tax.id])],
"last_purchase_price_received": cost,
"last_purchase_price_compute_type": compute_type,
}
)
return product
def _add_supplierinfo(
self, product, price, discount1=0.0, discount2=0.0, discount3=0.0
):
return (
self.env["product.supplierinfo"]
.with_context(skip_last_purchase_price_sync=True)
.create(
{
"partner_id": self.supplier.id,
"product_tmpl_id": product.product_tmpl_id.id,
"price": price,
"discount1": discount1,
"discount2": discount2,
"discount3": discount3,
"sequence": 1,
}
)
)
def _run_action(self, template):
return template.action_sync_purchase_price_from_supplierinfo()
def test_action_corrects_gross_price_no_discounts(self):
"""without_discounts: action keeps the gross price as-is."""
product = self._make_product("P-NoDsc", "without_discounts", cost=999.0)
self._add_supplierinfo(product, price=20.0)
self._run_action(product.product_tmpl_id)
self.assertAlmostEqual(product.last_purchase_price_received, 20.0, places=2)
def test_action_corrects_with_first_discount(self):
"""with_discount: action applies discount1 only."""
product = self._make_product("P-D1", "with_discount", cost=999.0)
self._add_supplierinfo(product, price=10.0, discount1=20.0, discount2=10.0)
self._run_action(product.product_tmpl_id)
# 10 * 0.80 = 8.0
self.assertAlmostEqual(product.last_purchase_price_received, 8.0, places=2)
def test_action_corrects_with_two_discounts(self):
"""with_two_discounts: action applies discount1 and discount2."""
product = self._make_product("P-D2", "with_two_discounts", cost=999.0)
self._add_supplierinfo(
product, price=10.0, discount1=20.0, discount2=10.0, discount3=5.0
)
self._run_action(product.product_tmpl_id)
# 10 * 0.80 * 0.90 = 7.2
self.assertAlmostEqual(product.last_purchase_price_received, 7.2, places=2)
def test_action_corrects_with_three_discounts(self):
"""with_three_discounts: action applies all three discounts."""
product = self._make_product("P-D3", "with_three_discounts", cost=999.0)
self._add_supplierinfo(
product, price=10.0, discount1=20.0, discount2=10.0, discount3=5.0
)
self._run_action(product.product_tmpl_id)
# 10 * 0.80 * 0.90 * 0.95 = 6.84
self.assertAlmostEqual(product.last_purchase_price_received, 6.84, places=2)
def test_action_skips_manual_update(self):
"""manual_update: action never touches the cost."""
product = self._make_product("P-Manual", "manual_update", cost=42.0)
self._add_supplierinfo(product, price=10.0, discount1=50.0)
self._run_action(product.product_tmpl_id)
self.assertAlmostEqual(product.last_purchase_price_received, 42.0, places=2)
def test_action_skips_product_without_seller(self):
"""Products with no supplierinfo are left untouched."""
product = self._make_product("P-NoSeller", "with_three_discounts", cost=99.0)
result = self._run_action(product.product_tmpl_id)
self.assertAlmostEqual(product.last_purchase_price_received, 99.0, places=2)
self.assertIn("warning", result["params"]["type"])
def test_action_also_recomputes_theoretical_price(self):
"""After correcting the cost the theoretical sale price is refreshed."""
product = self._make_product(
"P-Theoretical", "with_three_discounts", cost=999.0
)
self._add_supplierinfo(product, price=1.53, discount1=15.0)
self._run_action(product.product_tmpl_id)
# net = 1.53 * 0.85 = 1.30, theoretical = 1.30 * 1.5 = 1.95
self.assertAlmostEqual(product.last_purchase_price_received, 1.30, places=2)
self.assertAlmostEqual(product.list_price_theoritical, 1.95, places=2)
def test_action_returns_success_notification(self):
"""Return value is a display_notification client action with type success."""
product = self._make_product("P-Notif", "with_three_discounts", cost=999.0)
self._add_supplierinfo(product, price=10.0, discount1=20.0)
result = self._run_action(product.product_tmpl_id)
self.assertEqual(result["type"], "ir.actions.client")
self.assertEqual(result["tag"], "display_notification")
self.assertEqual(result["params"]["type"], "success")
def test_action_idempotent(self):
"""Running the action twice leaves the price unchanged on the second run."""
product = self._make_product("P-Idem", "with_three_discounts", cost=999.0)
self._add_supplierinfo(product, price=10.0, discount1=20.0)
self._run_action(product.product_tmpl_id)
price_after_first = product.last_purchase_price_received
result = self._run_action(product.product_tmpl_id)
self.assertAlmostEqual(
product.last_purchase_price_received, price_after_first, places=2
)
# No products were changed on the second run → warning (nothing updated)
self.assertEqual(result["params"]["type"], "warning")
def test_action_bulk_multiple_products(self):
"""Action works correctly when called on multiple templates at once."""
p1 = self._make_product("P-Bulk1", "with_discount", cost=999.0)
p2 = self._make_product("P-Bulk2", "with_two_discounts", cost=999.0)
self._add_supplierinfo(p1, price=10.0, discount1=10.0)
self._add_supplierinfo(p2, price=10.0, discount1=10.0, discount2=10.0)
templates = p1.product_tmpl_id | p2.product_tmpl_id
result = self._run_action(templates)
# p1: 10 * 0.90 = 9.0
self.assertAlmostEqual(p1.last_purchase_price_received, 9.0, places=2)
# p2: 10 * 0.90 * 0.90 = 8.1
self.assertAlmostEqual(p2.last_purchase_price_received, 8.1, places=2)
self.assertEqual(result["params"]["type"], "success")

View file

@ -18,4 +18,15 @@
</field>
</record>
<!-- Bulk-correct last_purchase_price_received from main supplierinfo discounts -->
<record id="action_sync_purchase_price_from_supplierinfo" model="ir.actions.server">
<field name="name">Sync Purchase Price from Supplier</field>
<field name="model_id" ref="product.model_product_template"/>
<field name="binding_model_id" ref="product.model_product_template"/>
<field name="state">code</field>
<field name="code">
records.action_sync_purchase_price_from_supplierinfo()
</field>
</record>
</odoo>