295 lines
17 KiB
Python
295 lines
17 KiB
Python
from odoo import models, fields, api, _
|
|
from odoo.exceptions import UserError
|
|
from odoo.tools import float_compare
|
|
from datetime import timedelta
|
|
|
|
class PurchaseBillSyncWizard(models.TransientModel):
|
|
_name = 'purchase.bill.sync.wizard'
|
|
_description = 'Purchase Bill Sync Wizard'
|
|
|
|
date_from = fields.Date(string='Start Date', required=True)
|
|
date_to = fields.Date(string='End Date', required=True)
|
|
|
|
line_ids = fields.One2many('purchase.bill.sync.line', 'wizard_id', string='Discrepancies')
|
|
|
|
def action_analyze(self):
|
|
self.ensure_one()
|
|
# Clear existing lines
|
|
self.line_ids.unlink()
|
|
|
|
domain = [
|
|
('move_type', 'in', ('in_invoice', 'in_refund')),
|
|
('invoice_date', '>=', self.date_from),
|
|
('invoice_date', '<=', self.date_to),
|
|
('state', '!=', 'cancel'),
|
|
]
|
|
|
|
moves = self.env['account.move'].search(domain)
|
|
|
|
sync_lines = []
|
|
for move in moves:
|
|
discrepancies = []
|
|
|
|
for line in move.invoice_line_ids:
|
|
if not line.purchase_line_id:
|
|
continue
|
|
|
|
po_line = line.purchase_line_id
|
|
|
|
# Currency Conversion
|
|
bill_currency = move.currency_id
|
|
po_currency = po_line.currency_id
|
|
|
|
bill_price_in_po_currency = line.price_unit
|
|
if bill_currency and po_currency and bill_currency != po_currency:
|
|
bill_price_in_po_currency = bill_currency._convert(
|
|
line.price_unit,
|
|
po_currency,
|
|
move.company_id,
|
|
move.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:
|
|
bill_price_in_po_currency = line.product_uom_id._compute_price(bill_price_in_po_currency, po_line.product_uom)
|
|
|
|
if float_compare(bill_price_in_po_currency, po_line.price_unit, precision_digits=2) != 0:
|
|
discrepancies.append(f"Product {line.product_id.name}: Price {bill_price_in_po_currency:.2f} != {po_line.price_unit:.2f}")
|
|
|
|
# Check Qty
|
|
# Convert bill qty to PO UoM for comparison
|
|
bill_qty_in_po_uom = line.quantity
|
|
if line.product_uom_id and po_line.product_uom and line.product_uom_id != po_line.product_uom:
|
|
bill_qty_in_po_uom = line.product_uom_id._compute_quantity(line.quantity, po_line.product_uom)
|
|
|
|
if float_compare(bill_qty_in_po_uom, po_line.product_qty, precision_digits=2) != 0:
|
|
discrepancies.append(f"Product {line.product_id.name}: Qty {bill_qty_in_po_uom} != {po_line.product_qty}")
|
|
|
|
if discrepancies:
|
|
sync_lines.append((0, 0, {
|
|
'move_id': move.id,
|
|
'partner_id': move.partner_id.id,
|
|
'discrepancy_details': "\n".join(discrepancies),
|
|
'selected': True,
|
|
}))
|
|
|
|
self.write({'line_ids': sync_lines})
|
|
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'purchase.bill.sync.wizard',
|
|
'view_mode': 'form',
|
|
'res_id': self.id,
|
|
'target': 'new',
|
|
}
|
|
|
|
def action_sync(self):
|
|
self.ensure_one()
|
|
count = 0
|
|
for line in self.line_ids:
|
|
if line.selected:
|
|
count += 1
|
|
move = line.move_id
|
|
for inv_line in move.invoice_line_ids:
|
|
if inv_line.purchase_line_id:
|
|
po_line = inv_line.purchase_line_id
|
|
bill_currency = move.currency_id
|
|
po_currency = po_line.currency_id
|
|
|
|
price_unit = inv_line.price_unit
|
|
if bill_currency and po_currency and bill_currency != po_currency:
|
|
price_unit = bill_currency._convert(
|
|
price_unit,
|
|
po_currency,
|
|
move.company_id,
|
|
move.invoice_date or fields.Date.today()
|
|
)
|
|
|
|
# UoM Conversion for Price
|
|
if inv_line.product_uom_id and po_line.product_uom and inv_line.product_uom_id != po_line.product_uom:
|
|
price_unit = inv_line.product_uom_id._compute_price(price_unit, po_line.product_uom)
|
|
|
|
# UoM Conversion for Qty
|
|
product_qty = inv_line.quantity
|
|
if inv_line.product_uom_id and po_line.product_uom and inv_line.product_uom_id != po_line.product_uom:
|
|
product_qty = inv_line.product_uom_id._compute_quantity(product_qty, po_line.product_uom)
|
|
vals = {'price_unit': price_unit}
|
|
|
|
# Only update Qty if changed to avoid "Cannot decrease below received" error
|
|
diff_res = float_compare(product_qty, po_line.product_qty, precision_rounding=po_line.product_uom.rounding)
|
|
if diff_res != 0:
|
|
vals['product_qty'] = product_qty
|
|
|
|
# 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() # Unlock
|
|
|
|
po_line.write(vals)
|
|
|
|
# Check for Valuation Update
|
|
if po_line.product_id.type == 'product': # Storable products only
|
|
for stock_move in po_line.move_ids:
|
|
if stock_move.state == 'done':
|
|
# Calculate Diff
|
|
# Theoretical Value based on Bill Price (new_price)
|
|
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
|
|
|
|
# Rounding check
|
|
currency = stock_move.company_id.currency_id
|
|
if not currency.is_zero(diff):
|
|
# Create SVL
|
|
svl_vals = {
|
|
'company_id': stock_move.company_id.id,
|
|
'product_id': stock_move.product_id.id,
|
|
'description': f"Valuation correction from Vendor Bill {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 to Stock Move Date + 1 second
|
|
# We use SQL because create_date is read-only in ORM
|
|
if stock_move.date:
|
|
new_date = stock_move.date + timedelta(seconds=1)
|
|
self._cr.execute("UPDATE stock_valuation_layer SET create_date = %s WHERE id = %s", (new_date, svl.id))
|
|
|
|
# AVCO/FIFO Logic: Update Standard Price and Distribute Value to Layers
|
|
product = stock_move.product_id
|
|
if product.categ_id.property_cost_method in ['average', 'fifo'] and product.quantity_svl > 0:
|
|
# 1. Update Standard Price
|
|
# We use disable_auto_svl to prevent Odoo from creating an extra SVL for this price change
|
|
# because we already created the specific adjustment SVL above.
|
|
new_std_price = product.standard_price + (diff / product.quantity_svl)
|
|
product.with_context(disable_auto_svl=True).sudo().write({'standard_price': new_std_price})
|
|
|
|
# 2. Distribute Value to Remaining Layers (Crucial for correct COGS later)
|
|
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
|
|
|
|
# Rounding
|
|
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
|
|
|
|
# Handle Accounting Entry if Automated
|
|
if stock_move.product_id.categ_id.property_valuation == 'real_time':
|
|
accounts = stock_move.product_id.product_tmpl_id.get_product_accounts()
|
|
|
|
# Default counterpart to Expense Account
|
|
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)
|
|
|
|
# Use Stock Move Date for Accounting Date
|
|
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': f"Revaluation for {stock_move.product_id.name} from Bill Sync",
|
|
'date': acc_date,
|
|
'move_type': 'entry',
|
|
'stock_valuation_layer_ids': [(6, 0, [svl.id])],
|
|
'line_ids': [
|
|
(0, 0, {
|
|
'name': f"Valuation Correction - {stock_move.product_id.name}",
|
|
'account_id': debit_acc,
|
|
'debit': amount,
|
|
'credit': 0,
|
|
'product_id': stock_move.product_id.id,
|
|
}),
|
|
(0, 0, {
|
|
'name': f"Valuation Correction - {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()
|
|
|
|
if was_locked:
|
|
po.button_done()
|
|
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'display_notification',
|
|
'params': {
|
|
'title': _('Success'),
|
|
'message': _('%s Bills Synced Successfully', count),
|
|
'type': 'success',
|
|
'sticky': False,
|
|
'next': {'type': 'ir.actions.act_window_close'},
|
|
}
|
|
}
|
|
|
|
def action_check_all(self):
|
|
self.ensure_one()
|
|
self.line_ids.write({'selected': True})
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'purchase.bill.sync.wizard',
|
|
'view_mode': 'form',
|
|
'res_id': self.id,
|
|
'target': 'new',
|
|
}
|
|
|
|
def action_uncheck_all(self):
|
|
self.ensure_one()
|
|
self.line_ids.write({'selected': False})
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'purchase.bill.sync.wizard',
|
|
'view_mode': 'form',
|
|
'res_id': self.id,
|
|
'target': 'new',
|
|
}
|
|
|
|
class PurchaseBillSyncLine(models.TransientModel):
|
|
_name = 'purchase.bill.sync.line'
|
|
_description = 'Line for Sync Wizard'
|
|
|
|
wizard_id = fields.Many2one('purchase.bill.sync.wizard')
|
|
selected = fields.Boolean(string="Sync", default=True)
|
|
move_id = fields.Many2one('account.move', string="Vendor Bill", readonly=True)
|
|
partner_id = fields.Many2one('res.partner', string="Vendor", readonly=True)
|
|
discrepancy_details = fields.Text(string="Details", readonly=True)
|