Compare commits

...

5 Commits
main ... 19.0

13 changed files with 116 additions and 125 deletions

15
.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
# Python
*.py[cod]
__pycache__/
*.so
# Odoo
*.po~
*.pot~
# Editor / System
.DS_Store
.vscode/
*.swp
*.swo
*~

View File

@ -223,7 +223,7 @@ python customaddons/vendor_bill_editable_totals/tests/run_integration_tests.py
## Compatibility ## Compatibility
- **Odoo Version:** 17.0 - **Odoo Version:** 19.0
- **Python Version:** 3.10+ - **Python Version:** 3.10+
- **Database:** PostgreSQL 12+ - **Database:** PostgreSQL 12+
- **License:** LGPL-3 - **License:** LGPL-3
@ -238,6 +238,10 @@ For issues, questions, or contributions, please contact your system administrato
## Changelog ## Changelog
### Version 19.0.1.0.3
- Fix incorrect vendor bill line revaluation during credit note creation
- Restrict logic to vendor bills (`in_invoice`) only
### Version 17.0.1.0.0 ### Version 17.0.1.0.0
- Initial release - Initial release
- Direct editing of price_subtotal on vendor bill lines - Direct editing of price_subtotal on vendor bill lines

View File

@ -1,6 +1,6 @@
{ {
"name": "Vendor Bill Editable Totals", "name": "Vendor Bill Editable Totals",
"version": "17.0.1.0.2", "version": "19.0.1.0.3",
"summary": "Enable direct editing of tax excluded and tax included amounts on vendor bill lines with automatic unit price recalculation", "summary": "Enable direct editing of tax excluded and tax included amounts on vendor bill lines with automatic unit price recalculation",
"description": """ "description": """
Vendor Bill Editable Totals Vendor Bill Editable Totals
@ -23,7 +23,9 @@
"author": "Suherdy Yacob", "author": "Suherdy Yacob",
"category": "Accounting", "category": "Accounting",
"depends": [ "depends": [
"account" "account",
"purchase",
"stock_account",
], ],
"data": [ "data": [
"views/account_move_views.xml", "views/account_move_views.xml",

Binary file not shown.

View File

@ -3,8 +3,7 @@
from odoo import api, fields, models, _ from odoo import api, fields, models, _
from odoo.exceptions import UserError from odoo.exceptions import UserError
from odoo.tools import float_compare
from datetime import timedelta
class AccountMoveLine(models.Model): class AccountMoveLine(models.Model):
@ -117,7 +116,10 @@ class AccountMoveLine(models.Model):
if 'price_unit' in vals or 'quantity' in vals: if 'price_unit' in vals or 'quantity' in vals:
for line in self: for line in self:
# Only for vendor bills and if linked to a PO line # Only for vendor bills and if linked to a PO line
if line.move_id.move_type in ('in_invoice', 'in_refund') and line.purchase_line_id: # Fix: Do not sync if triggered by Credit Note creation (Reversal Wizard)
if line.move_id.move_type == 'in_invoice' and \
line.purchase_line_id and \
self.env.context.get('active_model') != 'account.move.reversal':
po_line = line.purchase_line_id po_line = line.purchase_line_id
# 1. Sync Price # 1. Sync Price
@ -160,110 +162,7 @@ class AccountMoveLine(models.Model):
if was_locked: if was_locked:
po.button_done() po.button_done()
# ---------------------------------------------------------
# INVENTORY VALUATION UPDATE (AVCO/FIFO FIX)
# ---------------------------------------------------------
# If PO is received, updating price should update Stock Value
if po_line.state in ['purchase', 'done'] and po_line.product_id.type == 'product':
for stock_move in po_line.move_ids.filtered(lambda m: m.state == 'done'):
# Calculate Diff based on NEW Price (updated above)
new_val = price_unit * stock_move.quantity
# Current Value from SVLs
current_val = sum(stock_move.stock_valuation_layer_ids.mapped('value'))
diff = new_val - current_val
currency = stock_move.company_id.currency_id
if not currency.is_zero(diff):
# 1. Create Correction SVL
svl_vals = {
'company_id': stock_move.company_id.id,
'product_id': stock_move.product_id.id,
'description': _("Valuation correction from Vendor Bill %s") % line.move_id.name,
'value': diff,
'quantity': 0,
'stock_move_id': stock_move.id,
}
svl = self.env['stock.valuation.layer'].create(svl_vals)
# Backdate SVL
if stock_move.date:
new_date = stock_move.date + timedelta(seconds=1)
self.env.cr.execute("UPDATE stock_valuation_layer SET create_date = %s WHERE id = %s", (new_date, svl.id))
# 2. AVCO/FIFO Logic: Update Standard Price and Distribute Value
product = stock_move.product_id
if product.categ_id.property_cost_method in ['average', 'fifo'] and product.quantity_svl > 0:
new_std_price = product.standard_price + (diff / product.quantity_svl)
product.with_context(disable_auto_svl=True).sudo().write({'standard_price': new_std_price})
remaining_svls = self.env['stock.valuation.layer'].search([
('product_id', '=', product.id),
('remaining_qty', '>', 0),
('company_id', '=', stock_move.company_id.id),
])
if remaining_svls:
remaining_qty_total = sum(remaining_svls.mapped('remaining_qty'))
if remaining_qty_total > 0:
remaining_value_to_distribute = diff
remaining_value_unit_cost = remaining_value_to_distribute / remaining_qty_total
for layer in remaining_svls:
if float_compare(layer.remaining_qty, remaining_qty_total, precision_rounding=product.uom_id.rounding) >= 0:
taken_remaining_value = remaining_value_to_distribute
else:
taken_remaining_value = remaining_value_unit_cost * layer.remaining_qty
taken_remaining_value = stock_move.company_id.currency_id.round(taken_remaining_value)
layer.sudo().write({'remaining_value': layer.remaining_value + taken_remaining_value})
remaining_value_to_distribute -= taken_remaining_value
remaining_qty_total -= layer.remaining_qty
# 3. Create Accounting Entry
if stock_move.product_id.categ_id.property_valuation == 'real_time':
accounts = stock_move.product_id.product_tmpl_id.get_product_accounts()
acc_expense = accounts.get('expense')
acc_valuation = accounts.get('stock_valuation')
if acc_expense and acc_valuation:
if diff > 0:
debit_acc = acc_valuation.id
credit_acc = acc_expense.id
amount = diff
else:
debit_acc = acc_expense.id
credit_acc = acc_valuation.id
amount = abs(diff)
acc_date = stock_move.date.date() if stock_move.date else fields.Date.today()
move_vals = {
'journal_id': accounts['stock_journal'].id,
'company_id': stock_move.company_id.id,
'ref': _("Revaluation for %s from Bill Edit") % stock_move.product_id.name,
'date': acc_date,
'move_type': 'entry',
'stock_valuation_layer_ids': [(6, 0, [svl.id])],
'line_ids': [
(0, 0, {
'name': _("Valuation Correction - %s") % stock_move.product_id.name,
'account_id': debit_acc,
'debit': amount,
'credit': 0,
'product_id': stock_move.product_id.id,
}),
(0, 0, {
'name': _("Valuation Correction - %s") % stock_move.product_id.name,
'account_id': credit_acc,
'debit': 0,
'credit': amount,
'product_id': stock_move.product_id.id,
})
]
}
am = self.env['account.move'].create(move_vals)
am._post()
return res return res

View File

@ -6,3 +6,4 @@ from . import test_price_total_property
from . import test_edge_cases from . import test_edge_cases
from . import test_view_configuration from . import test_view_configuration
from . import test_integration from . import test_integration
from . import test_no_revaluation

View File

@ -80,7 +80,7 @@ def test_price_subtotal_field_editable():
# Find the xpath that modifies price_subtotal # Find the xpath that modifies price_subtotal
xpath_elements = root.xpath( xpath_elements = root.xpath(
"//xpath[@expr=\"//field[@name='invoice_line_ids']/tree//field[@name='price_subtotal']\"]" "//xpath[@expr=\"//field[@name='invoice_line_ids']/list//field[@name='price_subtotal']\"]"
) )
if not xpath_elements: if not xpath_elements:
@ -132,7 +132,7 @@ def test_price_total_field_editable():
# Find the xpath that modifies price_total # Find the xpath that modifies price_total
xpath_elements = root.xpath( xpath_elements = root.xpath(
"//xpath[@expr=\"//field[@name='invoice_line_ids']/tree//field[@name='price_total']\"]" "//xpath[@expr=\"//field[@name='invoice_line_ids']/list//field[@name='price_total']\"]"
) )
if not xpath_elements: if not xpath_elements:
@ -184,7 +184,7 @@ def test_fields_readonly_conditions():
# Check price_subtotal attrs # Check price_subtotal attrs
xpath_subtotal = root.xpath( xpath_subtotal = root.xpath(
"//xpath[@expr=\"//field[@name='invoice_line_ids']/tree//field[@name='price_subtotal']\"]" "//xpath[@expr=\"//field[@name='invoice_line_ids']/list//field[@name='price_subtotal']\"]"
) )
if not xpath_subtotal: if not xpath_subtotal:
@ -205,7 +205,7 @@ def test_fields_readonly_conditions():
# Check price_total attrs # Check price_total attrs
xpath_total = root.xpath( xpath_total = root.xpath(
"//xpath[@expr=\"//field[@name='invoice_line_ids']/tree//field[@name='price_total']\"]" "//xpath[@expr=\"//field[@name='invoice_line_ids']/list//field[@name='price_total']\"]"
) )
if not xpath_total: if not xpath_total:

View File

@ -0,0 +1,72 @@
# -*- coding: utf-8 -*-
from odoo.tests import TransactionCase, tagged
from odoo.fields import Date
@tagged('post_install', '-at_install', 'repro_bug')
class TestNoRevaluation(TransactionCase):
def setUp(self):
super().setUp()
self.env.company.currency_id = self.env.ref('base.USD')
# Create a product with automated valuation (AVCO)
self.product_category = self.env['product.category'].create({
'name': 'Test Auto Valuation',
'property_cost_method': 'average',
'property_valuation': 'real_time',
})
self.product = self.env['product.product'].create({
'name': 'Test Product',
'type': 'consu',
'is_storable': True,
'categ_id': self.product_category.id,
'standard_price': 100.0,
})
# Create a vendor
self.vendor = self.env['res.partner'].create({'name': 'Test Vendor'})
def test_no_revaluation_on_bill_edit(self):
"""
Verify that editing a vendor bill line price does NOT trigger
inventory revaluation (SVL creation) or accounting entries.
"""
# 1. Create and Confirm PO
po = self.env['purchase.order'].create({
'partner_id': self.vendor.id,
'order_line': [(0, 0, {
'product_id': self.product.id,
'product_qty': 10.0,
'price_unit': 100.0,
})],
})
po.button_confirm()
# 2. Receive Products
picking = po.picking_ids[0]
picking.button_validate()
# Verify initial SVL
svls = self.env['stock.valuation.layer'].search([('product_id', '=', self.product.id)])
self.assertEqual(len(svls), 1, "Should be 1 SVL for reception")
initial_svl_count = len(svls)
# 3. Create Vendor Bill
action = po.action_create_invoice()
bill = self.env['account.move'].browse(action['res_id'])
bill.invoice_date = Date.today()
# 4. Edit Vendor Bill Line Price (Simulate User Edit)
# Change price from 100 to 120
# This triggers the write method on account.move.line
line = bill.invoice_line_ids[0]
line.price_unit = 120.0 # This triggers the write method
# 5. Verify NO new SVL created
new_svls = self.env['stock.valuation.layer'].search([('product_id', '=', self.product.id)])
self.assertEqual(len(new_svls), initial_svl_count, "No new SVL should be created after bill edit")
# Verify standard price did NOT change (since no revaluation)
# Note: If revaluation logic was present, it would update standard_price
# However, since we expect NO revaluation, standard price might stay same or move based on other logic?
# The key is checking SVL count.

View File

@ -50,7 +50,7 @@ class TestViewConfiguration(TransactionCase):
arch = etree.fromstring(self.view.arch) arch = etree.fromstring(self.view.arch)
# Find the xpath that modifies price_subtotal # Find the xpath that modifies price_subtotal
xpath_elements = arch.xpath("//xpath[@expr=\"//field[@name='invoice_line_ids']/tree//field[@name='price_subtotal']\"]") xpath_elements = arch.xpath("//xpath[@expr=\"//field[@name='invoice_line_ids']/list//field[@name='price_subtotal']\"]")
self.assertTrue( self.assertTrue(
len(xpath_elements) > 0, len(xpath_elements) > 0,
@ -80,7 +80,7 @@ class TestViewConfiguration(TransactionCase):
arch = etree.fromstring(self.view.arch) arch = etree.fromstring(self.view.arch)
# Find the xpath that modifies price_total # Find the xpath that modifies price_total
xpath_elements = arch.xpath("//xpath[@expr=\"//field[@name='invoice_line_ids']/tree//field[@name='price_total']\"]") xpath_elements = arch.xpath("//xpath[@expr=\"//field[@name='invoice_line_ids']/list//field[@name='price_total']\"]")
self.assertTrue( self.assertTrue(
len(xpath_elements) > 0, len(xpath_elements) > 0,
@ -110,7 +110,7 @@ class TestViewConfiguration(TransactionCase):
arch = etree.fromstring(self.view.arch) arch = etree.fromstring(self.view.arch)
# Check price_subtotal attrs # Check price_subtotal attrs
xpath_subtotal = arch.xpath("//xpath[@expr=\"//field[@name='invoice_line_ids']/tree//field[@name='price_subtotal']\"]") xpath_subtotal = arch.xpath("//xpath[@expr=\"//field[@name='invoice_line_ids']/list//field[@name='price_subtotal']\"]")
self.assertTrue(len(xpath_subtotal) > 0, "price_subtotal xpath should exist") self.assertTrue(len(xpath_subtotal) > 0, "price_subtotal xpath should exist")
attrs_subtotal = xpath_subtotal[0].xpath(".//attribute[@name='attrs']") attrs_subtotal = xpath_subtotal[0].xpath(".//attribute[@name='attrs']")
@ -133,7 +133,7 @@ class TestViewConfiguration(TransactionCase):
) )
# Check price_total attrs # Check price_total attrs
xpath_total = arch.xpath("//xpath[@expr=\"//field[@name='invoice_line_ids']/tree//field[@name='price_total']\"]") xpath_total = arch.xpath("//xpath[@expr=\"//field[@name='invoice_line_ids']/list//field[@name='price_total']\"]")
self.assertTrue(len(xpath_total) > 0, "price_total xpath should exist") self.assertTrue(len(xpath_total) > 0, "price_total xpath should exist")
attrs_total = xpath_total[0].xpath(".//attribute[@name='attrs']") attrs_total = xpath_total[0].xpath(".//attribute[@name='attrs']")
@ -251,7 +251,7 @@ class TestViewConfiguration(TransactionCase):
arch = etree.fromstring(self.view.arch) arch = etree.fromstring(self.view.arch)
# Verify both fields have attrs that reference parent.state # Verify both fields have attrs that reference parent.state
xpath_subtotal = arch.xpath("//xpath[@expr=\"//field[@name='invoice_line_ids']/tree//field[@name='price_subtotal']\"]") xpath_subtotal = arch.xpath("//xpath[@expr=\"//field[@name='invoice_line_ids']/list//field[@name='price_subtotal']\"]")
attrs_subtotal = xpath_subtotal[0].xpath(".//attribute[@name='attrs']") attrs_subtotal = xpath_subtotal[0].xpath(".//attribute[@name='attrs']")
attrs_text_subtotal = attrs_subtotal[0].text attrs_text_subtotal = attrs_subtotal[0].text
@ -262,7 +262,7 @@ class TestViewConfiguration(TransactionCase):
"Attrs should reference parent.state for price_subtotal" "Attrs should reference parent.state for price_subtotal"
) )
xpath_total = arch.xpath("//xpath[@expr=\"//field[@name='invoice_line_ids']/tree//field[@name='price_total']\"]") xpath_total = arch.xpath("//xpath[@expr=\"//field[@name='invoice_line_ids']/list//field[@name='price_total']\"]")
attrs_total = xpath_total[0].xpath(".//attribute[@name='attrs']") attrs_total = xpath_total[0].xpath(".//attribute[@name='attrs']")
attrs_text_total = attrs_total[0].text attrs_text_total = attrs_total[0].text

View File

@ -8,15 +8,13 @@
<field name="inherit_id" ref="account.view_move_form"/> <field name="inherit_id" ref="account.view_move_form"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<!-- Make price_subtotal editable in vendor bill invoice lines --> <!-- Make price_subtotal editable in vendor bill invoice lines -->
<xpath expr="//field[@name='invoice_line_ids']/tree//field[@name='price_subtotal']" position="attributes"> <xpath expr="//field[@name='invoice_line_ids']/list//field[@name='price_subtotal']" position="attributes">
<attribute name="readonly">0</attribute> <attribute name="readonly">parent.state != 'draft'</attribute>
<attribute name="attrs">{'readonly': [('parent.state', '!=', 'draft')]}</attribute>
</xpath> </xpath>
<!-- Make price_total editable in vendor bill invoice lines --> <!-- Make price_total editable in vendor bill invoice lines -->
<xpath expr="//field[@name='invoice_line_ids']/tree//field[@name='price_total']" position="attributes"> <xpath expr="//field[@name='invoice_line_ids']/list//field[@name='price_total']" position="attributes">
<attribute name="readonly">0</attribute> <attribute name="readonly">parent.state != 'draft'</attribute>
<attribute name="attrs">{'readonly': [('parent.state', '!=', 'draft')]}</attribute>
</xpath> </xpath>
</field> </field>
</record> </record>