diff --git a/purchase_invoice_separate_valuation/README.md b/purchase_invoice_separate_valuation/README.md new file mode 100644 index 0000000..e69de29 diff --git a/purchase_invoice_separate_valuation/__init__.py b/purchase_invoice_separate_valuation/__init__.py new file mode 100644 index 0000000..6ed2c21 --- /dev/null +++ b/purchase_invoice_separate_valuation/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizards \ No newline at end of file diff --git a/purchase_invoice_separate_valuation/__manifest__.py b/purchase_invoice_separate_valuation/__manifest__.py new file mode 100644 index 0000000..13f3510 --- /dev/null +++ b/purchase_invoice_separate_valuation/__manifest__.py @@ -0,0 +1,24 @@ +{ + 'name': 'Purchase Invoice Separate Valuation', + 'version': '16.0.2.0.0', + 'category': 'Purchase', + 'summary': 'Separate purchase invoicing from stock valuation', + 'depends': [ + 'purchase', + 'purchase_stock', + 'stock_account', + 'account', + 'stock_landed_costs', + ], + 'data': [ + 'security/ir.model.access.csv', + 'wizards/purchase_make_invoice_advance_views.xml', + 'views/account_move_views.xml', + 'views/purchase_views.xml', + 'views/res_config_settings_views.xml', + ], + 'installable': True, + 'auto_install': False, + 'application': False, + 'license': 'AGPL-3', +} diff --git a/purchase_invoice_separate_valuation/models/__init__.py b/purchase_invoice_separate_valuation/models/__init__.py new file mode 100644 index 0000000..214393f --- /dev/null +++ b/purchase_invoice_separate_valuation/models/__init__.py @@ -0,0 +1,7 @@ +from . import res_company +from . import res_config_settings +from . import purchase_order_line +from . import purchase_order +from . import account_move +from . import account_move_line +from . import stock_move diff --git a/purchase_invoice_separate_valuation/models/account_move.py b/purchase_invoice_separate_valuation/models/account_move.py new file mode 100644 index 0000000..00185d5 --- /dev/null +++ b/purchase_invoice_separate_valuation/models/account_move.py @@ -0,0 +1,266 @@ +# -*- coding: utf-8 -*- +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + +_VENDOR_BILL_TYPES = frozenset({'in_invoice', 'in_refund', 'in_receipt'}) + + +class AccountMove(models.Model): + _inherit = 'account.move' + + is_final_invoice = fields.Boolean( + string='Final Invoice', + default=False, + copy=False, + ) + price_adjustment_move_id = fields.Many2one( + 'account.move', + string='Price Adjustment Entry', + compute='_compute_price_adjustment_move_id', + readonly=True, + ) + po_has_other_final_invoice = fields.Boolean( + string='PO Has Other Final Invoice', + compute='_compute_po_has_other_final_invoice', + ) + po_use_separate_valuation = fields.Boolean( + string='PO Uses Separate Valuation', + compute='_compute_po_use_separate_valuation', + help='True when a linked purchase order uses separate valuation mode.', + ) + + @api.depends( + 'purchase_id', + 'purchase_id.use_separate_valuation', + 'invoice_line_ids.purchase_line_id.order_id', + 'invoice_line_ids.purchase_line_id.order_id.use_separate_valuation', + ) + def _compute_po_use_separate_valuation(self): + for move in self: + move.po_use_separate_valuation = bool( + move._get_linked_purchase_orders().filtered('use_separate_valuation') + ) + + @api.depends( + 'purchase_id', + 'invoice_line_ids.purchase_line_id.order_id', + 'purchase_id.final_invoice_move_id', + 'purchase_id.final_invoice_move_id.state', + 'invoice_line_ids.purchase_line_id.order_id.final_invoice_move_id', + 'invoice_line_ids.purchase_line_id.order_id.final_invoice_move_id.state', + 'invoice_line_ids.purchase_line_id.order_id.order_line.invoice_lines.move_id.is_final_invoice', + 'invoice_line_ids.purchase_line_id.order_id.order_line.invoice_lines.move_id.state', + ) + def _compute_po_has_other_final_invoice(self): + for move in self: + move.po_has_other_final_invoice = any( + purchase._get_final_invoice() and purchase._get_final_invoice() != move + for purchase in move._get_linked_purchase_orders().filtered('use_separate_valuation') + ) + + @api.depends( + 'purchase_id', + 'invoice_line_ids.purchase_line_id.order_id', + 'purchase_id.price_adjustment_move_id', + 'purchase_id.final_invoice_move_id', + 'invoice_line_ids.purchase_line_id.order_id.price_adjustment_move_id', + 'invoice_line_ids.purchase_line_id.order_id.final_invoice_move_id', + ) + def _compute_price_adjustment_move_id(self): + for move in self: + adjustment_move = self.env['account.move'] + for purchase in move._get_linked_purchase_orders().filtered('use_separate_valuation'): + if purchase._get_final_invoice() == move: + adjustment_move = purchase.price_adjustment_move_id + break + move.price_adjustment_move_id = adjustment_move[:1] + + def _post(self, soft=True): + to_post = self.filtered(lambda m: m.state == 'draft') if soft else self + + for bill in to_post.filtered(lambda m: m.move_type in _VENDOR_BILL_TYPES): + purchases = bill._get_separate_valuation_purchase_orders() + if bill.is_final_invoice: + purchase = bill._get_final_invoice_purchase_order() + purchase._check_can_assign_final_invoice(bill, require_posted=False) + purchase._check_price_adjustment_account_configured() + elif bill.move_type != 'in_refund': + for purchase in purchases: + purchase._check_no_bill_after_final_invoice(bill) + + result = super()._post(soft) + + purchases_to_sync = self.env['purchase.order'] + already_synced = self.env['purchase.order'] + for bill in result.filtered(lambda m: m.move_type in _VENDOR_BILL_TYPES): + purchases_to_sync |= bill._get_separate_valuation_purchase_orders() + + for bill in result.filtered( + lambda m: m.is_final_invoice and m.move_type in _VENDOR_BILL_TYPES + ): + purchase = bill._get_final_invoice_purchase_order(raise_if_missing=False) + if purchase: + purchase._set_final_invoice(bill) + already_synced |= purchase + + for purchase in purchases_to_sync - already_synced: + purchase._sync_price_adjustment_entry() + + return result + + def write(self, vals): + track_final_flag = 'is_final_invoice' in vals + track_purchase_link = 'purchase_id' in vals + + purchases_before = {} + if track_final_flag or track_purchase_link: + for move in self.filtered(lambda m: m.move_type in _VENDOR_BILL_TYPES): + purchases_before[move.id] = move._get_linked_purchase_orders().filtered( + 'use_separate_valuation' + ) + + res = super().write(vals) + if self.env.context.get('skip_purchase_final_invoice_sync'): + return res + + if track_final_flag: + for move in self.filtered( + lambda m: m.state == 'posted' and m.move_type in _VENDOR_BILL_TYPES + ): + if move.is_final_invoice: + purchase = move._get_final_invoice_purchase_order() + purchase._set_final_invoice(move) + continue + + for old_purchase in purchases_before.get(move.id, self.env['purchase.order']): + if old_purchase._get_final_invoice() == move or old_purchase.final_invoice_move_id == move: + old_purchase._clear_final_invoice(invoice=move) + elif track_purchase_link: + purchases_to_sync = self.env['purchase.order'] + for move in self.filtered( + lambda m: m.state == 'posted' and m.move_type in _VENDOR_BILL_TYPES + ): + purchases_to_sync |= purchases_before.get(move.id, self.env['purchase.order']) + purchases_to_sync |= move._get_separate_valuation_purchase_orders() + for purchase in purchases_to_sync.filtered('use_separate_valuation'): + purchase._sync_price_adjustment_entry() + + return res + + def button_draft(self): + vendor_bills = self.filtered(lambda m: m.move_type in _VENDOR_BILL_TYPES) + purchases_before = { + move.id: move._get_separate_valuation_purchase_orders() + for move in vendor_bills + } + final_purchases_before = { + move.id: purchases_before[move.id].filtered(lambda po, move=move: po._get_final_invoice() == move) + for move in vendor_bills + } + + result = super().button_draft() + if self.env.context.get('skip_purchase_final_invoice_sync'): + return result + + to_sync = self.env['purchase.order'] + cleared = self.env['purchase.order'] + for move in vendor_bills: + to_sync |= purchases_before[move.id] + to_sync |= move._get_separate_valuation_purchase_orders() + for purchase in final_purchases_before[move.id]: + purchase._clear_final_invoice(invoice=move) + cleared |= purchase + + for purchase in (to_sync - cleared).filtered('use_separate_valuation'): + purchase._sync_price_adjustment_entry() + return result + + def button_cancel(self): + vendor_bills = self.filtered(lambda m: m.move_type in _VENDOR_BILL_TYPES) + purchases_before = { + move.id: move._get_separate_valuation_purchase_orders() + for move in vendor_bills + } + final_purchases_before = { + move.id: purchases_before[move.id].filtered(lambda po, move=move: po._get_final_invoice() == move) + for move in vendor_bills + } + + result = super().button_cancel() + if self.env.context.get('skip_purchase_final_invoice_sync'): + return result + + to_sync = self.env['purchase.order'] + cleared = self.env['purchase.order'] + for move in vendor_bills: + to_sync |= purchases_before[move.id] + to_sync |= move._get_separate_valuation_purchase_orders() + for purchase in final_purchases_before[move.id]: + purchase._clear_final_invoice(invoice=move) + cleared |= purchase + + for purchase in (to_sync - cleared).filtered('use_separate_valuation'): + purchase._sync_price_adjustment_entry() + return result + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _get_linked_purchase_orders(self): + self.ensure_one() + return self.purchase_id | self.invoice_line_ids.mapped('purchase_line_id.order_id') + + def _get_separate_valuation_purchase_orders(self): + self.ensure_one() + return self._get_linked_purchase_orders().filtered('use_separate_valuation') + + def _get_final_invoice_purchase_order(self, raise_if_missing=True): + self.ensure_one() + purchases = self._get_separate_valuation_purchase_orders() + if not purchases: + if raise_if_missing: + raise UserError(_( + 'The Final Invoice flag can only be used on vendor bills linked to a ' + 'purchase order that uses Separate Valuation Mode.' + )) + return self.env['purchase.order'] + if len(purchases) > 1: + raise UserError(_( + 'Final Invoice is only supported on vendor bills linked to a single ' + 'Separate Valuation purchase order.' + )) + return purchases + + def _sync_price_adjustment_entry(self): + self.ensure_one() + purchase = self._get_final_invoice_purchase_order(raise_if_missing=False) + if purchase: + purchase._sync_price_adjustment_entry() + + # ------------------------------------------------------------------ + # Admin actions (Purchase Manager only) + # ------------------------------------------------------------------ + + def _check_purchase_manager(self): + if not self.env.user.has_group('purchase.group_purchase_manager'): + raise UserError(_('Only Purchase Managers can change the Final Invoice status.')) + + def action_mark_as_final_invoice(self): + self.ensure_one() + self._check_purchase_manager() + purchase = self._get_final_invoice_purchase_order() + purchase._set_final_invoice(self) + + def action_unmark_as_final_invoice(self): + self.ensure_one() + self._check_purchase_manager() + if self.state != 'posted': + raise UserError(_('Only posted invoices can be modified.')) + purchase = self._get_final_invoice_purchase_order(raise_if_missing=False) + if purchase and purchase._get_final_invoice() == self: + purchase._clear_final_invoice(invoice=self) + elif self.is_final_invoice: + self.with_context(skip_purchase_final_invoice_sync=True).write({ + 'is_final_invoice': False, + }) diff --git a/purchase_invoice_separate_valuation/models/account_move_line.py b/purchase_invoice_separate_valuation/models/account_move_line.py new file mode 100644 index 0000000..3e78c86 --- /dev/null +++ b/purchase_invoice_separate_valuation/models/account_move_line.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +from odoo import models + +_VENDOR_BILL_TYPES = frozenset({'in_invoice', 'in_refund', 'in_receipt'}) + + +class AccountMoveLine(models.Model): + _inherit = 'account.move.line' + + def _apply_price_difference(self): + """Block price-difference SVL creation for separate-valuation vendor bills.""" + def _is_separate(line): + if line.move_id.move_type not in _VENDOR_BILL_TYPES: + return False + purchase = line.purchase_line_id.order_id or line.move_id.purchase_id + return bool(purchase and purchase.use_separate_valuation) + + separate = self.filtered(_is_separate) + non_separate = self - separate + if not non_separate: + return self.env['stock.valuation.layer'], self.env['account.move.line'] + return super(AccountMoveLine, non_separate)._apply_price_difference() diff --git a/purchase_invoice_separate_valuation/models/purchase_order.py b/purchase_invoice_separate_valuation/models/purchase_order.py new file mode 100644 index 0000000..196a608 --- /dev/null +++ b/purchase_invoice_separate_valuation/models/purchase_order.py @@ -0,0 +1,350 @@ +# -*- coding: utf-8 -*- +from odoo import api, fields, models, _ +from odoo.exceptions import UserError +from odoo.tools import float_is_zero + +_VENDOR_BILL_TYPES = frozenset({'in_invoice', 'in_refund', 'in_receipt'}) +_FINAL_INVOICE_TYPES = frozenset({'in_invoice', 'in_receipt'}) + + +class PurchaseOrder(models.Model): + _inherit = 'purchase.order' + + use_separate_valuation = fields.Boolean( + string='Separate Valuation Mode', + default=False, + copy=False, + readonly=True, + help='Set automatically when invoices are created via "Create Invoice (Advanced)". ' + 'When active, the standard "Create Invoice" button is blocked and invoicing ' + 'is managed separately from stock valuation.', + ) + price_diff_reason = fields.Text( + string='Price Difference Note', + help='Reason for any difference between the receipt value and the invoiced amount.', + ) + amount_invoiced = fields.Monetary( + string='Invoiced Amount', + compute='_compute_amount_invoiced', + store=True, + currency_field='currency_id', + ) + amount_to_invoice = fields.Monetary( + string='Amount to Invoice', + compute='_compute_amount_invoiced', + store=True, + currency_field='currency_id', + ) + has_final_invoice = fields.Boolean( + string='Has Final Invoice', + compute='_compute_has_final_invoice', + ) + final_invoice_move_id = fields.Many2one( + 'account.move', + string='Final Invoice', + readonly=True, + copy=False, + ondelete='set null', + ) + price_adjustment_move_id = fields.Many2one( + 'account.move', + string='Price Adjustment Entry', + readonly=True, + copy=False, + ondelete='set null', + ) + + @api.depends( + 'final_invoice_move_id', + 'final_invoice_move_id.state', + 'final_invoice_move_id.move_type', + 'order_line.invoice_lines.move_id.is_final_invoice', + 'order_line.invoice_lines.move_id.state', + 'order_line.invoice_lines.move_id.move_type', + ) + def _compute_has_final_invoice(self): + for order in self: + order.has_final_invoice = bool(order._get_final_invoice()) + + @api.depends('order_line.amount_invoiced', 'amount_untaxed', 'currency_id') + def _compute_amount_invoiced(self): + for order in self: + total = sum(order.order_line.mapped('amount_invoiced')) + order.amount_invoiced = total + order.amount_to_invoice = order.amount_untaxed - total + + def action_create_invoice(self): + """Block standard invoice creation for orders using separate valuation mode.""" + blocked = self.filtered('use_separate_valuation') + if blocked: + names = ', '.join(blocked.mapped('name')) + raise UserError(_( + 'The following purchase order(s) use Separate Valuation Mode:\n%s\n\n' + 'Please use the "Create Invoice (Advanced)" button instead.' + ) % names) + return super().action_create_invoice() + + # ------------------------------------------------------------------ + # Separate valuation helpers + # ------------------------------------------------------------------ + + def _get_vendor_bills(self): + self.ensure_one() + return self.order_line.mapped('invoice_lines.move_id').filtered( + lambda m: m.move_type in _VENDOR_BILL_TYPES + ) + + def _get_final_invoice(self): + self.ensure_one() + final_invoice = self.final_invoice_move_id + if final_invoice and final_invoice.state == 'posted' and final_invoice.move_type in _FINAL_INVOICE_TYPES: + return final_invoice + legacy_final = self._get_vendor_bills().filtered( + lambda m: m.is_final_invoice + and m.state == 'posted' + and m.move_type in _FINAL_INVOICE_TYPES + ) + return legacy_final[:1] + + def _check_can_create_advanced_invoice(self, is_final=False): + self.ensure_one() + if self.state not in ('purchase', 'done'): + raise UserError(_( + 'You can only create invoices from confirmed purchase orders.' + )) + + existing_bills = self._get_vendor_bills().filtered(lambda m: m.state != 'cancel') + if not self.use_separate_valuation and existing_bills: + raise UserError(_( + 'Purchase order %s already has vendor bills.\n' + 'Separate Valuation Mode must be started before any vendor bill is created.' + ) % self.name) + + existing_final = self._get_final_invoice() + if existing_final: + raise UserError(_( + 'Purchase order %s already has a final invoice (%s).\n' + 'Clear the Final Invoice status before creating another bill.' + ) % (self.name, existing_final.display_name or existing_final.name)) + + def _check_no_bill_after_final_invoice(self, bill=None): + self.ensure_one() + if bill and bill.move_type == 'in_refund': + return + if not self.use_separate_valuation: + return + existing_final = self._get_final_invoice() + if existing_final and existing_final != bill: + raise UserError(_( + 'Purchase order %s already has a final invoice (%s).\n' + 'No additional vendor bills can be posted for this order.' + ) % (self.name, existing_final.display_name or existing_final.name)) + + def _check_can_assign_final_invoice(self, invoice, require_posted=True): + self.ensure_one() + if require_posted and invoice.state != 'posted': + raise UserError(_('Only posted invoices can be marked as Final Invoice.')) + if invoice.move_type not in _FINAL_INVOICE_TYPES: + raise UserError(_('Only vendor bills can be marked as Final Invoice.')) + if not self.use_separate_valuation: + raise UserError(_( + 'The Final Invoice flag can only be set on vendor bills linked to a purchase ' + 'order that uses Separate Valuation Mode.' + )) + if invoice not in self._get_vendor_bills(): + raise UserError(_( + 'Invoice %s is not linked to purchase order %s.' + ) % (invoice.display_name or invoice.name, self.name)) + existing_final = self._get_final_invoice() + if existing_final and existing_final != invoice: + raise UserError(_( + 'Purchase order %s already has a final invoice (%s).\n' + 'Please remove the existing final invoice status first.' + ) % (self.name, existing_final.display_name or existing_final.name)) + + def _set_final_invoice(self, invoice): + self.ensure_one() + self._check_can_assign_final_invoice(invoice) + + current_final = self._get_final_invoice() + if current_final and current_final != invoice and current_final.is_final_invoice: + current_final.with_context(skip_purchase_final_invoice_sync=True).write({ + 'is_final_invoice': False, + }) + + if self.final_invoice_move_id != invoice: + self.write({'final_invoice_move_id': invoice.id}) + if not invoice.is_final_invoice: + invoice.with_context(skip_purchase_final_invoice_sync=True).write({ + 'is_final_invoice': True, + }) + + self._sync_price_adjustment_entry() + + def _clear_final_invoice(self, invoice=None): + self.ensure_one() + current_final = self._get_final_invoice() + if invoice and current_final and current_final != invoice: + raise UserError(_( + 'Invoice %s is not the current Final Invoice for purchase order %s.' + ) % (invoice.display_name or invoice.name, self.name)) + + invoice_to_clear = current_final or invoice + if self.final_invoice_move_id: + self.write({'final_invoice_move_id': False}) + if invoice_to_clear and invoice_to_clear.is_final_invoice: + invoice_to_clear.with_context(skip_purchase_final_invoice_sync=True).write({ + 'is_final_invoice': False, + }) + + self._sync_price_adjustment_entry() + + def _get_purchase_stock_input_account_ids(self): + self.ensure_one() + account_ids = set() + for line in self.order_line.filtered('product_id'): + account = line.product_id.product_tmpl_id._get_product_accounts().get('stock_input') + if account: + account_ids.add(account.id) + return account_ids + + def _check_price_adjustment_account_configured(self, stock_input_account_ids=None): + self.ensure_one() + account_ids = stock_input_account_ids + if account_ids is None: + account_ids = self._get_purchase_stock_input_account_ids() + if account_ids and not self.company_id.purchase_price_adjustment_account_id: + final_invoice = self._get_final_invoice() + raise UserError(_( + 'Please configure "Purchase Price Adjustment Account" in Accounting settings ' + 'before posting Final Invoice %s.' + ) % (final_invoice.display_name or final_invoice.name or self.name)) + + def _grni_line_vals(self, invoice, account_id, debit=0.0, credit=0.0): + return { + 'name': _('GRNI adjustment (Final Invoice %s)') % (invoice.name or invoice.display_name), + 'account_id': account_id, + 'debit': debit, + 'credit': credit, + 'partner_id': invoice.partner_id.id, + } + + def _remove_price_adjustment_entry(self): + self.ensure_one() + adj = self.price_adjustment_move_id + if not adj: + return + try: + if adj.state == 'posted': + adj.button_draft() + adj.unlink() + except Exception as err: + raise UserError(_( + 'Could not remove the GRNI adjustment entry %s.\n' + 'Please unreconcile or manually reverse it first.\n\nDetail: %s' + ) % (adj.name, str(err))) from err + self.write({'price_adjustment_move_id': False}) + + def _sync_price_adjustment_entry(self): + for order in self: + order._sync_price_adjustment_entry_one() + + def _sync_price_adjustment_entry_one(self): + self.ensure_one() + + final_invoice = self._get_final_invoice() + if final_invoice and self.final_invoice_move_id != final_invoice: + self.write({'final_invoice_move_id': final_invoice.id}) + + if ( + not final_invoice + or final_invoice.state != 'posted' + or final_invoice.move_type not in _FINAL_INVOICE_TYPES + ): + if self.price_adjustment_move_id: + self._remove_price_adjustment_entry() + return + + stock_input_account_ids = self._get_purchase_stock_input_account_ids() + if not stock_input_account_ids: + if self.price_adjustment_move_id: + self._remove_price_adjustment_entry() + return + + self._check_price_adjustment_account_configured( + stock_input_account_ids=stock_input_account_ids, + ) + price_adjustment_account = self.company_id.purchase_price_adjustment_account_id + + grni_journal = self.company_id.purchase_grni_adjustment_journal_id + if not grni_journal: + grni_journal = self.env['account.journal'].search([ + ('type', '=', 'general'), + ('company_id', '=', self.company_id.id), + ], limit=1) + if not grni_journal: + raise UserError(_( + 'Please configure a GRNI Adjustment Journal for company %s before ' + 'finalising purchase order %s.' + ) % (self.company_id.display_name, self.display_name)) + + done_moves = self.order_line.mapped('move_ids').filtered( + lambda m: m.state == 'done' + and m.product_id + and m.product_id.type == 'product' + and m.product_id.categ_id.property_valuation == 'real_time' + ) + receipt_balances = {} + for aml in done_moves.mapped('account_move_ids.line_ids').filtered( + lambda l: l.account_id.id in stock_input_account_ids and l.move_id.state == 'posted' + ): + receipt_balances.setdefault(aml.account_id.id, 0.0) + receipt_balances[aml.account_id.id] += aml.balance + + posted_bills = self._get_vendor_bills().filtered(lambda m: m.state == 'posted') + invoice_balances = {} + if posted_bills: + for aml in self.env['account.move.line'].search([ + ('move_id', 'in', posted_bills.ids), + ('account_id', 'in', list(stock_input_account_ids)), + ]): + invoice_balances.setdefault(aml.account_id.id, 0.0) + invoice_balances[aml.account_id.id] += aml.balance + + adjustment_lines = [] + precision_rounding = self.company_id.currency_id.rounding + for account_id in set(receipt_balances) | set(invoice_balances): + total_balance = ( + receipt_balances.get(account_id, 0.0) + + invoice_balances.get(account_id, 0.0) + ) + if float_is_zero(total_balance, precision_rounding=precision_rounding): + continue + + amount = abs(total_balance) + if total_balance > 0: + adjustment_lines += [ + self._grni_line_vals(final_invoice, price_adjustment_account.id, debit=amount), + self._grni_line_vals(final_invoice, account_id, credit=amount), + ] + else: + adjustment_lines += [ + self._grni_line_vals(final_invoice, account_id, debit=amount), + self._grni_line_vals(final_invoice, price_adjustment_account.id, credit=amount), + ] + + if adjustment_lines: + if self.price_adjustment_move_id: + self._remove_price_adjustment_entry() + + adjustment_move = self.env['account.move'].create({ + 'move_type': 'entry', + 'journal_id': grni_journal.id, + 'date': final_invoice.date, + 'ref': _('GRNI Adjustment - Final Invoice %s') % (final_invoice.name or final_invoice.display_name), + 'line_ids': [(0, 0, vals) for vals in adjustment_lines], + }) + adjustment_move.action_post() + self.write({'price_adjustment_move_id': adjustment_move.id}) + elif self.price_adjustment_move_id: + self._remove_price_adjustment_entry() diff --git a/purchase_invoice_separate_valuation/models/purchase_order_line.py b/purchase_invoice_separate_valuation/models/purchase_order_line.py new file mode 100644 index 0000000..fe596a8 --- /dev/null +++ b/purchase_invoice_separate_valuation/models/purchase_order_line.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +from odoo import api, fields, models + + +class PurchaseOrderLine(models.Model): + _inherit = 'purchase.order.line' + + amount_invoiced = fields.Monetary( + string='Invoiced Amount', + compute='_compute_amount_invoiced', + store=True, + currency_field='currency_id', + ) + amount_to_invoice = fields.Monetary( + string='Amount to Invoice', + compute='_compute_amount_invoiced', + store=True, + currency_field='currency_id', + ) + + @api.depends( + 'invoice_lines.move_id.state', + 'invoice_lines.price_subtotal', + 'invoice_lines.move_id.currency_id', + 'invoice_lines.move_id.invoice_date', + 'invoice_lines.move_id.move_type', + 'price_subtotal', + 'order_id.currency_id', + ) + def _compute_amount_invoiced(self): + """Compute invoiced amount from ALL posted vendor bills (both partial and final). + + This tracks the actual money invoiced, independent of the qty-based tracking. + """ + for line in self: + total = 0.0 + order = line.order_id + if not order: + line.amount_invoiced = 0.0 + line.amount_to_invoice = 0.0 + continue + for inv_line in line._get_invoice_lines(): + move = inv_line.move_id + if move.state != 'posted': + continue + if move.move_type not in ('in_invoice', 'in_refund', 'in_receipt'): + continue + if move.move_type in ('in_invoice', 'in_receipt'): + amount = inv_line.price_subtotal + else: + amount = -inv_line.price_subtotal + if move.currency_id != order.currency_id: + amount = move.currency_id._convert( + amount, + order.currency_id, + line.company_id, + move.invoice_date or move.date or fields.Date.context_today(line), + ) + total += amount + line.amount_invoiced = total + line.amount_to_invoice = line.price_subtotal - total + + @api.depends( + 'invoice_lines.move_id.state', + 'invoice_lines.move_id.is_final_invoice', + 'invoice_lines.quantity', + 'qty_received', + 'product_uom_qty', + 'order_id.state', + 'order_id.use_separate_valuation', + ) + def _compute_qty_invoiced(self): + """Override qty_invoiced computation. + + For regular orders (use_separate_valuation=False): standard Odoo behaviour. + For separate-valuation orders: + - Before final invoice: qty_invoiced=0, qty_to_invoice=full pending qty. + - After a POSTED final invoice: qty_invoiced=full qty, qty_to_invoice=0, + which closes the purchase order's billing status. + """ + separate_lines = self.filtered(lambda l: l.order_id.use_separate_valuation) + regular_lines = self - separate_lines + if regular_lines: + super(PurchaseOrderLine, regular_lines)._compute_qty_invoiced() + for line in separate_lines: + if line.order_id.state not in ('purchase', 'done'): + line.qty_invoiced = 0.0 + line.qty_to_invoice = 0.0 + continue + has_final_invoice = any( + inv_line.move_id.state == 'posted' + and inv_line.move_id.is_final_invoice + for inv_line in line._get_invoice_lines() + if inv_line.move_id.move_type in ('in_invoice', 'in_refund', 'in_receipt') + ) + basis_qty = ( + line.product_qty + if line.product_id.purchase_method == 'purchase' + else line.qty_received + ) + if has_final_invoice: + line.qty_invoiced = basis_qty + line.qty_to_invoice = 0.0 + else: + line.qty_invoiced = 0.0 + line.qty_to_invoice = basis_qty diff --git a/purchase_invoice_separate_valuation/models/res_company.py b/purchase_invoice_separate_valuation/models/res_company.py new file mode 100644 index 0000000..4ea7089 --- /dev/null +++ b/purchase_invoice_separate_valuation/models/res_company.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = 'res.company' + + purchase_price_adjustment_account_id = fields.Many2one( + 'account.account', + string='Purchase Price Adjustment Account', + help='Account used for price difference adjustments when posting final vendor bills', + domain="[('deprecated', '=', False), ('company_id', '=', id)]", + ) + purchase_grni_adjustment_journal_id = fields.Many2one( + 'account.journal', + string='GRNI Adjustment Journal', + help='Journal used for GRNI balancing entries created when a final vendor bill is posted. ' + 'Should be a general/miscellaneous journal, not the accounts payable journal. ' + 'Falls back to the first available general journal if not set.', + domain="[('type', '=', 'general'), ('company_id', '=', id)]", + ) diff --git a/purchase_invoice_separate_valuation/models/res_config_settings.py b/purchase_invoice_separate_valuation/models/res_config_settings.py new file mode 100644 index 0000000..0e192cc --- /dev/null +++ b/purchase_invoice_separate_valuation/models/res_config_settings.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + purchase_price_adjustment_account_id = fields.Many2one( + 'account.account', + related='company_id.purchase_price_adjustment_account_id', + string='Purchase Price Adjustment Account', + readonly=False, + help='Default expense account used for price difference adjustments when posting final vendor bills. ' + 'This account will be used instead of the product expense account.', + domain="[('deprecated', '=', False), ('company_id', '=', company_id)]", + ) + purchase_grni_adjustment_journal_id = fields.Many2one( + 'account.journal', + related='company_id.purchase_grni_adjustment_journal_id', + string='GRNI Adjustment Journal', + readonly=False, + help='Journal used for GRNI balancing entries created when a final vendor bill is posted. ' + 'Should be a general/miscellaneous journal. Falls back to the first general journal if not set.', + domain="[('type', '=', 'general'), ('company_id', '=', company_id)]", + ) \ No newline at end of file diff --git a/purchase_invoice_separate_valuation/models/stock_move.py b/purchase_invoice_separate_valuation/models/stock_move.py new file mode 100644 index 0000000..c1cb41f --- /dev/null +++ b/purchase_invoice_separate_valuation/models/stock_move.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +from odoo import models + + +class StockMove(models.Model): + _inherit = 'stock.move' + + def _get_separate_valuation_purchase_orders(self): + return ( + self.mapped('purchase_line_id.order_id') + | self.mapped('origin_returned_move_id.purchase_line_id.order_id') + ).filtered(lambda po: po.use_separate_valuation and po._get_final_invoice()) + + def _action_done(self, cancel_backorder=False): + result = super()._action_done(cancel_backorder=cancel_backorder) + self._get_separate_valuation_purchase_orders()._sync_price_adjustment_entry() + return result + + def _action_cancel(self): + purchases = self._get_separate_valuation_purchase_orders() + result = super()._action_cancel() + purchases._sync_price_adjustment_entry() + return result diff --git a/purchase_invoice_separate_valuation/security/ir.model.access.csv b/purchase_invoice_separate_valuation/security/ir.model.access.csv new file mode 100644 index 0000000..c7b943c --- /dev/null +++ b/purchase_invoice_separate_valuation/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_make_invoice_advance_user,purchase.make_invoice_advance.user,model_purchase_make_invoice_advance,purchase.group_purchase_user,1,1,1,1 +access_purchase_make_invoice_advance_manager,purchase.make_invoice_advance.manager,model_purchase_make_invoice_advance,purchase.group_purchase_manager,1,1,1,1 \ No newline at end of file diff --git a/purchase_invoice_separate_valuation/views/account_move_views.xml b/purchase_invoice_separate_valuation/views/account_move_views.xml new file mode 100644 index 0000000..0b76f02 --- /dev/null +++ b/purchase_invoice_separate_valuation/views/account_move_views.xml @@ -0,0 +1,69 @@ + + + + + account.move.form.inherit.final.invoice + account.move + + + + + +