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.env.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)