273 lines
15 KiB
Python
Executable File
273 lines
15 KiB
Python
Executable File
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from odoo import api, fields, models, _
|
|
from odoo.exceptions import UserError
|
|
from odoo.tools import float_compare
|
|
from datetime import timedelta
|
|
|
|
|
|
class AccountMoveLine(models.Model):
|
|
_inherit = 'account.move.line'
|
|
|
|
# Override price_unit to use more decimal places
|
|
price_unit = fields.Float(
|
|
string='Unit Price',
|
|
digits=(16, 10), # Use 10 decimal places for maximum precision
|
|
)
|
|
|
|
@api.onchange('price_subtotal')
|
|
def _onchange_price_subtotal(self):
|
|
"""
|
|
Recalculate price_unit when price_subtotal is manually edited.
|
|
|
|
This method is triggered when a user modifies the price_subtotal field
|
|
on a vendor bill line. It automatically calculates the unit price to
|
|
maintain the exact entered subtotal amount.
|
|
|
|
Requirements: 1.2, 1.3, 1.4, 3.1, 3.5, 5.3
|
|
"""
|
|
for line in self:
|
|
# Skip if not a vendor bill line or if in a computed context
|
|
if line.move_id.move_type not in ('in_invoice', 'in_refund'):
|
|
continue
|
|
|
|
# Validate quantity is not zero
|
|
if line.quantity == 0:
|
|
raise UserError(_("Cannot calculate unit price: quantity must be greater than zero"))
|
|
|
|
# Calculate price_unit from price_subtotal
|
|
# Formula: price_unit = price_subtotal / quantity
|
|
new_price_unit = line.price_subtotal / line.quantity
|
|
|
|
# Set the price_unit - now with 10 decimal precision
|
|
line.price_unit = new_price_unit
|
|
|
|
@api.onchange('price_total')
|
|
def _onchange_price_total(self):
|
|
"""
|
|
Recalculate price_unit when price_total is manually edited.
|
|
|
|
This method is triggered when a user modifies the price_total field
|
|
on a vendor bill line. It automatically calculates the unit price by
|
|
first deriving the price_subtotal from the price_total (accounting for
|
|
taxes), then calculating the unit price.
|
|
|
|
Requirements: 2.2, 2.3, 2.5, 3.1, 3.3, 3.4, 3.5, 5.3
|
|
"""
|
|
for line in self:
|
|
# Skip if not a vendor bill line or if in a computed context
|
|
if line.move_id.move_type not in ('in_invoice', 'in_refund'):
|
|
continue
|
|
|
|
# Validate quantity is not zero
|
|
if line.quantity == 0:
|
|
raise UserError(_("Cannot calculate unit price: quantity must be greater than zero"))
|
|
|
|
# Handle case with no taxes: price_total equals price_subtotal
|
|
if not line.tax_ids:
|
|
new_price_unit = line.price_total / line.quantity
|
|
line.price_unit = new_price_unit
|
|
continue
|
|
|
|
# Check if any taxes are price-included
|
|
# For tax-included taxes, the price_unit already includes the tax
|
|
has_price_included_tax = any(tax.price_include for tax in line.tax_ids)
|
|
|
|
if has_price_included_tax:
|
|
# For tax-included taxes, price_unit = price_total / quantity
|
|
# because the tax is already included in the unit price
|
|
new_price_unit = line.price_total / line.quantity
|
|
line.price_unit = new_price_unit
|
|
else:
|
|
# For tax-excluded taxes, we need to calculate the tax factor
|
|
# Use a temporary price_unit of 1.0 to get the tax multiplier
|
|
tax_results = line.tax_ids.compute_all(
|
|
price_unit=1.0,
|
|
currency=line.currency_id,
|
|
quantity=1.0,
|
|
product=line.product_id,
|
|
partner=line.move_id.partner_id
|
|
)
|
|
|
|
# Calculate the tax factor (total_included / total_excluded)
|
|
# This tells us the multiplier from subtotal to total
|
|
if tax_results['total_excluded'] != 0:
|
|
tax_factor = tax_results['total_included'] / tax_results['total_excluded']
|
|
else:
|
|
tax_factor = 1.0
|
|
|
|
# Derive price_subtotal from price_total
|
|
# Formula: price_subtotal = price_total / tax_factor
|
|
derived_price_subtotal = line.price_total / tax_factor
|
|
|
|
# Calculate price_unit from derived price_subtotal
|
|
# Formula: price_unit = price_subtotal / quantity
|
|
new_price_unit = derived_price_subtotal / line.quantity
|
|
|
|
line.price_unit = new_price_unit
|
|
|
|
def write(self, vals):
|
|
"""
|
|
Override write to sync changes to linked Purchase Order Line.
|
|
"""
|
|
res = super(AccountMoveLine, self).write(vals)
|
|
|
|
# Check if we need to sync
|
|
if 'price_unit' in vals or 'quantity' in vals:
|
|
for line in self:
|
|
# Only for vendor bills and if linked to a PO line
|
|
# 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
|
|
|
|
# 1. Sync Price
|
|
# Convert bill price (in bill currency) to PO currency
|
|
bill_currency = line.currency_id or line.move_id.currency_id
|
|
po_currency = po_line.currency_id or po_line.order_id.currency_id
|
|
price_unit = line.price_unit
|
|
|
|
if bill_currency and po_currency and bill_currency != po_currency:
|
|
price_unit = bill_currency._convert(
|
|
price_unit,
|
|
po_currency,
|
|
line.company_id,
|
|
line.move_id.invoice_date or fields.Date.today()
|
|
)
|
|
|
|
# Convert Price to PO UoM if needed
|
|
if line.product_uom_id and po_line.product_uom and line.product_uom_id != po_line.product_uom:
|
|
price_unit = line.product_uom_id._compute_price(price_unit, po_line.product_uom)
|
|
|
|
# 2. Sync Quantity
|
|
# Convert bill qty (in bill UoM) to PO UoM
|
|
product_qty = line.quantity
|
|
if line.product_uom_id and po_line.product_uom and line.product_uom_id != po_line.product_uom:
|
|
product_qty = line.product_uom_id._compute_quantity(product_qty, po_line.product_uom)
|
|
|
|
# Update PO Line
|
|
# We update both to ensure consistency
|
|
|
|
po = po_line.order_id
|
|
was_locked = po.state == 'done'
|
|
if was_locked:
|
|
po.button_unlock()
|
|
|
|
po_line.write({
|
|
'price_unit': price_unit,
|
|
'product_qty': product_qty,
|
|
})
|
|
|
|
if was_locked:
|
|
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
|
|
|