From 9c2b160e9841da63f4cb50c051eaa2f0931311ec Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Thu, 8 Jan 2026 10:59:58 +0700 Subject: [PATCH] first commit --- __init__.py | 1 + __manifest__.py | 21 ++ __pycache__/__init__.cpython-312.pyc | Bin 0 -> 206 bytes security/ir.model.access.csv | 3 + views/purchase_bill_sync_menus.xml | 7 + wizard/__init__.py | 1 + wizard/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 232 bytes .../purchase_bill_sync_wizard.cpython-312.pyc | Bin 0 -> 11347 bytes wizard/purchase_bill_sync_wizard.py | 259 ++++++++++++++++++ wizard/purchase_bill_sync_wizard_views.xml | 44 +++ 10 files changed, 336 insertions(+) create mode 100644 __init__.py create mode 100644 __manifest__.py create mode 100644 __pycache__/__init__.cpython-312.pyc create mode 100644 security/ir.model.access.csv create mode 100644 views/purchase_bill_sync_menus.xml create mode 100644 wizard/__init__.py create mode 100644 wizard/__pycache__/__init__.cpython-312.pyc create mode 100644 wizard/__pycache__/purchase_bill_sync_wizard.cpython-312.pyc create mode 100644 wizard/purchase_bill_sync_wizard.py create mode 100644 wizard/purchase_bill_sync_wizard_views.xml diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..4027237 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +from . import wizard diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..6489dce --- /dev/null +++ b/__manifest__.py @@ -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', +} diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..21bb7b7a05255e614d7da0c1c81e4de4079c8542 GIT binary patch literal 206 zcmX@j%ge<81SfgoGev>)V-N=hn4pZ$0zk%eh7^Vr#vF!R#wbQchDs()=9i2>VNJ$c zY~`6%iA5=XnoPGCikN|7D;Yk6bpG + + diff --git a/wizard/__init__.py b/wizard/__init__.py new file mode 100644 index 0000000..07c1e73 --- /dev/null +++ b/wizard/__init__.py @@ -0,0 +1 @@ +from . import purchase_bill_sync_wizard diff --git a/wizard/__pycache__/__init__.cpython-312.pyc b/wizard/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..50614c58d99169da0db94b3d54a5fb8fe8739605 GIT binary patch literal 232 zcmX@j%ge<81h;wOGc|zpV-N=hn4pZ$0zk%eh7^Vr#vF!R#wbQchDs()=9i2>VNJ$c zk_Dwj$r*{osqsmfIXUsgm3hhW<(XB9MJaxoOt%<{n1RYxGJFOZ_A5v~BR@A)zqm9b zwJ4=hKcKQCBR?-WKP6Q^KP5lk&|E*cw74WcH!&q8Kd)FH(_npw!TRy>nR%Hd@$q^E lmA^P_a`RJ4b5iY!IDl4w990Zrd|+l|WW2|qUc?6E004o4KsNvY literal 0 HcmV?d00001 diff --git a/wizard/__pycache__/purchase_bill_sync_wizard.cpython-312.pyc b/wizard/__pycache__/purchase_bill_sync_wizard.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..58ff1a63d3ec9dde7f55a1a989bf53b37ee32ad0 GIT binary patch literal 11347 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 literal 0 HcmV?d00001 diff --git a/wizard/purchase_bill_sync_wizard.py b/wizard/purchase_bill_sync_wizard.py new file mode 100644 index 0000000..f500857 --- /dev/null +++ b/wizard/purchase_bill_sync_wizard.py @@ -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) diff --git a/wizard/purchase_bill_sync_wizard_views.xml b/wizard/purchase_bill_sync_wizard_views.xml new file mode 100644 index 0000000..981a5ce --- /dev/null +++ b/wizard/purchase_bill_sync_wizard_views.xml @@ -0,0 +1,44 @@ + + + purchase.bill.sync.wizard.form + purchase.bill.sync.wizard + +
+
+
+ + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + + Sync Vendor Bills + purchase.bill.sync.wizard + form + new + +