[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:
parent
9484f8c349
commit
b484e3dc2e
5 changed files with 266 additions and 6 deletions
|
|
@ -5,6 +5,7 @@
|
||||||
from odoo import api
|
from odoo import api
|
||||||
from odoo import fields
|
from odoo import fields
|
||||||
from odoo import models
|
from odoo import models
|
||||||
|
from odoo.tools import float_compare
|
||||||
|
|
||||||
|
|
||||||
class ProductTemplate(models.Model):
|
class ProductTemplate(models.Model):
|
||||||
|
|
@ -172,3 +173,52 @@ class ProductTemplate(models.Model):
|
||||||
def action_update_list_price(self):
|
def action_update_list_price(self):
|
||||||
"""Delegate to product variants."""
|
"""Delegate to product variants."""
|
||||||
return self.product_variant_ids.action_update_list_price()
|
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"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,3 +6,4 @@ from . import test_pricelist
|
||||||
from . import test_res_config
|
from . import test_res_config
|
||||||
from . import test_theoretical_price
|
from . import test_theoretical_price
|
||||||
from . import test_supplierinfo_sync
|
from . import test_supplierinfo_sync
|
||||||
|
from . import test_sync_action
|
||||||
|
|
|
||||||
|
|
@ -269,22 +269,26 @@ class TestStockMove(TransactionCase):
|
||||||
self.assertEqual(product_dozen.last_purchase_price_received, 10.0)
|
self.assertEqual(product_dozen.last_purchase_price_received, 10.0)
|
||||||
|
|
||||||
def test_no_update_with_zero_quantity(self):
|
def test_no_update_with_zero_quantity(self):
|
||||||
"""Test that price is not updated with zero quantity done"""
|
"""Zero-quantity receipt must not change last_purchase_price_received.
|
||||||
initial_price = self.product.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 = self._create_purchase_order(self.product, qty=10, price=15.0)
|
||||||
purchase_order.button_confirm()
|
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 = purchase_order.picking_ids[0]
|
||||||
picking.action_assign()
|
picking.action_assign()
|
||||||
|
|
||||||
# Set quantity done to 0
|
|
||||||
for move in picking.move_ids:
|
for move in picking.move_ids:
|
||||||
move.quantity = 0
|
move.quantity = 0
|
||||||
|
|
||||||
# This should not update the price
|
# Stock move with qty=0 must leave the price untouched.
|
||||||
# Price should remain unchanged
|
self.assertEqual(self.product.last_purchase_price_received, price_after_confirm)
|
||||||
self.assertEqual(self.product.last_purchase_price_received, initial_price)
|
|
||||||
|
|
||||||
def test_manual_update_type_no_automatic_update(self):
|
def test_manual_update_type_no_automatic_update(self):
|
||||||
"""Test that manual update type prevents automatic price updates"""
|
"""Test that manual update type prevents automatic price updates"""
|
||||||
|
|
|
||||||
194
product_sale_price_from_pricelist/tests/test_sync_action.py
Normal file
194
product_sale_price_from_pricelist/tests/test_sync_action.py
Normal 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")
|
||||||
|
|
@ -18,4 +18,15 @@
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</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>
|
</odoo>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue