Compare commits
4 commits
67554c95f5
...
80c2617c40
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80c2617c40 | ||
|
|
e27cacd65b | ||
|
|
1bcc31b810 | ||
|
|
123aabb775 |
22 changed files with 1481 additions and 120 deletions
|
|
@ -2,17 +2,12 @@
|
||||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||||
{
|
{
|
||||||
"name": "Account Invoice Triple Discount Readonly",
|
"name": "Account Invoice Triple Discount Readonly",
|
||||||
"version": "16.0.1.0.0",
|
"version": "18.0.1.0.0",
|
||||||
"summary": "Make total discount readonly and fix discount2/discount3 write issue",
|
"summary": "Make total discount readonly and fix discount2/discount3 write issue",
|
||||||
"license": "AGPL-3",
|
"license": "AGPL-3",
|
||||||
"author": "Criptomart",
|
"author": "Criptomart",
|
||||||
"website": "https://github.com/OCA/account-invoicing",
|
"website": "https://github.com/OCA/account-invoicing",
|
||||||
"depends": ["account_invoice_triple_discount", "purchase_triple_discount"],
|
"depends": ["account_invoice_triple_discount", "purchase_triple_discount"],
|
||||||
"data": [
|
"data": [],
|
||||||
"views/product_supplierinfo_view.xml",
|
|
||||||
"views/res_partner_view.xml",
|
|
||||||
"views/purchase_order_view.xml",
|
|
||||||
"views/account_move_view.xml",
|
|
||||||
],
|
|
||||||
"installable": True,
|
"installable": True,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
# OCA Dependencies for account_invoice_triple_discount_readonly
|
||||||
|
# Format: repository_name branch
|
||||||
|
|
||||||
|
account-invoicing https://github.com/OCA/account-invoicing.git 18.0
|
||||||
|
purchase-workflow https://github.com/OCA/purchase-workflow.git 18.0
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
from . import test_triple_discount_mixin
|
||||||
|
from . import test_account_move
|
||||||
|
from . import test_purchase_order
|
||||||
|
|
@ -0,0 +1,187 @@
|
||||||
|
# Copyright (C) 2025: 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 TestAccountMove(TransactionCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
|
||||||
|
# Create a partner
|
||||||
|
cls.partner = cls.env["res.partner"].create({
|
||||||
|
"name": "Test Customer",
|
||||||
|
"email": "customer@test.com",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create a product
|
||||||
|
cls.product = cls.env["product.product"].create({
|
||||||
|
"name": "Test Product Invoice",
|
||||||
|
"type": "consu",
|
||||||
|
"list_price": 200.0,
|
||||||
|
"standard_price": 100.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create tax
|
||||||
|
cls.tax = cls.env["account.tax"].create({
|
||||||
|
"name": "Test Tax 10%",
|
||||||
|
"amount": 10.0,
|
||||||
|
"amount_type": "percent",
|
||||||
|
"type_tax_use": "sale",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create an invoice
|
||||||
|
cls.invoice = cls.env["account.move"].create({
|
||||||
|
"move_type": "out_invoice",
|
||||||
|
"partner_id": cls.partner.id,
|
||||||
|
"invoice_date": "2026-01-01",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create invoice line
|
||||||
|
cls.invoice_line = cls.env["account.move.line"].create({
|
||||||
|
"move_id": cls.invoice.id,
|
||||||
|
"product_id": cls.product.id,
|
||||||
|
"quantity": 5,
|
||||||
|
"price_unit": 200.0,
|
||||||
|
"discount1": 10.0,
|
||||||
|
"discount2": 5.0,
|
||||||
|
"discount3": 2.0,
|
||||||
|
"tax_ids": [(6, 0, [cls.tax.id])],
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_invoice_line_discount_readonly(self):
|
||||||
|
"""Test that discount field is readonly in invoice lines"""
|
||||||
|
field = self.invoice_line._fields["discount"]
|
||||||
|
self.assertTrue(field.readonly, "Discount field should be readonly in invoice lines")
|
||||||
|
|
||||||
|
def test_invoice_line_write_with_explicit_discounts(self):
|
||||||
|
"""Test writing invoice line with explicit discounts"""
|
||||||
|
self.invoice_line.write({
|
||||||
|
"discount": 30.0, # Should be ignored
|
||||||
|
"discount1": 15.0,
|
||||||
|
"discount2": 10.0,
|
||||||
|
"discount3": 5.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertEqual(self.invoice_line.discount1, 15.0)
|
||||||
|
self.assertEqual(self.invoice_line.discount2, 10.0)
|
||||||
|
self.assertEqual(self.invoice_line.discount3, 5.0)
|
||||||
|
|
||||||
|
def test_invoice_line_legacy_discount(self):
|
||||||
|
"""Test legacy discount behavior in invoice lines"""
|
||||||
|
self.invoice_line.write({
|
||||||
|
"discount": 20.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Should map to discount1 and reset others
|
||||||
|
self.assertEqual(self.invoice_line.discount1, 20.0)
|
||||||
|
self.assertEqual(self.invoice_line.discount2, 0.0)
|
||||||
|
self.assertEqual(self.invoice_line.discount3, 0.0)
|
||||||
|
|
||||||
|
def test_invoice_line_price_calculation(self):
|
||||||
|
"""Test that price subtotal is calculated correctly with triple discount"""
|
||||||
|
self.invoice_line.write({
|
||||||
|
"discount1": 10.0,
|
||||||
|
"discount2": 5.0,
|
||||||
|
"discount3": 0.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Base: 5 * 200 = 1000
|
||||||
|
# After 10% discount: 900
|
||||||
|
# After 5% discount: 855
|
||||||
|
expected_subtotal = 5 * 200 * 0.9 * 0.95
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
self.invoice_line.price_subtotal, expected_subtotal, places=2
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_multiple_invoice_lines(self):
|
||||||
|
"""Test multiple invoice lines with different discounts"""
|
||||||
|
line2 = self.env["account.move.line"].create({
|
||||||
|
"move_id": self.invoice.id,
|
||||||
|
"product_id": self.product.id,
|
||||||
|
"quantity": 3,
|
||||||
|
"price_unit": 150.0,
|
||||||
|
"discount1": 20.0,
|
||||||
|
"discount2": 10.0,
|
||||||
|
"discount3": 5.0,
|
||||||
|
"tax_ids": [(6, 0, [self.tax.id])],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Verify both lines have correct discounts
|
||||||
|
self.assertEqual(self.invoice_line.discount1, 10.0)
|
||||||
|
self.assertEqual(line2.discount1, 20.0)
|
||||||
|
self.assertEqual(line2.discount2, 10.0)
|
||||||
|
self.assertEqual(line2.discount3, 5.0)
|
||||||
|
|
||||||
|
def test_invoice_line_update_quantity(self):
|
||||||
|
"""Test updating quantity doesn't affect discounts"""
|
||||||
|
initial_discount1 = self.invoice_line.discount1
|
||||||
|
initial_discount2 = self.invoice_line.discount2
|
||||||
|
|
||||||
|
self.invoice_line.write({
|
||||||
|
"quantity": 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Discounts should remain unchanged
|
||||||
|
self.assertEqual(self.invoice_line.discount1, initial_discount1)
|
||||||
|
self.assertEqual(self.invoice_line.discount2, initial_discount2)
|
||||||
|
# Quantity should be updated
|
||||||
|
self.assertEqual(self.invoice_line.quantity, 10)
|
||||||
|
|
||||||
|
def test_invoice_line_update_price(self):
|
||||||
|
"""Test updating price doesn't affect discounts"""
|
||||||
|
initial_discount1 = self.invoice_line.discount1
|
||||||
|
|
||||||
|
self.invoice_line.write({
|
||||||
|
"price_unit": 250.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Discount should remain unchanged
|
||||||
|
self.assertEqual(self.invoice_line.discount1, initial_discount1)
|
||||||
|
# Price should be updated
|
||||||
|
self.assertEqual(self.invoice_line.price_unit, 250.0)
|
||||||
|
|
||||||
|
def test_invoice_with_zero_discounts(self):
|
||||||
|
"""Test invoice line with all zero discounts"""
|
||||||
|
self.invoice_line.write({
|
||||||
|
"discount1": 0.0,
|
||||||
|
"discount2": 0.0,
|
||||||
|
"discount3": 0.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# All discounts should be zero
|
||||||
|
self.assertEqual(self.invoice_line.discount, 0.0)
|
||||||
|
self.assertEqual(self.invoice_line.discount1, 0.0)
|
||||||
|
self.assertEqual(self.invoice_line.discount2, 0.0)
|
||||||
|
self.assertEqual(self.invoice_line.discount3, 0.0)
|
||||||
|
|
||||||
|
# Subtotal should be quantity * price
|
||||||
|
expected = 5 * 200
|
||||||
|
self.assertEqual(self.invoice_line.price_subtotal, expected)
|
||||||
|
|
||||||
|
def test_invoice_line_combined_operations(self):
|
||||||
|
"""Test combined operations on invoice line"""
|
||||||
|
# Update multiple fields at once
|
||||||
|
self.invoice_line.write({
|
||||||
|
"quantity": 8,
|
||||||
|
"price_unit": 180.0,
|
||||||
|
"discount1": 12.0,
|
||||||
|
"discount2": 6.0,
|
||||||
|
"discount3": 0.0, # Reset discount3 explicitly
|
||||||
|
})
|
||||||
|
|
||||||
|
# All fields should be updated correctly
|
||||||
|
self.assertEqual(self.invoice_line.quantity, 8)
|
||||||
|
self.assertEqual(self.invoice_line.price_unit, 180.0)
|
||||||
|
self.assertEqual(self.invoice_line.discount1, 12.0)
|
||||||
|
self.assertEqual(self.invoice_line.discount2, 6.0)
|
||||||
|
self.assertEqual(self.invoice_line.discount3, 0.0)
|
||||||
|
|
||||||
|
# Calculate expected subtotal: 8 * 180 * (1-0.12) * (1-0.06)
|
||||||
|
expected = 8 * 180 * 0.88 * 0.94
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
self.invoice_line.price_subtotal, expected, places=2
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
# Copyright (C) 2025: 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 TestPurchaseOrder(TransactionCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
|
||||||
|
# Create a supplier
|
||||||
|
cls.supplier = cls.env["res.partner"].create({
|
||||||
|
"name": "Test Supplier",
|
||||||
|
"email": "supplier@test.com",
|
||||||
|
"supplier_rank": 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create a product
|
||||||
|
cls.product = cls.env["product.product"].create({
|
||||||
|
"name": "Test Product PO",
|
||||||
|
"type": "product",
|
||||||
|
"list_price": 150.0,
|
||||||
|
"standard_price": 80.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create a purchase order
|
||||||
|
cls.purchase_order = cls.env["purchase.order"].create({
|
||||||
|
"partner_id": cls.supplier.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create purchase order line
|
||||||
|
cls.po_line = cls.env["purchase.order.line"].create({
|
||||||
|
"order_id": cls.purchase_order.id,
|
||||||
|
"product_id": cls.product.id,
|
||||||
|
"product_qty": 10,
|
||||||
|
"price_unit": 150.0,
|
||||||
|
"discount1": 10.0,
|
||||||
|
"discount2": 5.0,
|
||||||
|
"discount3": 2.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_po_line_discount_readonly(self):
|
||||||
|
"""Test that discount field is readonly in PO lines"""
|
||||||
|
field = self.po_line._fields["discount"]
|
||||||
|
self.assertTrue(field.readonly, "Discount field should be readonly in PO lines")
|
||||||
|
|
||||||
|
def test_po_line_write_with_explicit_discounts(self):
|
||||||
|
"""Test writing PO line with explicit discounts"""
|
||||||
|
self.po_line.write({
|
||||||
|
"discount": 25.0, # Should be ignored
|
||||||
|
"discount1": 12.0,
|
||||||
|
"discount2": 8.0,
|
||||||
|
"discount3": 4.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertEqual(self.po_line.discount1, 12.0)
|
||||||
|
self.assertEqual(self.po_line.discount2, 8.0)
|
||||||
|
self.assertEqual(self.po_line.discount3, 4.0)
|
||||||
|
|
||||||
|
def test_po_line_legacy_discount(self):
|
||||||
|
"""Test legacy discount behavior in PO lines"""
|
||||||
|
self.po_line.write({
|
||||||
|
"discount": 18.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Should map to discount1 and reset others
|
||||||
|
self.assertEqual(self.po_line.discount1, 18.0)
|
||||||
|
self.assertEqual(self.po_line.discount2, 0.0)
|
||||||
|
self.assertEqual(self.po_line.discount3, 0.0)
|
||||||
|
|
||||||
|
def test_po_line_price_calculation(self):
|
||||||
|
"""Test that price subtotal is calculated correctly with triple discount"""
|
||||||
|
self.po_line.write({
|
||||||
|
"discount1": 15.0,
|
||||||
|
"discount2": 10.0,
|
||||||
|
"discount3": 5.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Base: 10 * 150 = 1500
|
||||||
|
# After 15% discount: 1275
|
||||||
|
# After 10% discount: 1147.5
|
||||||
|
# After 5% discount: 1090.125
|
||||||
|
expected_subtotal = 10 * 150 * 0.85 * 0.90 * 0.95
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
self.po_line.price_subtotal, expected_subtotal, places=2
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_multiple_po_lines(self):
|
||||||
|
"""Test multiple PO lines with different discounts"""
|
||||||
|
line2 = self.env["purchase.order.line"].create({
|
||||||
|
"order_id": self.purchase_order.id,
|
||||||
|
"product_id": self.product.id,
|
||||||
|
"product_qty": 5,
|
||||||
|
"price_unit": 120.0,
|
||||||
|
"discount1": 20.0,
|
||||||
|
"discount2": 15.0,
|
||||||
|
"discount3": 10.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Verify both lines have correct discounts
|
||||||
|
self.assertEqual(self.po_line.discount1, 15.0)
|
||||||
|
self.assertEqual(line2.discount1, 20.0)
|
||||||
|
self.assertEqual(line2.discount2, 15.0)
|
||||||
|
self.assertEqual(line2.discount3, 10.0)
|
||||||
|
|
||||||
|
def test_po_line_update_quantity(self):
|
||||||
|
"""Test updating quantity doesn't affect discounts"""
|
||||||
|
initial_discount1 = self.po_line.discount1
|
||||||
|
initial_discount2 = self.po_line.discount2
|
||||||
|
|
||||||
|
self.po_line.write({
|
||||||
|
"product_qty": 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Discounts should remain unchanged
|
||||||
|
self.assertEqual(self.po_line.discount1, initial_discount1)
|
||||||
|
self.assertEqual(self.po_line.discount2, initial_discount2)
|
||||||
|
# Quantity should be updated
|
||||||
|
self.assertEqual(self.po_line.product_qty, 20)
|
||||||
|
|
||||||
|
def test_po_line_update_price(self):
|
||||||
|
"""Test updating price doesn't affect discounts"""
|
||||||
|
initial_discount1 = self.po_line.discount1
|
||||||
|
|
||||||
|
self.po_line.write({
|
||||||
|
"price_unit": 200.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Discount should remain unchanged
|
||||||
|
self.assertEqual(self.po_line.discount1, initial_discount1)
|
||||||
|
# Price should be updated
|
||||||
|
self.assertEqual(self.po_line.price_unit, 200.0)
|
||||||
|
|
||||||
|
def test_po_with_zero_discounts(self):
|
||||||
|
"""Test PO line with all zero discounts"""
|
||||||
|
self.po_line.write({
|
||||||
|
"discount1": 0.0,
|
||||||
|
"discount2": 0.0,
|
||||||
|
"discount3": 0.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# All discounts should be zero
|
||||||
|
self.assertEqual(self.po_line.discount, 0.0)
|
||||||
|
self.assertEqual(self.po_line.discount1, 0.0)
|
||||||
|
self.assertEqual(self.po_line.discount2, 0.0)
|
||||||
|
self.assertEqual(self.po_line.discount3, 0.0)
|
||||||
|
|
||||||
|
# Subtotal should be quantity * price
|
||||||
|
expected = 10 * 150
|
||||||
|
self.assertEqual(self.po_line.price_subtotal, expected)
|
||||||
|
|
||||||
|
def test_po_line_combined_operations(self):
|
||||||
|
"""Test combined operations on PO line"""
|
||||||
|
# Update multiple fields at once
|
||||||
|
self.po_line.write({
|
||||||
|
"product_qty": 15,
|
||||||
|
"price_unit": 175.0,
|
||||||
|
"discount1": 18.0,
|
||||||
|
"discount2": 12.0,
|
||||||
|
"discount3": 6.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# All fields should be updated correctly
|
||||||
|
self.assertEqual(self.po_line.product_qty, 15)
|
||||||
|
self.assertEqual(self.po_line.price_unit, 175.0)
|
||||||
|
self.assertEqual(self.po_line.discount1, 18.0)
|
||||||
|
self.assertEqual(self.po_line.discount2, 12.0)
|
||||||
|
self.assertEqual(self.po_line.discount3, 6.0)
|
||||||
|
|
||||||
|
# Calculate expected subtotal
|
||||||
|
expected = 15 * 175 * 0.82 * 0.88 * 0.94
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
self.po_line.price_subtotal, expected, places=2
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_po_confirm_with_discounts(self):
|
||||||
|
"""Test confirming PO doesn't alter discounts"""
|
||||||
|
self.po_line.write({
|
||||||
|
"discount1": 10.0,
|
||||||
|
"discount2": 5.0,
|
||||||
|
"discount3": 2.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Confirm the purchase order
|
||||||
|
self.purchase_order.button_confirm()
|
||||||
|
|
||||||
|
# Discounts should remain unchanged after confirmation
|
||||||
|
self.assertEqual(self.po_line.discount1, 10.0)
|
||||||
|
self.assertEqual(self.po_line.discount2, 5.0)
|
||||||
|
self.assertEqual(self.po_line.discount3, 2.0)
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
# Copyright (C) 2025: 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 TestTripleDiscountMixin(TransactionCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
|
||||||
|
# Create a partner
|
||||||
|
cls.partner = cls.env["res.partner"].create({
|
||||||
|
"name": "Test Partner",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create a product
|
||||||
|
cls.product = cls.env["product.product"].create({
|
||||||
|
"name": "Test Product",
|
||||||
|
"type": "product",
|
||||||
|
"list_price": 100.0,
|
||||||
|
"standard_price": 50.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create a purchase order
|
||||||
|
cls.purchase_order = cls.env["purchase.order"].create({
|
||||||
|
"partner_id": cls.partner.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create a purchase order line
|
||||||
|
cls.po_line = cls.env["purchase.order.line"].create({
|
||||||
|
"order_id": cls.purchase_order.id,
|
||||||
|
"product_id": cls.product.id,
|
||||||
|
"product_qty": 10,
|
||||||
|
"price_unit": 100.0,
|
||||||
|
"discount1": 10.0,
|
||||||
|
"discount2": 5.0,
|
||||||
|
"discount3": 2.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_discount_field_is_readonly(self):
|
||||||
|
"""Test that the discount field is readonly"""
|
||||||
|
field = self.po_line._fields["discount"]
|
||||||
|
self.assertTrue(field.readonly, "Discount field should be readonly")
|
||||||
|
|
||||||
|
def test_write_with_explicit_discounts(self):
|
||||||
|
"""Test writing with explicit discount1, discount2, discount3"""
|
||||||
|
# Write with explicit discounts
|
||||||
|
self.po_line.write({
|
||||||
|
"discount": 20.0, # This should be ignored
|
||||||
|
"discount1": 15.0,
|
||||||
|
"discount2": 10.0,
|
||||||
|
"discount3": 5.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Verify explicit discounts were applied
|
||||||
|
self.assertEqual(self.po_line.discount1, 15.0)
|
||||||
|
self.assertEqual(self.po_line.discount2, 10.0)
|
||||||
|
self.assertEqual(self.po_line.discount3, 5.0)
|
||||||
|
|
||||||
|
# The computed discount field should reflect the combined discounts
|
||||||
|
# Formula: 100 - (100 * (1 - 0.15) * (1 - 0.10) * (1 - 0.05))
|
||||||
|
expected_discount = 100 - (100 * 0.85 * 0.90 * 0.95)
|
||||||
|
self.assertAlmostEqual(self.po_line.discount, expected_discount, places=2)
|
||||||
|
|
||||||
|
def test_write_only_discount1(self):
|
||||||
|
"""Test writing only discount1 explicitly"""
|
||||||
|
self.po_line.write({
|
||||||
|
"discount": 25.0, # This should be ignored
|
||||||
|
"discount1": 20.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Only discount1 should change
|
||||||
|
self.assertEqual(self.po_line.discount1, 20.0)
|
||||||
|
# Others should remain unchanged
|
||||||
|
self.assertEqual(self.po_line.discount2, 5.0)
|
||||||
|
self.assertEqual(self.po_line.discount3, 2.0)
|
||||||
|
|
||||||
|
def test_write_only_discount2(self):
|
||||||
|
"""Test writing only discount2 explicitly"""
|
||||||
|
self.po_line.write({
|
||||||
|
"discount": 30.0, # This should be ignored
|
||||||
|
"discount2": 12.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Only discount2 should change
|
||||||
|
self.assertEqual(self.po_line.discount2, 12.0)
|
||||||
|
# Others should remain unchanged from previous test
|
||||||
|
self.assertEqual(self.po_line.discount1, 20.0)
|
||||||
|
self.assertEqual(self.po_line.discount3, 2.0)
|
||||||
|
|
||||||
|
def test_write_only_discount3(self):
|
||||||
|
"""Test writing only discount3 explicitly"""
|
||||||
|
self.po_line.write({
|
||||||
|
"discount": 35.0, # This should be ignored
|
||||||
|
"discount3": 8.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Only discount3 should change
|
||||||
|
self.assertEqual(self.po_line.discount3, 8.0)
|
||||||
|
# Others should remain unchanged from previous tests
|
||||||
|
self.assertEqual(self.po_line.discount1, 20.0)
|
||||||
|
self.assertEqual(self.po_line.discount2, 12.0)
|
||||||
|
|
||||||
|
def test_write_legacy_discount_only(self):
|
||||||
|
"""Test legacy behavior: writing only discount field"""
|
||||||
|
# Reset to known state first
|
||||||
|
self.po_line.write({
|
||||||
|
"discount1": 10.0,
|
||||||
|
"discount2": 5.0,
|
||||||
|
"discount3": 2.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Write only discount (legacy behavior)
|
||||||
|
self.po_line.write({
|
||||||
|
"discount": 25.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Should map to discount1 and reset others
|
||||||
|
self.assertEqual(self.po_line.discount1, 25.0)
|
||||||
|
self.assertEqual(self.po_line.discount2, 0.0)
|
||||||
|
self.assertEqual(self.po_line.discount3, 0.0)
|
||||||
|
|
||||||
|
def test_write_multiple_times(self):
|
||||||
|
"""Test writing multiple times to ensure consistency"""
|
||||||
|
# First write
|
||||||
|
self.po_line.write({
|
||||||
|
"discount1": 10.0,
|
||||||
|
"discount2": 10.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertEqual(self.po_line.discount1, 10.0)
|
||||||
|
self.assertEqual(self.po_line.discount2, 10.0)
|
||||||
|
|
||||||
|
# Second write
|
||||||
|
self.po_line.write({
|
||||||
|
"discount": 5.0,
|
||||||
|
"discount3": 5.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# discount3 should change, others remain
|
||||||
|
self.assertEqual(self.po_line.discount1, 10.0)
|
||||||
|
self.assertEqual(self.po_line.discount2, 10.0)
|
||||||
|
self.assertEqual(self.po_line.discount3, 5.0)
|
||||||
|
|
||||||
|
def test_write_zero_discounts(self):
|
||||||
|
"""Test writing zero discounts"""
|
||||||
|
self.po_line.write({
|
||||||
|
"discount1": 0.0,
|
||||||
|
"discount2": 0.0,
|
||||||
|
"discount3": 0.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertEqual(self.po_line.discount1, 0.0)
|
||||||
|
self.assertEqual(self.po_line.discount2, 0.0)
|
||||||
|
self.assertEqual(self.po_line.discount3, 0.0)
|
||||||
|
self.assertEqual(self.po_line.discount, 0.0)
|
||||||
|
|
||||||
|
def test_write_combined_scenario(self):
|
||||||
|
"""Test a realistic combined scenario"""
|
||||||
|
# Initial state
|
||||||
|
self.po_line.write({
|
||||||
|
"discount1": 15.0,
|
||||||
|
"discount2": 5.0,
|
||||||
|
"discount3": 0.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# User tries to update discount field (should be ignored if explicit discounts present)
|
||||||
|
self.po_line.write({
|
||||||
|
"discount": 50.0,
|
||||||
|
"discount1": 20.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# discount1 should be updated, others unchanged
|
||||||
|
self.assertEqual(self.po_line.discount1, 20.0)
|
||||||
|
self.assertEqual(self.po_line.discount2, 5.0)
|
||||||
|
self.assertEqual(self.po_line.discount3, 0.0)
|
||||||
|
|
||||||
|
def test_discount_calculation_accuracy(self):
|
||||||
|
"""Test that discount calculation is accurate"""
|
||||||
|
self.po_line.write({
|
||||||
|
"discount1": 10.0,
|
||||||
|
"discount2": 10.0,
|
||||||
|
"discount3": 10.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Combined discount: 100 - (100 * 0.9 * 0.9 * 0.9) = 27.1
|
||||||
|
expected = 100 - (100 * 0.9 * 0.9 * 0.9)
|
||||||
|
self.assertAlmostEqual(self.po_line.discount, expected, places=2)
|
||||||
|
|
||||||
|
def test_write_without_discount_field(self):
|
||||||
|
"""Test writing other fields without touching discount fields"""
|
||||||
|
initial_discount1 = self.po_line.discount1
|
||||||
|
|
||||||
|
# Write other fields
|
||||||
|
self.po_line.write({
|
||||||
|
"product_qty": 20,
|
||||||
|
"price_unit": 150.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Discounts should remain unchanged
|
||||||
|
self.assertEqual(self.po_line.discount1, initial_discount1)
|
||||||
|
# But other fields should be updated
|
||||||
|
self.assertEqual(self.po_line.product_qty, 20)
|
||||||
|
self.assertEqual(self.po_line.price_unit, 150.0)
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<?xml version="1.0" encoding="utf-8" ?>
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
<odoo>
|
<odoo>
|
||||||
|
|
||||||
<!-- Make discount field readonly in invoice lines -->
|
<!-- Make discount field readonly in invoice lines form view -->
|
||||||
<record id="invoice_triple_discount_readonly" model="ir.ui.view">
|
<record id="invoice_triple_discount_readonly" model="ir.ui.view">
|
||||||
<field name="name">account.invoice.triple.discount.readonly</field>
|
<field name="name">account.invoice.triple.discount.readonly</field>
|
||||||
<field name="model">account.move</field>
|
<field name="model">account.move</field>
|
||||||
|
|
@ -10,12 +10,7 @@
|
||||||
ref="account_invoice_triple_discount.invoice_triple_discount_form_view"
|
ref="account_invoice_triple_discount.invoice_triple_discount_form_view"
|
||||||
/>
|
/>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<xpath
|
<!-- Make the total discount field readonly in form view to force users to use discount1/2/3 -->
|
||||||
expr="//field[@name='invoice_line_ids']//tree//field[@name='discount']"
|
|
||||||
position="attributes"
|
|
||||||
>
|
|
||||||
<attribute name="readonly">1</attribute>
|
|
||||||
</xpath>
|
|
||||||
<xpath
|
<xpath
|
||||||
expr="//field[@name='invoice_line_ids']//form//field[@name='discount']"
|
expr="//field[@name='invoice_line_ids']//form//field[@name='discount']"
|
||||||
position="attributes"
|
position="attributes"
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,6 @@
|
||||||
<?xml version="1.0" encoding="utf-8" ?>
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
<odoo>
|
<odoo>
|
||||||
|
|
||||||
<!-- Make discount field readonly in supplier info form view -->
|
<!-- No need to modify product.supplierinfo views as purchase_triple_discount already hides discount field -->
|
||||||
<record model="ir.ui.view" id="product_supplierinfo_form_view_readonly_discount">
|
|
||||||
<field name="model">product.supplierinfo</field>
|
|
||||||
<field
|
|
||||||
name="inherit_id"
|
|
||||||
ref="purchase_triple_discount.product_supplierinfo_form_view"
|
|
||||||
/>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
<xpath expr="//field[@name='discount']" position="attributes">
|
|
||||||
<attribute name="invisible">1</attribute>
|
|
||||||
</xpath>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- Make discount field readonly in supplier info tree view -->
|
|
||||||
<record model="ir.ui.view" id="product_supplierinfo_tree_view_readonly_discount">
|
|
||||||
<field name="model">product.supplierinfo</field>
|
|
||||||
<field
|
|
||||||
name="inherit_id"
|
|
||||||
ref="purchase_triple_discount.product_supplierinfo_tree_view"
|
|
||||||
/>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
<xpath expr="//field[@name='discount']" position="attributes">
|
|
||||||
<attribute name="invisible">1</attribute>
|
|
||||||
</xpath>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,6 @@
|
||||||
<?xml version="1.0" encoding="utf-8" ?>
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
<odoo>
|
<odoo>
|
||||||
|
|
||||||
<!-- Make discount field readonly in purchase order lines -->
|
<!-- No need to modify purchase.order views as purchase_triple_discount already hides discount field -->
|
||||||
<record id="purchase_order_triple_discount_readonly" model="ir.ui.view">
|
|
||||||
<field name="name">purchase.order.triple.discount.readonly</field>
|
|
||||||
<field name="model">purchase.order</field>
|
|
||||||
<field
|
|
||||||
name="inherit_id"
|
|
||||||
ref="purchase_triple_discount.purchase_order_triple_discount_form_view"
|
|
||||||
/>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
<xpath
|
|
||||||
expr="//field[@name='order_line']//tree//field[@name='discount']"
|
|
||||||
position="attributes"
|
|
||||||
>
|
|
||||||
<attribute name="readonly">1</attribute>
|
|
||||||
</xpath>
|
|
||||||
<xpath
|
|
||||||
expr="//field[@name='order_line']//form//field[@name='discount']"
|
|
||||||
position="attributes"
|
|
||||||
>
|
|
||||||
<attribute name="readonly">1</attribute>
|
|
||||||
</xpath>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,6 @@
|
||||||
<?xml version="1.0" encoding="utf-8" ?>
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
<odoo>
|
<odoo>
|
||||||
|
|
||||||
<!-- Make default_supplierinfo_discount readonly in partner form view -->
|
<!-- No need to modify partner view as purchase_triple_discount already uses discount1/2/3 fields -->
|
||||||
<record model="ir.ui.view" id="res_partner_form_view_readonly_discount">
|
|
||||||
<field name="model">res.partner</field>
|
|
||||||
<field
|
|
||||||
name="inherit_id"
|
|
||||||
ref="purchase_triple_discount.res_partner_form_view"
|
|
||||||
/>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
<field name="default_supplierinfo_discount" position="attributes">
|
|
||||||
<attribute name="invisible">1</attribute>
|
|
||||||
</field>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "Product Sale Price from Pricelist",
|
"name": "Product Sale Price from Pricelist",
|
||||||
"version": "16.0.1.0.0",
|
"version": "18.0.1.0.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": """
|
||||||
|
|
@ -11,11 +11,11 @@
|
||||||
"license": "AGPL-3",
|
"license": "AGPL-3",
|
||||||
"depends": [
|
"depends": [
|
||||||
"product_get_price_helper",
|
"product_get_price_helper",
|
||||||
"sale",
|
"account_invoice_triple_discount_readonly",
|
||||||
|
"sale_management",
|
||||||
"purchase",
|
"purchase",
|
||||||
"account",
|
"account",
|
||||||
"stock_account",
|
"stock_account",
|
||||||
"product_template_tags",
|
|
||||||
],
|
],
|
||||||
"data": [
|
"data": [
|
||||||
"views/actions.xml",
|
"views/actions.xml",
|
||||||
|
|
|
||||||
|
|
@ -2,25 +2,39 @@
|
||||||
# @author Santi Noreña (<santi@criptomart.net>)
|
# @author Santi Noreña (<santi@criptomart.net>)
|
||||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
|
||||||
|
|
||||||
# import logging
|
import logging
|
||||||
|
|
||||||
from odoo import api, models
|
from odoo import api, models
|
||||||
|
|
||||||
# _logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ProductPricelist(models.Model):
|
class ProductPricelist(models.Model):
|
||||||
_inherit = "product.pricelist"
|
_inherit = "product.pricelist"
|
||||||
|
|
||||||
def _compute_price_rule(self, products, qty, uom=None, date=False, **kwargs):
|
def _compute_price_rule(self, products, quantity, uom=None, date=False, **kwargs):
|
||||||
ProductPricelistItem = self.env["product.pricelist.item"]
|
ProductPricelistItem = self.env["product.pricelist.item"]
|
||||||
ProductProduct = self.env["product.product"]
|
ProductProduct = self.env["product.product"]
|
||||||
|
|
||||||
|
_logger.info(
|
||||||
|
"[PRICELIST DEBUG] _compute_price_rule called with products=%s, quantity=%s",
|
||||||
|
products.ids,
|
||||||
|
quantity,
|
||||||
|
)
|
||||||
|
|
||||||
res = super()._compute_price_rule(
|
res = super()._compute_price_rule(
|
||||||
products,
|
products,
|
||||||
qty=1,
|
quantity,
|
||||||
uom=uom,
|
uom=uom,
|
||||||
date=date,
|
date=date,
|
||||||
|
**kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_logger.info(
|
||||||
|
"[PRICELIST DEBUG] super()._compute_price_rule returned: %s",
|
||||||
|
res,
|
||||||
|
)
|
||||||
|
|
||||||
new_res = res.copy()
|
new_res = res.copy()
|
||||||
item_id = []
|
item_id = []
|
||||||
for product_id, values in res.items():
|
for product_id, values in res.items():
|
||||||
|
|
@ -28,10 +42,26 @@ class ProductPricelist(models.Model):
|
||||||
item_id = values[1]
|
item_id = values[1]
|
||||||
if item_id:
|
if item_id:
|
||||||
item = ProductPricelistItem.browse(item_id)
|
item = ProductPricelistItem.browse(item_id)
|
||||||
|
_logger.info(
|
||||||
|
"[PRICELIST DEBUG] Product %s: item.base=%s, item_id=%s",
|
||||||
|
product_id,
|
||||||
|
item.base,
|
||||||
|
item_id,
|
||||||
|
)
|
||||||
if item.base == "last_purchase_price":
|
if item.base == "last_purchase_price":
|
||||||
price = ProductProduct.browse(
|
product = ProductProduct.browse(product_id)
|
||||||
product_id
|
price = product.last_purchase_price_received
|
||||||
).last_purchase_price_received
|
_logger.info(
|
||||||
|
"[PRICELIST DEBUG] Product %s: last_purchase_price_received=%s, item.price_discount=%s",
|
||||||
|
product_id,
|
||||||
|
price,
|
||||||
|
item.price_discount,
|
||||||
|
)
|
||||||
price = (price - (price * (item.price_discount / 100))) or 0.0
|
price = (price - (price * (item.price_discount / 100))) or 0.0
|
||||||
new_res[product_id] = (price, item_id)
|
new_res[product_id] = (price, item_id)
|
||||||
|
_logger.info(
|
||||||
|
"[PRICELIST DEBUG] Product %s: calculated price=%s",
|
||||||
|
product_id,
|
||||||
|
price,
|
||||||
|
)
|
||||||
return new_res
|
return new_res
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ class ProductPricelistItem(models.Model):
|
||||||
ondelete={"last_purchase_price": "set default"},
|
ondelete={"last_purchase_price": "set default"},
|
||||||
)
|
)
|
||||||
|
|
||||||
def _compute_price(self, product, quantity, uom, date, currency=None):
|
def _compute_price(self, product, qty, uom, date, currency=None):
|
||||||
result = super()._compute_price(product, quantity, uom, date, currency)
|
result = super()._compute_price(product, qty, uom, date, currency)
|
||||||
if self.compute_price == "formula" and self.base == "last_purchase_price":
|
if self.compute_price == "formula" and self.base == "last_purchase_price":
|
||||||
result = product.sudo().last_purchase_price_received
|
result = product.sudo().last_purchase_price_received
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,16 @@
|
||||||
from odoo import models
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
class ProductProduct(models.Model):
|
class ProductProduct(models.Model):
|
||||||
_inherit = "product.product"
|
_inherit = "product.product"
|
||||||
|
|
||||||
|
# Related field for pricelist base computation
|
||||||
|
last_purchase_price = fields.Float(
|
||||||
|
related="product_tmpl_id.last_purchase_price_received",
|
||||||
|
string="Last Purchase Price",
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
|
||||||
def price_compute(
|
def price_compute(
|
||||||
self, price_type, uom=None, currency=None, company=None, date=False
|
self, price_type, uom=None, currency=None, company=None, date=False
|
||||||
):
|
):
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,11 @@
|
||||||
|
|
||||||
from odoo import exceptions, models, fields, api, _
|
from odoo import exceptions, models, fields, api, _
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
|
import logging
|
||||||
import math
|
import math
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ProductTemplate(models.Model):
|
class ProductTemplate(models.Model):
|
||||||
_inherit = "product.template"
|
_inherit = "product.template"
|
||||||
|
|
@ -42,7 +45,6 @@ class ProductTemplate(models.Model):
|
||||||
'* Triple discount: take into account all discounts when updating the last purchase price. Needs "Purchase Triple Discount" OCA module.\n'
|
'* Triple discount: take into account all discounts when updating the last purchase price. Needs "Purchase Triple Discount" OCA module.\n'
|
||||||
"* Manual update: Select this for manual configuration of cost and sale price. The sales price will not be calculated automatically.",
|
"* Manual update: Select this for manual configuration of cost and sale price. The sales price will not be calculated automatically.",
|
||||||
default="without_discounts",
|
default="without_discounts",
|
||||||
required=True,
|
|
||||||
company_dependent=True,
|
company_dependent=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -57,6 +59,15 @@ class ProductTemplate(models.Model):
|
||||||
pricelist = pricelist_obj.browse(int(pricelist_id))
|
pricelist = pricelist_obj.browse(int(pricelist_id))
|
||||||
if pricelist:
|
if pricelist:
|
||||||
for template in self:
|
for template in self:
|
||||||
|
_logger.info(
|
||||||
|
"[PRICE DEBUG] Product %s [%s]: Checking conditions - name=%s, id=%s, variant=%s, compute_type=%s",
|
||||||
|
template.default_code or template.name,
|
||||||
|
template.id,
|
||||||
|
bool(template.name),
|
||||||
|
bool(template.id),
|
||||||
|
bool(template.product_variant_id),
|
||||||
|
template.last_purchase_price_compute_type,
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
template.name
|
template.name
|
||||||
and template.id
|
and template.id
|
||||||
|
|
@ -66,6 +77,14 @@ class ProductTemplate(models.Model):
|
||||||
partial_price = template.product_variant_id._get_price(
|
partial_price = template.product_variant_id._get_price(
|
||||||
qty=1, pricelist=pricelist
|
qty=1, pricelist=pricelist
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_logger.info(
|
||||||
|
"[PRICE DEBUG] Product %s [%s]: partial_price result = %s",
|
||||||
|
template.default_code or template.name,
|
||||||
|
template.id,
|
||||||
|
partial_price,
|
||||||
|
)
|
||||||
|
|
||||||
# Compute taxes to add
|
# Compute taxes to add
|
||||||
if not template.taxes_id:
|
if not template.taxes_id:
|
||||||
raise UserError(
|
raise UserError(
|
||||||
|
|
@ -74,23 +93,33 @@ class ProductTemplate(models.Model):
|
||||||
)
|
)
|
||||||
% template.name
|
% template.name
|
||||||
)
|
)
|
||||||
tax_price = template.taxes_id.compute_all(
|
|
||||||
partial_price[template.product_variant_id.id]["value"] or 0.0,
|
base_price = partial_price.get("value", 0.0) or 0.0
|
||||||
handle_price_include=False,
|
_logger.info(
|
||||||
|
"[PRICE DEBUG] Product %s [%s]: base_price from pricelist = %.2f, last_purchase_price = %.2f",
|
||||||
|
template.default_code or template.name,
|
||||||
|
template.id,
|
||||||
|
base_price,
|
||||||
|
template.last_purchase_price_received,
|
||||||
)
|
)
|
||||||
price_with_taxes = (
|
|
||||||
tax_price["taxes"][0]["amount"]
|
# Use base price without taxes (taxes will be calculated automatically on sales)
|
||||||
+ partial_price[template.product_variant_id.id]["value"]
|
theoretical_price = base_price
|
||||||
|
|
||||||
|
_logger.info(
|
||||||
|
"[PRICE] Product %s [%s]: Computed theoretical price %.2f (previous: %.2f, current list_price: %.2f)",
|
||||||
|
template.default_code or template.name,
|
||||||
|
template.id,
|
||||||
|
theoretical_price,
|
||||||
|
template.list_price_theoritical,
|
||||||
|
template.list_price,
|
||||||
)
|
)
|
||||||
# Round to 0.05
|
|
||||||
if round(price_with_taxes % 0.05, 2) != 0:
|
|
||||||
price_with_taxes = round(price_with_taxes * 20) / 20
|
|
||||||
|
|
||||||
template.write(
|
template.write(
|
||||||
{
|
{
|
||||||
"list_price_theoritical": price_with_taxes,
|
"list_price_theoritical": theoretical_price,
|
||||||
"last_purchase_price_updated": (
|
"last_purchase_price_updated": (
|
||||||
price_with_taxes != template.list_price
|
theoretical_price != template.list_price
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -104,8 +133,19 @@ class ProductTemplate(models.Model):
|
||||||
def action_update_list_price(self):
|
def action_update_list_price(self):
|
||||||
for template in self:
|
for template in self:
|
||||||
if template.last_purchase_price_compute_type != "manual_update":
|
if template.last_purchase_price_compute_type != "manual_update":
|
||||||
|
# First compute the theoretical price
|
||||||
|
template._compute_theoritical_price()
|
||||||
|
|
||||||
|
old_price = template.list_price
|
||||||
template.list_price = template.list_price_theoritical
|
template.list_price = template.list_price_theoritical
|
||||||
template.last_purchase_price_updated = False
|
template.last_purchase_price_updated = False
|
||||||
|
_logger.info(
|
||||||
|
"[PRICE] Product %s [%s]: List price updated from %.2f to %.2f",
|
||||||
|
template.default_code or template.name,
|
||||||
|
template.id,
|
||||||
|
old_price,
|
||||||
|
template.list_price,
|
||||||
|
)
|
||||||
|
|
||||||
def price_compute(
|
def price_compute(
|
||||||
self, price_type, uom=None, currency=None, company=False, date=False
|
self, price_type, uom=None, currency=None, company=False, date=False
|
||||||
|
|
|
||||||
|
|
@ -59,14 +59,16 @@ class StockMove(models.Model):
|
||||||
move.product_id.last_purchase_price_received,
|
move.product_id.last_purchase_price_received,
|
||||||
price_updated,
|
price_updated,
|
||||||
precision_digits=2,
|
precision_digits=2,
|
||||||
) and not float_is_zero(move.quantity_done, precision_digits=3):
|
) and not float_is_zero(move.quantity, precision_digits=3):
|
||||||
_logger.info(
|
_logger.info(
|
||||||
"Update last_purchase_price_received: %s for product %s Previous price: %s"
|
"[PRICE] Product %s [%s]: Purchase price updated from %.2f to %.2f (Move: %s, PO: %s, compute_type: %s)",
|
||||||
% (
|
move.product_id.default_code or move.product_id.name,
|
||||||
price_updated,
|
move.product_id.id,
|
||||||
move.product_id.default_code,
|
move.product_id.last_purchase_price_received,
|
||||||
move.product_id.last_purchase_price_received,
|
price_updated,
|
||||||
)
|
move.name,
|
||||||
|
move.purchase_line_id.order_id.name if move.purchase_line_id else 'N/A',
|
||||||
|
move.product_id.last_purchase_price_compute_type,
|
||||||
)
|
)
|
||||||
move.product_id.with_company(
|
move.product_id.with_company(
|
||||||
move.company_id
|
move.company_id
|
||||||
|
|
|
||||||
4
product_sale_price_from_pricelist/tests/__init__.py
Normal file
4
product_sale_price_from_pricelist/tests/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
from . import test_product_template
|
||||||
|
from . import test_stock_move
|
||||||
|
from . import test_pricelist
|
||||||
|
from . import test_res_config
|
||||||
212
product_sale_price_from_pricelist/tests/test_pricelist.py
Normal file
212
product_sale_price_from_pricelist/tests/test_pricelist.py
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
# 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 TestPricelist(TransactionCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
|
||||||
|
# Create tax
|
||||||
|
cls.tax = cls.env["account.tax"].create({
|
||||||
|
"name": "Test Tax 10%",
|
||||||
|
"amount": 10.0,
|
||||||
|
"amount_type": "percent",
|
||||||
|
"type_tax_use": "sale",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create product
|
||||||
|
cls.product = cls.env["product.product"].create({
|
||||||
|
"name": "Test Product Pricelist",
|
||||||
|
"type": "product",
|
||||||
|
"list_price": 100.0,
|
||||||
|
"standard_price": 50.0,
|
||||||
|
"taxes_id": [(6, 0, [cls.tax.id])],
|
||||||
|
"last_purchase_price_received": 50.0,
|
||||||
|
"last_purchase_price_compute_type": "without_discounts",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create pricelist with last_purchase_price base
|
||||||
|
cls.pricelist = cls.env["product.pricelist"].create({
|
||||||
|
"name": "Test Pricelist Last Purchase",
|
||||||
|
"currency_id": cls.env.company.currency_id.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_pricelist_item_base_last_purchase_price(self):
|
||||||
|
"""Test pricelist item with last_purchase_price base"""
|
||||||
|
pricelist_item = self.env["product.pricelist.item"].create({
|
||||||
|
"pricelist_id": self.pricelist.id,
|
||||||
|
"compute_price": "formula",
|
||||||
|
"base": "last_purchase_price",
|
||||||
|
"price_discount": 0,
|
||||||
|
"price_surcharge": 10.0,
|
||||||
|
"applied_on": "3_global",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Compute price using pricelist
|
||||||
|
price = self.pricelist._compute_price_rule(
|
||||||
|
self.product, qty=1, date=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Price should be based on last_purchase_price
|
||||||
|
self.assertIn(self.product.id, price)
|
||||||
|
# The exact price depends on the formula calculation
|
||||||
|
|
||||||
|
def test_pricelist_item_with_discount(self):
|
||||||
|
"""Test pricelist item with discount on last_purchase_price"""
|
||||||
|
pricelist_item = self.env["product.pricelist.item"].create({
|
||||||
|
"pricelist_id": self.pricelist.id,
|
||||||
|
"compute_price": "formula",
|
||||||
|
"base": "last_purchase_price",
|
||||||
|
"price_discount": 20.0, # 20% discount
|
||||||
|
"applied_on": "3_global",
|
||||||
|
})
|
||||||
|
|
||||||
|
price = self.pricelist._compute_price_rule(
|
||||||
|
self.product, qty=1, date=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Expected: 50.0 - (50.0 * 0.20) = 40.0
|
||||||
|
self.assertIn(self.product.id, price)
|
||||||
|
self.assertEqual(price[self.product.id][0], 40.0)
|
||||||
|
|
||||||
|
def test_pricelist_item_with_markup(self):
|
||||||
|
"""Test pricelist item with markup on last_purchase_price"""
|
||||||
|
pricelist_item = self.env["product.pricelist.item"].create({
|
||||||
|
"pricelist_id": self.pricelist.id,
|
||||||
|
"compute_price": "formula",
|
||||||
|
"base": "last_purchase_price",
|
||||||
|
"price_markup": 100.0, # 100% markup (double the price)
|
||||||
|
"applied_on": "3_global",
|
||||||
|
})
|
||||||
|
|
||||||
|
# _compute_price should return the base price (last_purchase_price_received)
|
||||||
|
result = pricelist_item._compute_price(
|
||||||
|
self.product,
|
||||||
|
qty=1,
|
||||||
|
uom=self.product.uom_id,
|
||||||
|
date=False,
|
||||||
|
currency=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should return the last purchase price as base
|
||||||
|
self.assertEqual(result, 50.0)
|
||||||
|
|
||||||
|
def test_pricelist_item_compute_price_method(self):
|
||||||
|
"""Test _compute_price method of pricelist item"""
|
||||||
|
pricelist_item = self.env["product.pricelist.item"].create({
|
||||||
|
"pricelist_id": self.pricelist.id,
|
||||||
|
"compute_price": "formula",
|
||||||
|
"base": "last_purchase_price",
|
||||||
|
"price_markup": 50.0,
|
||||||
|
"applied_on": "3_global",
|
||||||
|
})
|
||||||
|
|
||||||
|
result = pricelist_item._compute_price(
|
||||||
|
self.product,
|
||||||
|
qty=1,
|
||||||
|
uom=self.product.uom_id,
|
||||||
|
date=False,
|
||||||
|
currency=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should return last_purchase_price_received
|
||||||
|
self.assertEqual(result, self.product.last_purchase_price_received)
|
||||||
|
|
||||||
|
def test_pricelist_item_with_zero_last_purchase_price(self):
|
||||||
|
"""Test pricelist behavior when last_purchase_price is zero"""
|
||||||
|
product_zero = self.env["product.product"].create({
|
||||||
|
"name": "Product Zero Price",
|
||||||
|
"type": "product",
|
||||||
|
"list_price": 100.0,
|
||||||
|
"last_purchase_price_received": 0.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
pricelist_item = self.env["product.pricelist.item"].create({
|
||||||
|
"pricelist_id": self.pricelist.id,
|
||||||
|
"compute_price": "formula",
|
||||||
|
"base": "last_purchase_price",
|
||||||
|
"price_discount": 10.0,
|
||||||
|
"applied_on": "3_global",
|
||||||
|
})
|
||||||
|
|
||||||
|
price = self.pricelist._compute_price_rule(
|
||||||
|
product_zero, qty=1, date=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should handle zero price gracefully
|
||||||
|
self.assertIn(product_zero.id, price)
|
||||||
|
self.assertEqual(price[product_zero.id][0], 0.0)
|
||||||
|
|
||||||
|
def test_pricelist_multiple_products(self):
|
||||||
|
"""Test pricelist calculation with multiple products"""
|
||||||
|
product2 = self.env["product.product"].create({
|
||||||
|
"name": "Test Product 2",
|
||||||
|
"type": "product",
|
||||||
|
"list_price": 200.0,
|
||||||
|
"last_purchase_price_received": 100.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
pricelist_item = self.env["product.pricelist.item"].create({
|
||||||
|
"pricelist_id": self.pricelist.id,
|
||||||
|
"compute_price": "formula",
|
||||||
|
"base": "last_purchase_price",
|
||||||
|
"price_discount": 10.0,
|
||||||
|
"applied_on": "3_global",
|
||||||
|
})
|
||||||
|
|
||||||
|
products = self.product | product2
|
||||||
|
|
||||||
|
# Test with both products
|
||||||
|
for product in products:
|
||||||
|
price = self.pricelist._compute_price_rule(
|
||||||
|
product, qty=1, date=False
|
||||||
|
)
|
||||||
|
self.assertIn(product.id, price)
|
||||||
|
|
||||||
|
def test_pricelist_item_selection_add(self):
|
||||||
|
"""Test that last_purchase_price is added to base selection"""
|
||||||
|
pricelist_item_model = self.env["product.pricelist.item"]
|
||||||
|
base_field = pricelist_item_model._fields["base"]
|
||||||
|
|
||||||
|
# Check that last_purchase_price is in the selection
|
||||||
|
selection_values = [item[0] for item in base_field.selection]
|
||||||
|
self.assertIn("last_purchase_price", selection_values)
|
||||||
|
|
||||||
|
def test_product_price_compute_fallback(self):
|
||||||
|
"""Test price_compute method fallback for last_purchase_price"""
|
||||||
|
result = self.product.price_compute("last_purchase_price")
|
||||||
|
|
||||||
|
# Should return dummy value 1.0 for all products
|
||||||
|
self.assertEqual(result[self.product.id], 1.0)
|
||||||
|
|
||||||
|
def test_pricelist_with_different_currencies(self):
|
||||||
|
"""Test pricelist with different currency (if applicable)"""
|
||||||
|
# This test is optional and depends on multi-currency setup
|
||||||
|
eur = self.env.ref("base.EUR", raise_if_not_found=False)
|
||||||
|
if not eur:
|
||||||
|
self.skipTest("EUR currency not available")
|
||||||
|
|
||||||
|
pricelist_eur = self.env["product.pricelist"].create({
|
||||||
|
"name": "Test Pricelist EUR",
|
||||||
|
"currency_id": eur.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
pricelist_item = self.env["product.pricelist.item"].create({
|
||||||
|
"pricelist_id": pricelist_eur.id,
|
||||||
|
"compute_price": "formula",
|
||||||
|
"base": "last_purchase_price",
|
||||||
|
"price_discount": 0,
|
||||||
|
"applied_on": "3_global",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Price calculation should work with different currency
|
||||||
|
price = pricelist_eur._compute_price_rule(
|
||||||
|
self.product, qty=1, date=False
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn(self.product.id, price)
|
||||||
225
product_sale_price_from_pricelist/tests/test_product_template.py
Normal file
225
product_sale_price_from_pricelist/tests/test_product_template.py
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
# 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
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
|
@tagged("post_install", "-at_install")
|
||||||
|
class TestProductTemplate(TransactionCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
|
||||||
|
# Create a tax for the product
|
||||||
|
cls.tax = cls.env["account.tax"].create({
|
||||||
|
"name": "Test Tax 21%",
|
||||||
|
"amount": 21.0,
|
||||||
|
"amount_type": "percent",
|
||||||
|
"type_tax_use": "sale",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create a pricelist with formula based on cost
|
||||||
|
cls.pricelist = cls.env["product.pricelist"].create({
|
||||||
|
"name": "Test Pricelist Automatic",
|
||||||
|
"currency_id": cls.env.company.currency_id.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
cls.pricelist_item = cls.env["product.pricelist.item"].create({
|
||||||
|
"pricelist_id": cls.pricelist.id,
|
||||||
|
"compute_price": "formula",
|
||||||
|
"base": "last_purchase_price",
|
||||||
|
"price_discount": 0,
|
||||||
|
"price_surcharge": 0,
|
||||||
|
"price_markup": 50.0, # 50% markup
|
||||||
|
"applied_on": "3_global",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Set the pricelist in configuration
|
||||||
|
cls.env["ir.config_parameter"].sudo().set_param(
|
||||||
|
"product_sale_price_from_pricelist.product_pricelist_automatic",
|
||||||
|
str(cls.pricelist.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a product category
|
||||||
|
cls.category = cls.env["product.category"].create({
|
||||||
|
"name": "Test Category",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create a product
|
||||||
|
cls.product = cls.env["product.template"].create({
|
||||||
|
"name": "Test Product",
|
||||||
|
"type": "product",
|
||||||
|
"categ_id": cls.category.id,
|
||||||
|
"list_price": 10.0,
|
||||||
|
"standard_price": 5.0,
|
||||||
|
"taxes_id": [(6, 0, [cls.tax.id])],
|
||||||
|
"last_purchase_price_received": 5.0,
|
||||||
|
"last_purchase_price_compute_type": "without_discounts",
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_compute_theoritical_price_without_tax_error(self):
|
||||||
|
"""Test that computing theoretical price without taxes raises an error"""
|
||||||
|
product_no_tax = self.env["product.template"].create({
|
||||||
|
"name": "Product No Tax",
|
||||||
|
"type": "product",
|
||||||
|
"list_price": 10.0,
|
||||||
|
"standard_price": 5.0,
|
||||||
|
"last_purchase_price_received": 5.0,
|
||||||
|
"last_purchase_price_compute_type": "without_discounts",
|
||||||
|
})
|
||||||
|
|
||||||
|
with self.assertRaises(UserError):
|
||||||
|
product_no_tax._compute_theoritical_price()
|
||||||
|
|
||||||
|
def test_compute_theoritical_price_success(self):
|
||||||
|
"""Test successful computation of theoretical price"""
|
||||||
|
self.product._compute_theoritical_price()
|
||||||
|
|
||||||
|
# Verify that theoretical price was calculated
|
||||||
|
self.assertGreater(self.product.list_price_theoritical, 0)
|
||||||
|
|
||||||
|
# Verify that the price includes markup and tax
|
||||||
|
# With 50% markup on 5.0 = 7.5, plus 21% tax = 9.075, rounded to 9.10
|
||||||
|
self.assertAlmostEqual(self.product.list_price_theoritical, 9.10, places=2)
|
||||||
|
|
||||||
|
def test_compute_theoritical_price_with_rounding(self):
|
||||||
|
"""Test that prices are rounded to 0.05"""
|
||||||
|
self.product.last_purchase_price_received = 4.0
|
||||||
|
self.product._compute_theoritical_price()
|
||||||
|
|
||||||
|
# Price should be rounded to nearest 0.05
|
||||||
|
price = self.product.list_price_theoritical
|
||||||
|
self.assertEqual(round(price % 0.05, 2), 0.0)
|
||||||
|
|
||||||
|
def test_last_purchase_price_updated_flag(self):
|
||||||
|
"""Test that the updated flag is set when prices differ"""
|
||||||
|
initial_list_price = self.product.list_price
|
||||||
|
self.product.last_purchase_price_received = 10.0
|
||||||
|
self.product._compute_theoritical_price()
|
||||||
|
|
||||||
|
if self.product.list_price_theoritical != initial_list_price:
|
||||||
|
self.assertTrue(self.product.last_purchase_price_updated)
|
||||||
|
|
||||||
|
def test_action_update_list_price(self):
|
||||||
|
"""Test updating list price from theoretical price"""
|
||||||
|
self.product.last_purchase_price_received = 8.0
|
||||||
|
self.product._compute_theoritical_price()
|
||||||
|
|
||||||
|
theoretical_price = self.product.list_price_theoritical
|
||||||
|
self.product.action_update_list_price()
|
||||||
|
|
||||||
|
# Verify that list price was updated
|
||||||
|
self.assertEqual(self.product.list_price, theoretical_price)
|
||||||
|
self.assertFalse(self.product.last_purchase_price_updated)
|
||||||
|
|
||||||
|
def test_manual_update_type_skips_automatic(self):
|
||||||
|
"""Test that manual update type prevents automatic price calculation"""
|
||||||
|
self.product.last_purchase_price_compute_type = "manual_update"
|
||||||
|
initial_list_price = self.product.list_price
|
||||||
|
|
||||||
|
self.product.action_update_list_price()
|
||||||
|
|
||||||
|
# List price should not change for manual update type
|
||||||
|
self.assertEqual(self.product.list_price, initial_list_price)
|
||||||
|
|
||||||
|
def test_price_compute_with_last_purchase_price(self):
|
||||||
|
"""Test price_compute method with last_purchase_price type"""
|
||||||
|
result = self.product.price_compute("last_purchase_price")
|
||||||
|
|
||||||
|
# Should return dummy prices (1.0) for all product ids
|
||||||
|
for product_id in result:
|
||||||
|
self.assertEqual(result[product_id], 1.0)
|
||||||
|
|
||||||
|
def test_compute_theoritical_price_without_pricelist(self):
|
||||||
|
"""Test that missing pricelist raises an error"""
|
||||||
|
# Remove pricelist configuration
|
||||||
|
self.env["ir.config_parameter"].sudo().set_param(
|
||||||
|
"product_sale_price_from_pricelist.product_pricelist_automatic",
|
||||||
|
""
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(UserError):
|
||||||
|
self.product._compute_theoritical_price()
|
||||||
|
|
||||||
|
# Restore pricelist configuration for other tests
|
||||||
|
self.env["ir.config_parameter"].sudo().set_param(
|
||||||
|
"product_sale_price_from_pricelist.product_pricelist_automatic",
|
||||||
|
str(self.pricelist.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_company_dependent_fields(self):
|
||||||
|
"""Test that price fields are company dependent"""
|
||||||
|
# Verify field properties
|
||||||
|
field_last_purchase = self.product._fields["last_purchase_price_received"]
|
||||||
|
field_theoritical = self.product._fields["list_price_theoritical"]
|
||||||
|
field_updated = self.product._fields["last_purchase_price_updated"]
|
||||||
|
field_compute_type = self.product._fields["last_purchase_price_compute_type"]
|
||||||
|
|
||||||
|
self.assertTrue(field_last_purchase.company_dependent)
|
||||||
|
self.assertTrue(field_theoritical.company_dependent)
|
||||||
|
self.assertTrue(field_updated.company_dependent)
|
||||||
|
self.assertTrue(field_compute_type.company_dependent)
|
||||||
|
def test_compute_theoritical_price_with_actual_purchase_price(self):
|
||||||
|
"""Test that theoretical price is calculated correctly from last purchase price
|
||||||
|
This test simulates a real scenario where a product has a purchase price set"""
|
||||||
|
# Set a realistic purchase price
|
||||||
|
purchase_price = 10.50
|
||||||
|
self.product.last_purchase_price_received = purchase_price
|
||||||
|
self.product.last_purchase_price_compute_type = "without_discounts"
|
||||||
|
|
||||||
|
# Compute theoretical price
|
||||||
|
self.product._compute_theoritical_price()
|
||||||
|
|
||||||
|
# Verify price is not zero
|
||||||
|
self.assertNotEqual(
|
||||||
|
self.product.list_price_theoritical,
|
||||||
|
0.0,
|
||||||
|
"Theoretical price should not be 0.0 when last_purchase_price_received is set"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify price calculation is correct
|
||||||
|
# Expected: 10.50 * 1.50 (50% markup) = 15.75
|
||||||
|
# Plus 21% tax: 15.75 * 1.21 = 19.0575
|
||||||
|
# Rounded to 0.05: 19.05 or 19.10
|
||||||
|
expected_base = purchase_price * 1.50 # 15.75
|
||||||
|
expected_with_tax = expected_base * 1.21 # 19.0575
|
||||||
|
|
||||||
|
self.assertGreater(
|
||||||
|
self.product.list_price_theoritical,
|
||||||
|
expected_base,
|
||||||
|
"Theoretical price should include taxes"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Allow some tolerance for rounding
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
self.product.list_price_theoritical,
|
||||||
|
expected_with_tax,
|
||||||
|
delta=0.10,
|
||||||
|
msg=f"Expected around {expected_with_tax:.2f}, got {self.product.list_price_theoritical:.2f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_compute_price_zero_purchase_price(self):
|
||||||
|
"""Test behavior when last_purchase_price_received is 0.0"""
|
||||||
|
self.product.last_purchase_price_received = 0.0
|
||||||
|
self.product._compute_theoritical_price()
|
||||||
|
|
||||||
|
# When purchase price is 0, theoretical price should also be 0
|
||||||
|
self.assertEqual(
|
||||||
|
self.product.list_price_theoritical,
|
||||||
|
0.0,
|
||||||
|
"Theoretical price should be 0.0 when last_purchase_price_received is 0.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_pricelist_item_base_field(self):
|
||||||
|
"""Test that pricelist item uses last_purchase_price as base"""
|
||||||
|
self.assertEqual(
|
||||||
|
self.pricelist_item.base,
|
||||||
|
"last_purchase_price",
|
||||||
|
"Pricelist item should use last_purchase_price as base"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify the base field is properly configured
|
||||||
|
self.assertEqual(self.pricelist_item.compute_price, "formula")
|
||||||
|
self.assertEqual(self.pricelist_item.price_markup, 50.0)
|
||||||
78
product_sale_price_from_pricelist/tests/test_res_config.py
Normal file
78
product_sale_price_from_pricelist/tests/test_res_config.py
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
# 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 TestResConfigSettings(TransactionCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
|
||||||
|
cls.pricelist = cls.env["product.pricelist"].create({
|
||||||
|
"name": "Test Config Pricelist",
|
||||||
|
"currency_id": cls.env.company.currency_id.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_config_parameter_set_and_get(self):
|
||||||
|
"""Test setting and getting pricelist configuration"""
|
||||||
|
config = self.env["res.config.settings"].create({
|
||||||
|
"product_pricelist_automatic": self.pricelist.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
config.execute()
|
||||||
|
|
||||||
|
# Verify parameter was saved
|
||||||
|
saved_id = self.env["ir.config_parameter"].sudo().get_param(
|
||||||
|
"product_sale_price_from_pricelist.product_pricelist_automatic"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(int(saved_id), self.pricelist.id)
|
||||||
|
|
||||||
|
def test_config_load_from_parameter(self):
|
||||||
|
"""Test loading pricelist from config parameter"""
|
||||||
|
# Set parameter directly
|
||||||
|
self.env["ir.config_parameter"].sudo().set_param(
|
||||||
|
"product_sale_price_from_pricelist.product_pricelist_automatic",
|
||||||
|
str(self.pricelist.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create config and check if value is loaded
|
||||||
|
config = self.env["res.config.settings"].create({})
|
||||||
|
|
||||||
|
self.assertEqual(config.product_pricelist_automatic.id, self.pricelist.id)
|
||||||
|
|
||||||
|
def test_config_update_pricelist(self):
|
||||||
|
"""Test updating pricelist configuration"""
|
||||||
|
# Set initial pricelist
|
||||||
|
config = self.env["res.config.settings"].create({
|
||||||
|
"product_pricelist_automatic": self.pricelist.id,
|
||||||
|
})
|
||||||
|
config.execute()
|
||||||
|
|
||||||
|
# Create new pricelist and update
|
||||||
|
new_pricelist = self.env["product.pricelist"].create({
|
||||||
|
"name": "New Config Pricelist",
|
||||||
|
"currency_id": self.env.company.currency_id.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
config2 = self.env["res.config.settings"].create({
|
||||||
|
"product_pricelist_automatic": new_pricelist.id,
|
||||||
|
})
|
||||||
|
config2.execute()
|
||||||
|
|
||||||
|
# Verify new value
|
||||||
|
saved_id = self.env["ir.config_parameter"].sudo().get_param(
|
||||||
|
"product_sale_price_from_pricelist.product_pricelist_automatic"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(int(saved_id), new_pricelist.id)
|
||||||
|
|
||||||
|
def test_config_without_pricelist(self):
|
||||||
|
"""Test configuration can be saved without pricelist"""
|
||||||
|
config = self.env["res.config.settings"].create({})
|
||||||
|
|
||||||
|
# Should not raise error
|
||||||
|
config.execute()
|
||||||
244
product_sale_price_from_pricelist/tests/test_stock_move.py
Normal file
244
product_sale_price_from_pricelist/tests/test_stock_move.py
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
# 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 TestStockMove(TransactionCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
|
||||||
|
# Create tax
|
||||||
|
cls.tax = cls.env["account.tax"].create({
|
||||||
|
"name": "Test Tax 21%",
|
||||||
|
"amount": 21.0,
|
||||||
|
"amount_type": "percent",
|
||||||
|
"type_tax_use": "sale",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create pricelist
|
||||||
|
cls.pricelist = cls.env["product.pricelist"].create({
|
||||||
|
"name": "Test Pricelist",
|
||||||
|
"currency_id": cls.env.company.currency_id.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
cls.pricelist_item = 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)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create supplier
|
||||||
|
cls.supplier = cls.env["res.partner"].create({
|
||||||
|
"name": "Test Supplier",
|
||||||
|
"supplier_rank": 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create product with UoM
|
||||||
|
cls.uom_unit = cls.env.ref("uom.product_uom_unit")
|
||||||
|
cls.uom_dozen = cls.env.ref("uom.product_uom_dozen")
|
||||||
|
|
||||||
|
cls.product = cls.env["product.product"].create({
|
||||||
|
"name": "Test Product Stock",
|
||||||
|
"type": "product",
|
||||||
|
"uom_id": cls.uom_unit.id,
|
||||||
|
"uom_po_id": cls.uom_unit.id,
|
||||||
|
"list_price": 10.0,
|
||||||
|
"standard_price": 5.0,
|
||||||
|
"taxes_id": [(6, 0, [cls.tax.id])],
|
||||||
|
"last_purchase_price_received": 5.0,
|
||||||
|
"last_purchase_price_compute_type": "without_discounts",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create locations
|
||||||
|
cls.supplier_location = cls.env.ref("stock.stock_location_suppliers")
|
||||||
|
cls.stock_location = cls.env.ref("stock.stock_location_stock")
|
||||||
|
|
||||||
|
def _create_purchase_order(self, product, qty, price, discount1=0, discount2=0, discount3=0):
|
||||||
|
"""Helper to create a purchase order"""
|
||||||
|
purchase_order = self.env["purchase.order"].create({
|
||||||
|
"partner_id": self.supplier.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
po_line = self.env["purchase.order.line"].create({
|
||||||
|
"order_id": purchase_order.id,
|
||||||
|
"product_id": product.id,
|
||||||
|
"product_qty": qty,
|
||||||
|
"price_unit": price,
|
||||||
|
"product_uom": product.uom_po_id.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add discounts if module supports it
|
||||||
|
if hasattr(po_line, "discount1"):
|
||||||
|
po_line.discount1 = discount1
|
||||||
|
if hasattr(po_line, "discount2"):
|
||||||
|
po_line.discount2 = discount2
|
||||||
|
if hasattr(po_line, "discount3"):
|
||||||
|
po_line.discount3 = discount3
|
||||||
|
|
||||||
|
return purchase_order
|
||||||
|
|
||||||
|
def test_update_price_without_discounts(self):
|
||||||
|
"""Test price update without discounts"""
|
||||||
|
purchase_order = self._create_purchase_order(
|
||||||
|
self.product, qty=10, price=8.0
|
||||||
|
)
|
||||||
|
purchase_order.button_confirm()
|
||||||
|
|
||||||
|
# Get the picking and process it
|
||||||
|
picking = purchase_order.picking_ids[0]
|
||||||
|
picking.action_assign()
|
||||||
|
|
||||||
|
for move in picking.move_ids:
|
||||||
|
move.quantity = move.product_uom_qty
|
||||||
|
|
||||||
|
picking.button_validate()
|
||||||
|
|
||||||
|
# Verify price was updated
|
||||||
|
self.assertEqual(self.product.last_purchase_price_received, 8.0)
|
||||||
|
|
||||||
|
def test_update_price_with_first_discount(self):
|
||||||
|
"""Test price update with first discount only"""
|
||||||
|
if not hasattr(self.env["purchase.order.line"], "discount1"):
|
||||||
|
self.skipTest("Purchase discount module not installed")
|
||||||
|
|
||||||
|
self.product.last_purchase_price_compute_type = "with_discount"
|
||||||
|
|
||||||
|
purchase_order = self._create_purchase_order(
|
||||||
|
self.product, qty=10, price=10.0, discount1=20.0
|
||||||
|
)
|
||||||
|
purchase_order.button_confirm()
|
||||||
|
|
||||||
|
picking = purchase_order.picking_ids[0]
|
||||||
|
picking.action_assign()
|
||||||
|
|
||||||
|
for move in picking.move_ids:
|
||||||
|
move.quantity = move.product_uom_qty
|
||||||
|
|
||||||
|
picking.button_validate()
|
||||||
|
|
||||||
|
# Expected: 10.0 * (1 - 0.20) = 8.0
|
||||||
|
self.assertEqual(self.product.last_purchase_price_received, 8.0)
|
||||||
|
|
||||||
|
def test_update_price_with_two_discounts(self):
|
||||||
|
"""Test price update with two discounts"""
|
||||||
|
if not hasattr(self.env["purchase.order.line"], "discount2"):
|
||||||
|
self.skipTest("Purchase double discount module not installed")
|
||||||
|
|
||||||
|
self.product.last_purchase_price_compute_type = "with_two_discounts"
|
||||||
|
|
||||||
|
purchase_order = self._create_purchase_order(
|
||||||
|
self.product, qty=10, price=10.0, discount1=20.0, discount2=10.0
|
||||||
|
)
|
||||||
|
purchase_order.button_confirm()
|
||||||
|
|
||||||
|
picking = purchase_order.picking_ids[0]
|
||||||
|
picking.action_assign()
|
||||||
|
|
||||||
|
for move in picking.move_ids:
|
||||||
|
move.quantity = move.product_uom_qty
|
||||||
|
|
||||||
|
picking.button_validate()
|
||||||
|
|
||||||
|
# Expected: 10.0 * (1 - 0.20) * (1 - 0.10) = 7.2
|
||||||
|
self.assertEqual(self.product.last_purchase_price_received, 7.2)
|
||||||
|
|
||||||
|
def test_update_price_with_three_discounts(self):
|
||||||
|
"""Test price update with three discounts"""
|
||||||
|
if not hasattr(self.env["purchase.order.line"], "discount3"):
|
||||||
|
self.skipTest("Purchase triple discount module not installed")
|
||||||
|
|
||||||
|
self.product.last_purchase_price_compute_type = "with_three_discounts"
|
||||||
|
|
||||||
|
purchase_order = self._create_purchase_order(
|
||||||
|
self.product, qty=10, price=10.0, discount1=20.0, discount2=10.0, discount3=5.0
|
||||||
|
)
|
||||||
|
purchase_order.button_confirm()
|
||||||
|
|
||||||
|
picking = purchase_order.picking_ids[0]
|
||||||
|
picking.action_assign()
|
||||||
|
|
||||||
|
for move in picking.move_ids:
|
||||||
|
move.quantity = move.product_uom_qty
|
||||||
|
|
||||||
|
picking.button_validate()
|
||||||
|
|
||||||
|
# Price should be calculated from subtotal / qty
|
||||||
|
# Subtotal with all discounts applied
|
||||||
|
|
||||||
|
def test_update_price_with_uom_conversion(self):
|
||||||
|
"""Test price update with different purchase UoM"""
|
||||||
|
# Create product with different purchase UoM
|
||||||
|
product_dozen = self.product.copy({
|
||||||
|
"name": "Test Product Dozen",
|
||||||
|
"uom_po_id": self.uom_dozen.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
purchase_order = self._create_purchase_order(
|
||||||
|
product_dozen, qty=2, price=120.0 # 2 dozens at 120.0 per dozen
|
||||||
|
)
|
||||||
|
purchase_order.button_confirm()
|
||||||
|
|
||||||
|
picking = purchase_order.picking_ids[0]
|
||||||
|
picking.action_assign()
|
||||||
|
|
||||||
|
for move in picking.move_ids:
|
||||||
|
move.quantity = move.product_uom_qty
|
||||||
|
|
||||||
|
picking.button_validate()
|
||||||
|
|
||||||
|
# Price should be converted to base UoM (unit)
|
||||||
|
# 120.0 per dozen = 10.0 per unit
|
||||||
|
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
|
||||||
|
|
||||||
|
purchase_order = self._create_purchase_order(
|
||||||
|
self.product, qty=10, price=15.0
|
||||||
|
)
|
||||||
|
purchase_order.button_confirm()
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
def test_manual_update_type_no_automatic_update(self):
|
||||||
|
"""Test that manual update type prevents automatic price updates"""
|
||||||
|
self.product.last_purchase_price_compute_type = "manual_update"
|
||||||
|
initial_price = self.product.last_purchase_price_received
|
||||||
|
|
||||||
|
purchase_order = self._create_purchase_order(
|
||||||
|
self.product, qty=10, price=15.0
|
||||||
|
)
|
||||||
|
purchase_order.button_confirm()
|
||||||
|
|
||||||
|
picking = purchase_order.picking_ids[0]
|
||||||
|
picking.action_assign()
|
||||||
|
|
||||||
|
for move in picking.move_ids:
|
||||||
|
move.quantity = move.product_uom_qty
|
||||||
|
|
||||||
|
picking.button_validate()
|
||||||
|
|
||||||
|
# For manual update, the standard Odoo behavior applies
|
||||||
|
# which may or may not update standard_price, but our custom
|
||||||
|
# last_purchase_price_received logic still runs
|
||||||
|
|
@ -6,15 +6,12 @@
|
||||||
<record model="ir.ui.view" id="product_view_inherit_list_price_auto">
|
<record model="ir.ui.view" id="product_view_inherit_list_price_auto">
|
||||||
<field name="name">product.list.price.automatic.form</field>
|
<field name="name">product.list.price.automatic.form</field>
|
||||||
<field name="model">product.template</field>
|
<field name="model">product.template</field>
|
||||||
<field name="type">form</field>
|
|
||||||
<field name="inherit_id" ref="product.product_template_form_view" />
|
<field name="inherit_id" ref="product.product_template_form_view" />
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<field name="standard_price" position="attributes">
|
<field name="standard_price" position="attributes">
|
||||||
<attribute name="attrs">
|
<attribute name="readonly">last_purchase_price_compute_type != 'manual_update'</attribute>
|
||||||
{'readonly':[('last_purchase_price_compute_type','!=','manual_update')]}
|
|
||||||
</attribute>
|
|
||||||
</field>
|
</field>
|
||||||
<field name="product_tag_ids" position="after">
|
<field name="list_price" position="after">
|
||||||
<separator string="Label Info" colspan="2"/>
|
<separator string="Label Info" colspan="2"/>
|
||||||
<field name="last_purchase_price_compute_type" />
|
<field name="last_purchase_price_compute_type" />
|
||||||
<field name="last_purchase_price_received"
|
<field name="last_purchase_price_received"
|
||||||
|
|
@ -46,16 +43,13 @@
|
||||||
<field name="priority" eval="100" />
|
<field name="priority" eval="100" />
|
||||||
<field name="inherit_id" ref="sale.res_config_settings_view_form" />
|
<field name="inherit_id" ref="sale.res_config_settings_view_form" />
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<xpath expr="//div[@id='pricing_setting_container']" position="inside">
|
<xpath expr="//block[@id='pricing_setting_container']" position="inside">
|
||||||
<div
|
<setting name="supermarket_settings_container">
|
||||||
class="col-12 col-lg-6 o_setting_box"
|
|
||||||
name="supermarket_settigs_container"
|
|
||||||
>
|
|
||||||
<div class="o_setting_right_pane">
|
<div class="o_setting_right_pane">
|
||||||
<label for="product_pricelist_automatic" string="Sale Price Pricelist" />
|
<label for="product_pricelist_automatic" string="Sale Price Pricelist" />
|
||||||
<field name="product_pricelist_automatic" />
|
<field name="product_pricelist_automatic" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</setting>
|
||||||
</xpath>
|
</xpath>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue