first commit

This commit is contained in:
Suherdy Yacob 2026-01-08 10:59:58 +07:00
commit 9c2b160e98
10 changed files with 336 additions and 0 deletions

1
__init__.py Normal file
View File

@ -0,0 +1 @@
from . import wizard

21
__manifest__.py Normal file
View 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',
}

Binary file not shown.

View 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_purchase_bill_sync_wizard purchase.bill.sync.wizard model_purchase_bill_sync_wizard purchase.group_purchase_user 1 1 1 1
3 access_purchase_bill_sync_line purchase.bill.sync.line model_purchase_bill_sync_line purchase.group_purchase_user 1 1 1 1

View 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
View File

@ -0,0 +1 @@
from . import purchase_bill_sync_wizard

Binary file not shown.

View 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)

View 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>