first commit
This commit is contained in:
commit
9c2b160e98
1
__init__.py
Normal file
1
__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import wizard
|
||||
21
__manifest__.py
Normal file
21
__manifest__.py
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
'name': 'Purchase Bill Sync',
|
||||
'version': '17.0.1.0.0',
|
||||
'category': 'Purchases',
|
||||
'summary': 'Sync Vendor Bills with Purchase Orders',
|
||||
'description': """
|
||||
This module allows users to:
|
||||
1. Find discrepancies between Vendor Bills and linked Purchase Orders within a date range.
|
||||
2. Sync selected Vendor Bills to update the Purchase Orders.
|
||||
""",
|
||||
'author': 'Antigravity',
|
||||
'depends': ['purchase', 'account'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'wizard/purchase_bill_sync_wizard_views.xml',
|
||||
'views/purchase_bill_sync_menus.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
BIN
__pycache__/__init__.cpython-312.pyc
Normal file
BIN
__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
3
security/ir.model.access.csv
Normal file
3
security/ir.model.access.csv
Normal file
@ -0,0 +1,3 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_purchase_bill_sync_wizard,purchase.bill.sync.wizard,model_purchase_bill_sync_wizard,purchase.group_purchase_user,1,1,1,1
|
||||
access_purchase_bill_sync_line,purchase.bill.sync.line,model_purchase_bill_sync_line,purchase.group_purchase_user,1,1,1,1
|
||||
|
7
views/purchase_bill_sync_menus.xml
Normal file
7
views/purchase_bill_sync_menus.xml
Normal file
@ -0,0 +1,7 @@
|
||||
<odoo>
|
||||
<menuitem id="menu_purchase_bill_sync_wizard"
|
||||
name="Sync Vendor Bills"
|
||||
parent="purchase.menu_procurement_management"
|
||||
action="action_purchase_bill_sync_wizard"
|
||||
sequence="99"/>
|
||||
</odoo>
|
||||
1
wizard/__init__.py
Normal file
1
wizard/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import purchase_bill_sync_wizard
|
||||
BIN
wizard/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
wizard/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
wizard/__pycache__/purchase_bill_sync_wizard.cpython-312.pyc
Normal file
BIN
wizard/__pycache__/purchase_bill_sync_wizard.cpython-312.pyc
Normal file
Binary file not shown.
259
wizard/purchase_bill_sync_wizard.py
Normal file
259
wizard/purchase_bill_sync_wizard.py
Normal file
@ -0,0 +1,259 @@
|
||||
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)
|
||||
|
||||
# Check Price
|
||||
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))
|
||||
|
||||
# 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)
|
||||
44
wizard/purchase_bill_sync_wizard_views.xml
Normal file
44
wizard/purchase_bill_sync_wizard_views.xml
Normal file
@ -0,0 +1,44 @@
|
||||
<odoo>
|
||||
<record id="view_purchase_bill_sync_wizard_form" model="ir.ui.view">
|
||||
<field name="name">purchase.bill.sync.wizard.form</field>
|
||||
<field name="model">purchase.bill.sync.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Sync Vendor Bills">
|
||||
<header>
|
||||
<button name="action_check_all" string="Check All" type="object" class="btn-secondary"/>
|
||||
<button name="action_uncheck_all" string="Uncheck All" type="object" class="btn-secondary"/>
|
||||
</header>
|
||||
<group>
|
||||
<group>
|
||||
<field name="date_from"/>
|
||||
<field name="date_to"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Discrepancies">
|
||||
<field name="line_ids">
|
||||
<tree editable="bottom" create="0" delete="0">
|
||||
<field name="selected"/>
|
||||
<field name="move_id"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="discrepancy_details"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
<footer>
|
||||
<button name="action_analyze" string="Find Discrepancies" type="object" class="btn-primary"/>
|
||||
<button name="action_sync" string="Sync Selected" type="object" class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_purchase_bill_sync_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Sync Vendor Bills</field>
|
||||
<field name="res_model">purchase.bill.sync.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Loading…
Reference in New Issue
Block a user