[ADD] product_sale_price_from_pricelist: update sale orders from list price

This commit is contained in:
snt 2026-02-27 17:05:54 +01:00
parent ced21cc489
commit 55497327e8
7 changed files with 292 additions and 7 deletions

View file

@ -2,7 +2,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{ # noqa: B018
"name": "Product Sale Price from Pricelist",
"version": "18.0.2.6.0",
"version": "18.0.2.7.0",
"category": "product",
"summary": "Set sale price from pricelist based on last purchase price",
"author": "Odoo Community Association (OCA), Criptomart",
@ -20,5 +20,6 @@
"data": [
"views/actions.xml",
"views/product_view.xml",
"views/sale_order_view.xml",
],
}

View file

@ -187,6 +187,32 @@ msgstr "Triple descuento"
msgid "Update Sales Price"
msgstr "Actualizar Precio de Venta"
#. module: product_sale_price_from_pricelist
#: model:ir.actions.server,name:product_sale_price_from_pricelist.action_sale_update_from_list_price
#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.view_order_form_update_list_price
msgid "Update with List Price"
msgstr "Actualizar con precio de venta"
#. module: product_sale_price_from_pricelist
#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.view_order_form_update_list_price
msgid "Update skipped"
msgstr "Actualización omitida"
#. module: product_sale_price_from_pricelist
#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.view_order_form_update_list_price
msgid "No sales orders to update. Only non-invoiced and non-canceled orders are processed."
msgstr "No hay pedidos de venta que actualizar. Solo se procesan los no facturados ni cancelados."
#. module: product_sale_price_from_pricelist
#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.view_order_form_update_list_price
msgid "Sales price update"
msgstr "Actualización de precios de venta"
#. module: product_sale_price_from_pricelist
#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.view_order_form_update_list_price
msgid "Updated %(lines)s lines across %(orders)s sales orders."
msgstr "Se actualizaron %(lines)s líneas en %(orders)s pedidos de venta."
#. module: product_sale_price_from_pricelist
#: model:ir.model.fields.selection,name:product_sale_price_from_pricelist.selection__product_template__last_purchase_price_compute_type__without_discounts
msgid "Without discounts"

View file

@ -0,0 +1,43 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# \t* product_sale_price_from_pricelist
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 18.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-02-27 00:00+0000\n"
"PO-Revision-Date: 2026-02-27 00:00+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: eu\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: product_sale_price_from_pricelist
#: model:ir.actions.server,name:product_sale_price_from_pricelist.action_sale_update_from_list_price
#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.view_order_form_update_list_price
msgid "Update with List Price"
msgstr "Eguneratu tarifa-prezioarekin"
#. module: product_sale_price_from_pricelist
#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.view_order_form_update_list_price
msgid "Update skipped"
msgstr "Eguneratzea saltatu da"
#. module: product_sale_price_from_pricelist
#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.view_order_form_update_list_price
msgid "No sales orders to update. Only non-invoiced and non-canceled orders are processed."
msgstr "Ez dago eguneratzeko salmenta-agindurik. Soilik fakturatu gabe eta bertan behera utzi gabe daudenak prozesatzen dira."
#. module: product_sale_price_from_pricelist
#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.view_order_form_update_list_price
msgid "Sales price update"
msgstr "Salmenta-prezioaren eguneraketa"
#. module: product_sale_price_from_pricelist
#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.view_order_form_update_list_price
msgid "Updated %(lines)s lines across %(orders)s sales orders."
msgstr "Eguneratu dira %(orders)s salmenta-agindutako %(lines)s lerro."

View file

@ -1,6 +1,7 @@
from . import product_pricelist
from . import product_pricelist_item
from . import product_product
from . import product_template
from . import res_config
from . import stock_move
from . import product_pricelist # noqa: F401
from . import product_pricelist_item # noqa: F401
from . import product_product # noqa: F401
from . import product_template # noqa: F401
from . import res_config # noqa: F401
from . import stock_move # noqa: F401
from . import sale_order # noqa: F401

View file

@ -0,0 +1,72 @@
import logging
from odoo import models
_logger = logging.getLogger(__name__)
class SaleOrder(models.Model):
_inherit = "sale.order"
def action_update_lines_from_list_price(self):
"""Copy current product list price into sale order lines.
Applies to orders that are not fully invoiced. Skips display and
downpayment lines, keeps discounts. Totals/taxes are recomputed by
field dependencies when the lines change.
"""
eligible_orders = self.filtered(
lambda so: so.invoice_status not in ("invoiced", "up_to_date")
and so.state != "cancel"
)
if not eligible_orders:
return {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": self.env._("Update skipped"),
"message": self.env._(
"No sales orders to update. Only non-invoiced and non-canceled orders are processed."
),
"type": "warning",
"sticky": False,
},
}
total_lines = 0
for order in eligible_orders:
lines = order.order_line.filtered(
lambda line: not line.display_type
and not line.is_downpayment
and line.product_id
)
for line in lines:
price_unit = line.product_id.uom_id._compute_price(
line.product_id.lst_price, line.product_uom
)
line.price_unit = price_unit
total_lines += len(lines)
_logger.info(
"[SALE PRICE UPDATE] Order %s (%s): updated %s lines",
order.name,
order.id,
len(lines),
)
return {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": self.env._("Sales price update"),
"message": self.env._(
"Updated %(lines)s lines across %(orders)s sales orders.",
lines=total_lines,
orders=len(eligible_orders),
),
"type": "success" if total_lines else "warning",
"sticky": False,
},
}

View file

@ -0,0 +1,95 @@
# Copyright (C) 2026: 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 TestSaleOrderUpdate(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env.ref("base.res_partner_1")
cls.product = cls.env["product.product"].create(
{
"name": "Test Product SO",
"list_price": 120.0,
"standard_price": 80.0,
}
)
cls.downpayment_product = cls.env["product.product"].create(
{
"name": "Downpayment Product",
"type": "service",
"list_price": 10.0,
}
)
def _create_sale_order(self, values=None):
vals = {
"partner_id": self.partner.id,
"order_line": [
(
0,
0,
{
"product_id": self.product.id,
"product_uom": self.product.uom_id.id,
"product_uom_qty": 2.0,
"price_unit": 50.0,
"tax_id": [(6, 0, self.product.taxes_id.ids)],
},
),
(0, 0, {"display_type": "line_note", "name": "Note"}),
(
0,
0,
{
"product_id": self.downpayment_product.id,
"product_uom": self.downpayment_product.uom_id.id,
"product_uom_qty": 1.0,
"price_unit": 10.0,
"is_downpayment": True,
},
),
],
}
if values:
vals.update(values)
return self.env["sale.order"].create(vals)
def test_update_lines_copies_list_price(self):
order = self._create_sale_order()
order.action_update_lines_from_list_price()
line = order.order_line.filtered(lambda line: line.product_id == self.product)
self.assertTrue(line)
self.assertEqual(line.price_unit, self.product.list_price)
# Downpayment and display lines remain unchanged
down_line = order.order_line.filtered("is_downpayment")
self.assertTrue(down_line)
self.assertEqual(down_line.price_unit, 10.0)
def test_invoiced_orders_are_skipped(self):
order = self._create_sale_order()
order.action_confirm()
invoice = order._create_invoices()
invoice.action_post()
order._compute_invoice_status()
self.assertEqual(order.invoice_status, "invoiced")
# Change product price to ensure potential change
order.order_line.filtered(
lambda line: line.product_id == self.product
).price_unit = 5.0
order.action_update_lines_from_list_price()
# Price should remain untouched because order is invoiced
line = order.order_line.filtered(lambda line: line.product_id == self.product)
self.assertEqual(line.price_unit, 5.0)

View file

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data>
<!-- Server action for mass update from list view -->
<record id="action_sale_update_from_list_price" model="ir.actions.server">
<field name="name">Update with List Price</field>
<field name="model_id" ref="sale.model_sale_order" />
<field name="binding_model_id" ref="sale.model_sale_order" />
<field name="binding_view_types">list,form</field>
<field name="state">code</field>
<field name="code">records.action_update_lines_from_list_price()</field>
</record>
<!-- Button in sale order form -->
<record id="view_order_form_update_list_price" model="ir.ui.view">
<field name="name">sale.order.form.update.list.price</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form" />
<field name="arch" type="xml">
<xpath expr="//button[@name='action_update_prices']" position="after">
<button
name="action_update_lines_from_list_price"
type="object"
string="Update with List Price"
class="btn-link mb-1 px-0"
icon="fa-refresh"
invisible="invoice_status in ('invoiced','up_to_date') or state == 'cancel'"
/>
</xpath>
</field>
</record>
<!-- Action menu entry in tree/list view -->
<record id="view_order_tree_update_list_price" model="ir.ui.view">
<field name="name">sale.order.tree.update.list.price</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_quotation_tree" />
<field name="arch" type="xml">
<xpath expr="//list" position="attributes">
<attribute name="decoration-muted">state == 'cancel'</attribute>
</xpath>
</field>
</record>
</data>
</odoo>