commit 9c2b160e9841da63f4cb50c051eaa2f0931311ec Author: Suherdy Yacob Date: Thu Jan 8 10:59:58 2026 +0700 first commit 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 0000000..21bb7b7 Binary files /dev/null and b/__pycache__/__init__.cpython-312.pyc differ diff --git a/security/ir.model.access.csv b/security/ir.model.access.csv new file mode 100644 index 0000000..b3c3663 --- /dev/null +++ b/security/ir.model.access.csv @@ -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 diff --git a/views/purchase_bill_sync_menus.xml b/views/purchase_bill_sync_menus.xml new file mode 100644 index 0000000..f2974c1 --- /dev/null +++ b/views/purchase_bill_sync_menus.xml @@ -0,0 +1,7 @@ + + + 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 0000000..50614c5 Binary files /dev/null and b/wizard/__pycache__/__init__.cpython-312.pyc differ 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 0000000..58ff1a6 Binary files /dev/null and b/wizard/__pycache__/purchase_bill_sync_wizard.cpython-312.pyc differ 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 + +