diff --git a/purchase_deposit_separate_valuation/__init__.py b/purchase_deposit_separate_valuation/__init__.py new file mode 100644 index 00000000..31399946 --- /dev/null +++ b/purchase_deposit_separate_valuation/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2026 Quartile Limited +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import models diff --git a/purchase_deposit_separate_valuation/__manifest__.py b/purchase_deposit_separate_valuation/__manifest__.py new file mode 100644 index 00000000..0e62a4e4 --- /dev/null +++ b/purchase_deposit_separate_valuation/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2026 Quartile Limited +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +{ + "name": "Purchase Deposit Separate Valuation", + "version": "16.0.1.0.0", + "author": "Quartile Limited", + "website": "https://www.quartile.co", + "category": "Purchase", + "license": "AGPL-3", + "summary": "Per-PO stock valuation freeze on top of purchase_deposit " + "so vendor bills do not create price-difference SVL entries; any " + "Stock-Input residual is closed out to a configurable adjustment " + "account.", + "depends": ["purchase_deposit", "purchase_stock"], + "data": [ + "views/res_config_settings_views.xml", + "views/purchase_order_views.xml", + ], + "installable": True, +} diff --git a/purchase_deposit_separate_valuation/models/__init__.py b/purchase_deposit_separate_valuation/models/__init__.py new file mode 100644 index 00000000..1f6f6ce2 --- /dev/null +++ b/purchase_deposit_separate_valuation/models/__init__.py @@ -0,0 +1,7 @@ +# Copyright 2026 Quartile Limited +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import res_company +from . import res_config_settings +from . import purchase_order +from . import account_move +from . import account_move_line diff --git a/purchase_deposit_separate_valuation/models/account_move.py b/purchase_deposit_separate_valuation/models/account_move.py new file mode 100644 index 00000000..34ed85fe --- /dev/null +++ b/purchase_deposit_separate_valuation/models/account_move.py @@ -0,0 +1,38 @@ +# Copyright 2026 Quartile Limited +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import models + +_VENDOR_BILL_TYPES = frozenset({"in_invoice", "in_refund", "in_receipt"}) + + +class AccountMove(models.Model): + _inherit = "account.move" + + def _post(self, soft=True): + posted = super()._post(soft=soft) + self._trigger_sep_val_grni_sync(posted) + return posted + + def button_draft(self): + result = super().button_draft() + self._trigger_sep_val_grni_sync(self) + return result + + def button_cancel(self): + result = super().button_cancel() + self._trigger_sep_val_grni_sync(self) + return result + + def _trigger_sep_val_grni_sync(self, moves): + purchases = self.env["purchase.order"] + for move in moves.filtered(lambda m: m.move_type in _VENDOR_BILL_TYPES): + purchases |= move._get_sep_val_purchase_orders() + if purchases: + purchases._sync_sep_val_grni_adjustment() + + def _get_sep_val_purchase_orders(self): + self.ensure_one() + candidates = self.purchase_id | self.invoice_line_ids.mapped( + "purchase_line_id.order_id" + ) + return candidates.filtered("use_separate_valuation") diff --git a/purchase_deposit_separate_valuation/models/account_move_line.py b/purchase_deposit_separate_valuation/models/account_move_line.py new file mode 100644 index 00000000..fddc4d39 --- /dev/null +++ b/purchase_deposit_separate_valuation/models/account_move_line.py @@ -0,0 +1,28 @@ +# Copyright 2026 Quartile Limited +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import models + + +class AccountMoveLine(models.Model): + _inherit = "account.move.line" + + def _apply_price_difference(self): + """Suppress price-difference SVL creation for vendor bills linked to + a separate-valuation purchase order, so stock value stays at the + receipt cost regardless of the billed amount. + """ + + def _is_separate(line): + 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) + regular = self - separate + if not regular: + return ( + self.env["stock.valuation.layer"].sudo(), + self.env["account.move.line"].sudo(), + ) + return super(AccountMoveLine, regular)._apply_price_difference() diff --git a/purchase_deposit_separate_valuation/models/purchase_order.py b/purchase_deposit_separate_valuation/models/purchase_order.py new file mode 100644 index 00000000..2e2caae8 --- /dev/null +++ b/purchase_deposit_separate_valuation/models/purchase_order.py @@ -0,0 +1,292 @@ +# Copyright 2026 Quartile Limited +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import Command, _, fields, models +from odoo.exceptions import UserError +from odoo.tools.float_utils import float_compare, float_is_zero + + +class PurchaseOrder(models.Model): + _inherit = "purchase.order" + + use_separate_valuation = fields.Boolean( + string="Separate Valuation Mode", + copy=False, + help="When enabled, vendor bills linked to this purchase order do " + "not trigger price-difference stock valuation layers. Any residual " + "on the Stock-Input (GRNI) account caused by the difference between " + "the receipt cost and the billed amount is closed out to the " + "configured Separate-Valuation GRNI Adjustment Account.", + ) + sep_val_grni_adjustment_move_id = fields.Many2one( + "account.move", + string="Separate-Valuation GRNI Adjustment", + copy=False, + readonly=True, + help="Latest GRNI adjustment journal entry posted to close the " + "Stock-Input residual for this purchase order.", + ) + + def action_view_sep_val_grni_adjustment(self): + self.ensure_one() + move = self.sep_val_grni_adjustment_move_id + if not move: + return {"type": "ir.actions.act_window_close"} + return { + "type": "ir.actions.act_window", + "name": _("GRNI Adjustment"), + "res_model": "account.move", + "view_mode": "form", + "res_id": move.id, + "context": {"create": False}, + } + + def _sync_sep_val_grni_adjustment(self): + """Re-compute and (re)post the GRNI adjustment entry for this PO. + + Called from ``account.move._post`` (and after draft/cancel) whenever + a vendor bill linked to a Separate-Valuation PO changes state. Only + fires once every stockable product line is fully invoiced — until + then a residual is expected and posting an adjustment would be + premature. + """ + for order in self: + order._sync_sep_val_grni_adjustment_one() + + def _sync_sep_val_grni_adjustment_one(self): + self.ensure_one() + if not self.use_separate_valuation: + return + + if not self._is_sep_val_fully_invoiced(): + if self.sep_val_grni_adjustment_move_id: + self._remove_sep_val_grni_adjustment() + return + + stock_input_account_ids = self._get_sep_val_stock_input_account_ids() + balances = self._compute_sep_val_grni_balances(stock_input_account_ids) + rounding = self.company_id.currency_id.rounding + meaningful = { + account_id: balance + for account_id, balance in balances.items() + if not float_is_zero(balance, precision_rounding=rounding) + } + + if self.sep_val_grni_adjustment_move_id: + self._remove_sep_val_grni_adjustment() + + if not meaningful: + return + + adjustment_account = self.company_id.sep_val_grni_adjustment_account_id + if not adjustment_account: + raise UserError( + _( + "Please configure the Separate-Valuation GRNI Adjustment " + "Account on company '%s' before posting a vendor bill " + "for purchase order '%s'." + ) + % (self.company_id.display_name, self.display_name) + ) + + journal = self._get_sep_val_grni_adjustment_journal() + line_vals = [] + for account_id, balance in meaningful.items(): + amount = abs(balance) + if balance > 0: + # Bill side exceeded receipt side: stock_input has a debit + # residual. Credit it, debit the adjustment account (loss). + line_vals.append( + Command.create( + { + "name": _("Sep-Val GRNI Adjustment"), + "account_id": account_id, + "credit": amount, + "debit": 0.0, + } + ) + ) + line_vals.append( + Command.create( + { + "name": _("Sep-Val GRNI Adjustment"), + "account_id": adjustment_account.id, + "debit": amount, + "credit": 0.0, + } + ) + ) + else: + # Receipt side exceeded bill side: stock_input has a credit + # residual. Debit it, credit the adjustment account (gain). + line_vals.append( + Command.create( + { + "name": _("Sep-Val GRNI Adjustment"), + "account_id": account_id, + "debit": amount, + "credit": 0.0, + } + ) + ) + line_vals.append( + Command.create( + { + "name": _("Sep-Val GRNI Adjustment"), + "account_id": adjustment_account.id, + "credit": amount, + "debit": 0.0, + } + ) + ) + + adjustment = self.env["account.move"].create( + { + "move_type": "entry", + "journal_id": journal.id, + "date": fields.Date.context_today(self), + "ref": _("Sep-Val GRNI Adjustment - %s") % self.name, + "line_ids": line_vals, + } + ) + adjustment.action_post() + self.sep_val_grni_adjustment_move_id = adjustment + + def _remove_sep_val_grni_adjustment(self): + self.ensure_one() + adj = self.sep_val_grni_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 previous GRNI adjustment entry " + "'%s'. Please unreconcile or manually reverse it first.\n" + "Details: %s" + ) + % (adj.display_name, err) + ) + self.sep_val_grni_adjustment_move_id = False + + def _is_sep_val_fully_invoiced(self): + """Return True iff every stockable PO line has been fully covered + by **posted** vendor bills. + + Standard Odoo's ``qty_invoiced`` also counts lines from bills in + draft state, but for the GRNI adjustment we want to mirror what is + actually in the ledger — otherwise resetting a bill to draft would + leave the PO looking fully invoiced from a qty perspective while + the bill's stock-input balance is no longer recorded, producing + a spurious adjustment for the receipt-only residual. + """ + self.ensure_one() + precision = self.env["decimal.precision"].precision_get( + "Product Unit of Measure" + ) + product_lines = self.order_line.filtered( + lambda l: not l.display_type and l.product_id.type == "product" + ) + if not product_lines: + return True + for line in product_lines: + posted_qty = 0.0 + for inv_line in line.invoice_lines: + move = inv_line.move_id + if move.state != "posted": + continue + qty = inv_line.product_uom_id._compute_quantity( + inv_line.quantity, line.product_uom + ) + if move.move_type == "in_invoice": + posted_qty += qty + elif move.move_type == "in_refund": + posted_qty -= qty + if ( + float_compare( + posted_qty, + line.qty_received, + precision_digits=precision, + ) + < 0 + ): + return False + return True + + def _get_sep_val_stock_input_account_ids(self): + self.ensure_one() + account_ids = set() + for line in self.order_line.filtered( + lambda l: l.product_id + and l.product_id.type == "product" + and l.product_id.categ_id.property_valuation == "real_time" + ): + accounts = line.product_id.product_tmpl_id.get_product_accounts( + fiscal_pos=self.fiscal_position_id + ) + stock_input = accounts.get("stock_input") + if stock_input: + account_ids.add(stock_input.id) + return account_ids + + def _compute_sep_val_grni_balances(self, stock_input_account_ids): + """Sum balances on Stock-Input accounts from receipt moves AND posted + vendor bills linked to this PO. The adjustment entry itself is + excluded by checking the move_type. + """ + self.ensure_one() + balances = {} + if not stock_input_account_ids: + return balances + + 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_amls = 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" + ) + for aml in receipt_amls: + balances.setdefault(aml.account_id.id, 0.0) + balances[aml.account_id.id] += aml.balance + + bill_ids = self.invoice_ids.filtered(lambda m: m.state == "posted").ids + if bill_ids: + bill_amls = self.env["account.move.line"].search( + [ + ("move_id", "in", bill_ids), + ("account_id", "in", list(stock_input_account_ids)), + ] + ) + for aml in bill_amls: + balances.setdefault(aml.account_id.id, 0.0) + balances[aml.account_id.id] += aml.balance + + return balances + + def _get_sep_val_grni_adjustment_journal(self): + self.ensure_one() + journal = self.company_id.sep_val_grni_adjustment_journal_id + if journal: + return journal + journal = self.env["account.journal"].search( + [ + ("type", "=", "general"), + ("company_id", "=", self.company_id.id), + ], + limit=1, + ) + if not journal: + raise UserError( + _( + "No General journal was found for company '%s'. Please " + "configure the Separate-Valuation GRNI Adjustment Journal." + ) + % self.company_id.display_name + ) + return journal diff --git a/purchase_deposit_separate_valuation/models/res_company.py b/purchase_deposit_separate_valuation/models/res_company.py new file mode 100644 index 00000000..a6c44469 --- /dev/null +++ b/purchase_deposit_separate_valuation/models/res_company.py @@ -0,0 +1,23 @@ +# Copyright 2026 Quartile Limited +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + sep_val_grni_adjustment_account_id = fields.Many2one( + "account.account", + string="Separate-Valuation GRNI Adjustment Account", + help="Account that absorbs the residual on the Stock-Input (GRNI) " + "account when a vendor bill amount differs from the receipt cost " + "on a Separate-Valuation purchase order. Typically an expense / " + "loss-gain account.", + ) + sep_val_grni_adjustment_journal_id = fields.Many2one( + "account.journal", + string="Separate-Valuation GRNI Adjustment Journal", + domain="[('type', '=', 'general')]", + help="Journal used to post the GRNI adjustment entry. Defaults to " + "the first General journal of the company if not configured.", + ) diff --git a/purchase_deposit_separate_valuation/models/res_config_settings.py b/purchase_deposit_separate_valuation/models/res_config_settings.py new file mode 100644 index 00000000..dcdac038 --- /dev/null +++ b/purchase_deposit_separate_valuation/models/res_config_settings.py @@ -0,0 +1,16 @@ +# Copyright 2026 Quartile Limited +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + sep_val_grni_adjustment_account_id = fields.Many2one( + related="company_id.sep_val_grni_adjustment_account_id", + readonly=False, + ) + sep_val_grni_adjustment_journal_id = fields.Many2one( + related="company_id.sep_val_grni_adjustment_journal_id", + readonly=False, + ) diff --git a/purchase_deposit_separate_valuation/readme/DESCRIPTION.md b/purchase_deposit_separate_valuation/readme/DESCRIPTION.md new file mode 100644 index 00000000..6b5c9712 --- /dev/null +++ b/purchase_deposit_separate_valuation/readme/DESCRIPTION.md @@ -0,0 +1,78 @@ +# Purchase Deposit Separate Valuation + +Per-PO stock-valuation freeze on top of OCA's `purchase_deposit`, with +automatic Stock-Input (GRNI) residual handling. + +## What it does + +When `Separate Valuation Mode` is enabled on a purchase order: + +1. **Stock value stays at the receipt cost.** Vendor bills linked to the + PO no longer create the price-difference SVL that standard Odoo + posts when the billed amount differs from the receipt cost. +2. **The Stock-Input residual is closed automatically.** Because the + SVL is suppressed, the Stock-Input (GRNI) account would otherwise + carry the price difference as an open balance. After every vendor + bill posts, the module recomputes the residual for the PO and posts + (or replaces) a journal entry that closes the residual to the + configured **Separate-Valuation GRNI Adjustment Account**. + +The adjustment is computed and posted only once the PO is **fully +invoiced** (every stockable PO line's `qty_invoiced ≥ qty_received`), +so partial / deposit bills do not produce premature adjustments. + +## Why + +`purchase_deposit` gives a clean deposit + offset flow, but the final +invoice still goes through standard Anglo-Saxon accounting which moves +stock value via price-difference SVL whenever the bill amount differs +from the receipt cost. For workflows where stock valuation must stay +exactly at the receipt moment (so reporting figures are not disturbed +by billed amounts), this module turns that adjustment into a separate +journal entry against a dedicated account. + +## Example + +Receipt 1000, final vendor bill 1200 (Separate Valuation Mode enabled): + +``` +Receipt: Stock 1000 / GRNI 1000 +Vendor bill: GRNI 1200 / AP 1200 (no SVL created) +GRNI adjustment: GRNI -200 / Adjustment +200 (auto-posted) + +Net stock value: 1000 (unchanged) +Net GRNI: 0 +Adjustment a/c: +200 (loss recognised) +``` + +If a later bill changes the picture (refund, additional invoice, etc.), +the old adjustment entry is replaced with a fresh one reflecting the +new aggregate balance. + +## Configuration + +Settings → Accounting → *Purchase Deposit — Separate Valuation*: + +- **Separate-Valuation GRNI Adjustment Account** — required. Expense + account that absorbs the price difference. +- **Separate-Valuation GRNI Adjustment Journal** — optional. Defaults + to the first General journal of the company. + +Per purchase order, tick **Separate Valuation Mode** under *Invoicing*. + +## Related + +- For recording a deposit in a currency different from the PO + currency, install the companion module **Purchase Deposit + Multi-Currency** (`purchase_deposit_currency`). + +## Notes / Limitations + +- The adjustment is recomputed (delete + create) every time a vendor + bill on the PO changes state. If the previous adjustment has been + reconciled manually, removal raises an error and the user must + unreconcile / reverse it first. +- The adjustment only fires after the PO is fully invoiced. Until + then the Stock-Input residual is expected (and visible in reports); + the freeze prevents stock movement but does not pre-emptively close + GRNI on partial bills. diff --git a/purchase_deposit_separate_valuation/views/purchase_order_views.xml b/purchase_deposit_separate_valuation/views/purchase_order_views.xml new file mode 100644 index 00000000..bfd14abf --- /dev/null +++ b/purchase_deposit_separate_valuation/views/purchase_order_views.xml @@ -0,0 +1,28 @@ + + + + purchase.order.form.separate.valuation + purchase.order + + + + + + + GRNI + Adjustment + + + + + + + + + diff --git a/purchase_deposit_separate_valuation/views/res_config_settings_views.xml b/purchase_deposit_separate_valuation/views/res_config_settings_views.xml new file mode 100644 index 00000000..7950150d --- /dev/null +++ b/purchase_deposit_separate_valuation/views/res_config_settings_views.xml @@ -0,0 +1,46 @@ + + + + res.config.settings.form.sep.val + res.config.settings + + + + Purchase Deposit — Separate Valuation + + + + + + + Account that absorbs the Stock-Input residual on + Separate-Valuation purchase orders when the vendor + bill amount differs from the receipt cost. + + + + + + + + + + + + + + Journal used to post the GRNI adjustment entry. + Falls back to the first General journal if blank. + + + + + + + + + + + + + diff --git a/purchase_invoice_separate_valuation/README.md b/purchase_invoice_separate_valuation/README.md new file mode 100644 index 00000000..e69de29b diff --git a/purchase_invoice_separate_valuation/__init__.py b/purchase_invoice_separate_valuation/__init__.py new file mode 100644 index 00000000..6ed2c210 --- /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 00000000..13f35106 --- /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 00000000..214393f3 --- /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 00000000..00185d5e --- /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 00000000..3e78c86c --- /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 00000000..196a608c --- /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 00000000..fe596a80 --- /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 00000000..4ea70893 --- /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 00000000..0e192cc8 --- /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 00000000..c1cb41fe --- /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 00000000..c7b943c8 --- /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 00000000..0b76f02c --- /dev/null +++ b/purchase_invoice_separate_valuation/views/account_move_views.xml @@ -0,0 +1,69 @@ + + + + + account.move.form.inherit.final.invoice + account.move + + + + + + + + + + + + + + + + + + + + + + + + + + + account.invoice.supplier.tree.inherit.final.invoice + account.move + + + + + + + + diff --git a/purchase_invoice_separate_valuation/views/purchase_views.xml b/purchase_invoice_separate_valuation/views/purchase_views.xml new file mode 100644 index 00000000..dce51f62 --- /dev/null +++ b/purchase_invoice_separate_valuation/views/purchase_views.xml @@ -0,0 +1,76 @@ + + + + + Create Invoice (Advanced) + purchase.make_invoice_advance + form + new + + + + + purchase.order.form.inherit.invoice.wizard + purchase.order + + + + + + {'invisible': ['|', '|', ('state', 'not in', ('purchase', 'done')), ('invoice_status', 'in', ('no', 'invoiced')), ('use_separate_valuation', '=', True)]} + + + {'invisible': ['|', '|', '|', ('state', 'not in', ('purchase', 'done')), ('invoice_status', 'not in', ('no', 'invoiced')), ('order_line', '=', []), ('use_separate_valuation', '=', True)]} + + + + + + + + + + + Separate Valuation Mode: + Use Create Invoice (Advanced) to add invoices. + The standard "Create Invoice" button is blocked for this order. + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/purchase_invoice_separate_valuation/views/res_config_settings_views.xml b/purchase_invoice_separate_valuation/views/res_config_settings_views.xml new file mode 100644 index 00000000..069e64ff --- /dev/null +++ b/purchase_invoice_separate_valuation/views/res_config_settings_views.xml @@ -0,0 +1,45 @@ + + + + + res.config.settings.view.form.inherit.purchase.price.adjustment + res.config.settings + + + + + + + + + Account used for price difference adjustments when posting final vendor bills + + + + + + + + + + + + + + Journal for GRNI balancing entries (general/miscellaneous journal) + + + + + + + + + + + + \ No newline at end of file diff --git a/purchase_invoice_separate_valuation/wizards/__init__.py b/purchase_invoice_separate_valuation/wizards/__init__.py new file mode 100644 index 00000000..53ab4662 --- /dev/null +++ b/purchase_invoice_separate_valuation/wizards/__init__.py @@ -0,0 +1 @@ +from . import purchase_make_invoice_advance \ No newline at end of file diff --git a/purchase_invoice_separate_valuation/wizards/purchase_make_invoice_advance.py b/purchase_invoice_separate_valuation/wizards/purchase_make_invoice_advance.py new file mode 100644 index 00000000..00377f37 --- /dev/null +++ b/purchase_invoice_separate_valuation/wizards/purchase_make_invoice_advance.py @@ -0,0 +1,149 @@ +# -*- coding: utf-8 -*- +from odoo import api, fields, models, _ +from odoo.exceptions import UserError +from odoo.tools.float_utils import float_is_zero + + +class PurchaseMakeInvoiceAdvance(models.TransientModel): + _name = 'purchase.make_invoice_advance' + _description = 'Create Invoice from Purchase Order (Separate Valuation)' + + is_final_invoice = fields.Boolean( + string='Final Invoice', + default=False, + help='Mark as the final invoice for this purchase order. ' + 'This closes the billing cycle and triggers GRNI balancing.', + ) + purchase_order_id = fields.Many2one( + 'purchase.order', + string='Purchase Order', + required=True, + ) + + @api.model + def default_get(self, field_names): + res = super().default_get(field_names) + active_ids = self.env.context.get('active_ids', []) + if len(active_ids) > 1: + raise UserError(_( + 'Please open the purchase order form and use the ' + '"Create Invoice (Advanced)" button there. ' + 'Only one purchase order can be invoiced at a time.' + )) + if active_ids: + res['purchase_order_id'] = active_ids[0] + return res + + def create_invoice(self): + """Create a vendor bill for the selected purchase order. + + Quantity calculation always uses amount_to_invoice (money-based) so + that fractional-quantity rounding never causes a discrepancy between + what was already billed and what is proposed next time. + + Lines whose remaining amount is zero or negative are skipped entirely + rather than creating zero-amount placeholder lines. + """ + purchase_order = self.purchase_order_id + if not purchase_order: + raise UserError(_('Please select a purchase order.')) + + purchase_order._check_can_create_advanced_invoice(is_final=self.is_final_invoice) + + invoice_vals = purchase_order._prepare_invoice() + invoice_vals['is_final_invoice'] = self.is_final_invoice + + lines_vals = [] + # Temporary record for _prepare_account_move_line + tmp_move = self.env['account.move'].with_context( + final_invoice=self.is_final_invoice + ).new(invoice_vals) + + for line in purchase_order.order_line.filtered(lambda l: not l.display_type): + line_vals = self._prepare_line_vals(line, tmp_move) + if line_vals is None: + continue + lines_vals.append((0, 0, line_vals)) + + if not lines_vals: + raise UserError(_( + 'No remaining amount to invoice for %s. ' + 'All lines have already been fully invoiced.' + ) % purchase_order.name) + + invoice_vals['invoice_line_ids'] = lines_vals + invoice = self.env['account.move'].create(invoice_vals) + + # Flag the purchase order as using separate-valuation mode + if not purchase_order.use_separate_valuation: + purchase_order.with_context(tracking_disable=True).write( + {'use_separate_valuation': True} + ) + + return self._return_invoice_action(invoice) + + def _prepare_line_vals(self, line, tmp_move): + """Return line_vals dict for a single PO line, or None to skip. + + Lines with a zero or negative remaining amount are skipped so that the + invoice does not contain confusing zero-price rows. + + For lines with a positive remaining amount the invoice line is always + created with qty=1 and price_unit=remaining_amount so that the invoice + total is exact regardless of fractional product quantities. + + For products with price-inclusive taxes, amount_to_invoice is + tax-exclusive (based on price_subtotal). The invoice line's price_unit + must be the tax-inclusive (gross) amount so that Odoo's tax engine + computes the correct price_subtotal when it back-calculates. + We achieve this by running compute_all on the inclusive taxes in + price-exclusive mode (handle_price_include=False) to obtain the + gross-up factor: price_unit = remaining_amount + inclusive_tax_amount. + """ + remaining_amount = line.amount_to_invoice + if ( + float_is_zero(remaining_amount, precision_rounding=line.currency_id.rounding) + or remaining_amount <= 0 + ): + return None + line_vals = line._prepare_account_move_line(tmp_move) + + # Resolve tax IDs from ORM command format [(6, 0, [ids])] + tax_ids = [] + for cmd in line_vals.get('tax_ids', []): + if isinstance(cmd, (list, tuple)): + if cmd[0] == 6: + tax_ids = list(cmd[2]) + elif cmd[0] == 4: + tax_ids.append(cmd[1]) + + # For price-inclusive taxes, gross up to the tax-inclusive price_unit. + include_taxes = self.env['account.tax'].browse(tax_ids).filtered('price_include') + if include_taxes: + tax_res = include_taxes.compute_all( + remaining_amount, + currency=line.currency_id, + quantity=1.0, + product=line.product_id, + partner=line.order_id.partner_id, + handle_price_include=False, + ) + price_unit = tax_res['total_included'] + else: + price_unit = remaining_amount + + line_vals['quantity'] = 1.0 + line_vals['discount'] = 0.0 + line_vals['price_unit'] = price_unit + return line_vals + + @staticmethod + def _return_invoice_action(invoice): + return { + 'type': 'ir.actions.act_window', + 'name': _('Vendor Bills'), + 'res_model': 'account.move', + 'view_mode': 'form', + 'res_id': invoice.id, + 'target': 'current', + } diff --git a/purchase_invoice_separate_valuation/wizards/purchase_make_invoice_advance_views.xml b/purchase_invoice_separate_valuation/wizards/purchase_make_invoice_advance_views.xml new file mode 100644 index 00000000..fcec4df4 --- /dev/null +++ b/purchase_invoice_separate_valuation/wizards/purchase_make_invoice_advance_views.xml @@ -0,0 +1,26 @@ + + + + + purchase.make_invoice_advance.form + purchase.make_invoice_advance + + + + + + + Final Invoice: This will update purchase order quantities and may close the purchase order. + + + Partial Invoice: Purchase order quantities will not be updated. You can create multiple invoices. + + + + + + + \ No newline at end of file diff --git a/purchase_provisional_billing/__init__.py b/purchase_provisional_billing/__init__.py new file mode 100644 index 00000000..2d94ca59 --- /dev/null +++ b/purchase_provisional_billing/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2025 Quartile Limited +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import models diff --git a/purchase_provisional_billing/__manifest__.py b/purchase_provisional_billing/__manifest__.py new file mode 100644 index 00000000..56d5676f --- /dev/null +++ b/purchase_provisional_billing/__manifest__.py @@ -0,0 +1,17 @@ +# Copyright 2025 Quartile Limited +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +{ + "name": "Purchase Provisional Billing", + "version": "16.0.1.0.0", + "author": "Quartile Limited", + "website": "https://www.quartile.co", + "category": "Purchase", + "license": "AGPL-3", + "depends": ["purchase_stock"], + "data": [ + "views/res_config_settings_views.xml", + "views/purchase_order_views.xml", + "views/account_move_views.xml", + ], + "installable": True, +} diff --git a/purchase_provisional_billing/models/__init__.py b/purchase_provisional_billing/models/__init__.py new file mode 100644 index 00000000..59aed84e --- /dev/null +++ b/purchase_provisional_billing/models/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2025 Quartile Limited +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import account_move +from . import purchase_order +from . import res_company +from . import res_config_settings diff --git a/purchase_provisional_billing/models/account_move.py b/purchase_provisional_billing/models/account_move.py new file mode 100644 index 00000000..7941d79a --- /dev/null +++ b/purchase_provisional_billing/models/account_move.py @@ -0,0 +1,90 @@ +# Copyright 2025 Quartile Limited +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import api, fields, models + + +class AccountMove(models.Model): + _inherit = "account.move" + + sep_val_move_type = fields.Selection( + [ + ("advance_bill", "Advance Bill"), + ("final_bill", "Final Bill"), + ("advance_application", "Application Credit Note"), + ("adjustment_entry", "Adjustment Entry"), + ], + string="Sep. Val. Type", + copy=False, + help="Separate valuation document type.", + ) + sep_val_purchase_id = fields.Many2one( + "purchase.order", + string="Sep. Val. Purchase Order", + copy=False, + ondelete="set null", + help="Purchase order this document is linked to (Separate Valuation Mode).", + ) + sep_val_origin_move_id = fields.Many2one( + "account.move", + string="Origin Advance Bill", + copy=False, + ondelete="set null", + help="Advance bill this application credit note is derived from.", + ) + sep_val_target_move_id = fields.Many2one( + "account.move", + string="Target Final Bill", + copy=False, + ondelete="set null", + help="Final bill this application credit note was reconciled against.", + ) + sep_val_application_move_ids = fields.One2many( + "account.move", + "sep_val_origin_move_id", + string="Application Credit Notes", + help="Application credit notes derived from this advance bill.", + ) + sep_val_advance_applied_amount = fields.Monetary( + string="Applied Amount", + compute="_compute_sep_val_advance_amounts", + currency_field="currency_id", + help="Sum of posted application credit note untaxed amounts.", + ) + sep_val_advance_remaining_amount = fields.Monetary( + string="Remaining Amount", + compute="_compute_sep_val_advance_amounts", + currency_field="currency_id", + help="Untaxed amount of this advance bill minus the applied amount.", + ) + + @api.depends( + "sep_val_move_type", + "amount_untaxed", + "sep_val_application_move_ids.state", + "sep_val_application_move_ids.amount_untaxed", + ) + def _compute_sep_val_advance_amounts(self): + for move in self: + if move.sep_val_move_type != "advance_bill": + move.sep_val_advance_applied_amount = 0.0 + move.sep_val_advance_remaining_amount = 0.0 + continue + applied = sum( + cn.amount_untaxed + for cn in move.sep_val_application_move_ids + if cn.state == "posted" + ) + move.sep_val_advance_applied_amount = applied + move.sep_val_advance_remaining_amount = move.amount_untaxed - applied + + def _post(self, soft=True): + posted = super()._post(soft=soft) + for move in posted: + order = move.sep_val_purchase_id + if not order: + continue + if move.sep_val_move_type == "advance_bill": + order._create_paired_credit_note(move) + elif move.sep_val_move_type == "final_bill": + order._settle_final_bill(move) + return posted diff --git a/purchase_provisional_billing/models/purchase_order.py b/purchase_provisional_billing/models/purchase_order.py new file mode 100644 index 00000000..dc24793c --- /dev/null +++ b/purchase_provisional_billing/models/purchase_order.py @@ -0,0 +1,518 @@ +# Copyright 2025 Quartile Limited +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import Command, _, api, fields, models +from odoo.exceptions import UserError +from odoo.tools.float_utils import float_compare, float_is_zero + + +class PurchaseOrder(models.Model): + _inherit = "purchase.order" + + separate_valuation = fields.Boolean( + string="Separate Valuation Mode", + copy=False, + help="When enabled, advance bills are used for interim invoicing and " + "stock valuation is not affected by vendor bills.", + ) + sep_val_advance_bill_ids = fields.One2many( + "account.move", + "sep_val_purchase_id", + domain=[("sep_val_move_type", "=", "advance_bill")], + string="Advance Bills", + ) + sep_val_final_bill_id = fields.Many2one( + "account.move", + string="Final Bill", + copy=False, + ) + sep_val_application_credit_note_ids = fields.One2many( + "account.move", + "sep_val_purchase_id", + domain=[("sep_val_move_type", "=", "advance_application")], + string="Application Credit Notes", + ) + sep_val_adjustment_entry_ids = fields.One2many( + "account.move", + "sep_val_purchase_id", + domain=[("sep_val_move_type", "=", "adjustment_entry")], + string="Adjustment Entries", + ) + sep_val_advance_total = fields.Monetary( + string="Advance Total", + compute="_compute_sep_val_advance_totals", + currency_field="currency_id", + help="Sum of posted Advance Bills converted to PO currency.", + ) + sep_val_applied_total = fields.Monetary( + string="Applied Total", + compute="_compute_sep_val_advance_totals", + currency_field="currency_id", + help="Sum of posted Application Credit Notes converted to PO currency.", + ) + sep_val_unapplied_advance_total = fields.Monetary( + string="Unapplied Advance", + compute="_compute_sep_val_advance_totals", + currency_field="currency_id", + help="Advance Total minus Applied Total.", + ) + + @api.depends( + "sep_val_advance_bill_ids.state", + "sep_val_advance_bill_ids.amount_untaxed", + "sep_val_advance_bill_ids.currency_id", + "sep_val_advance_bill_ids.invoice_date", + "sep_val_application_credit_note_ids.state", + "sep_val_application_credit_note_ids.amount_untaxed", + "sep_val_application_credit_note_ids.currency_id", + "sep_val_application_credit_note_ids.invoice_date", + "currency_id", + ) + def _compute_sep_val_advance_totals(self): + for order in self: + posted_bills = order.sep_val_advance_bill_ids.filtered( + lambda m: m.state == "posted" + ) + advance_total = sum( + bill.currency_id._convert( + bill.amount_untaxed, + order.currency_id, + order.company_id, + bill.invoice_date or fields.Date.today(), + ) + for bill in posted_bills + ) + posted_notes = order.sep_val_application_credit_note_ids.filtered( + lambda m: m.state == "posted" + ) + applied_total = sum( + note.currency_id._convert( + note.amount_untaxed, + order.currency_id, + order.company_id, + note.invoice_date or fields.Date.today(), + ) + for note in posted_notes + ) + order.sep_val_advance_total = advance_total + order.sep_val_applied_total = applied_total + order.sep_val_unapplied_advance_total = advance_total - applied_total + + def action_create_invoice(self): + sep_val_orders = self.filtered("separate_valuation") + if sep_val_orders: + raise UserError( + _( + "Purchase order(s) %s use Separate Valuation Mode. " + "Please use the dedicated Advance/Final Bill buttons instead." + ) + % ", ".join(sep_val_orders.mapped("name")) + ) + return super().action_create_invoice() + + def action_create_advance_bill(self): + self.ensure_one() + if self.sep_val_final_bill_id: + raise UserError( + _( + "A Final Bill already exists for purchase order '%s'. " + "You cannot create a new advance bill after the final bill." + ) + % self.name + ) + order = self.with_company(self.company_id) + invoice_vals = order._prepare_invoice() + invoice_vals["sep_val_move_type"] = "advance_bill" + invoice_vals["sep_val_purchase_id"] = order.id + + sequence = 10 + pending_section = None + for line in order.order_line: + if line.display_type == "line_section": + pending_section = line + continue + if line.display_type: + if line.display_type == "line_note": + note_vals = line._prepare_account_move_line() + note_vals["sequence"] = sequence + invoice_vals["invoice_line_ids"].append(Command.create(note_vals)) + sequence += 1 + continue + if pending_section: + section_vals = pending_section._prepare_account_move_line() + section_vals["sequence"] = sequence + invoice_vals["invoice_line_ids"].append(Command.create(section_vals)) + sequence += 1 + pending_section = None + line_vals = line._prepare_account_move_line() + line_vals.pop("product_id", None) + line_vals.pop("purchase_line_id", None) + line_vals["account_id"] = order._get_sep_val_advance_account(line).id + line_vals["tax_ids"] = False + line_vals["quantity"] = line.product_qty + line_vals["sequence"] = sequence + invoice_vals["invoice_line_ids"].append(Command.create(line_vals)) + sequence += 1 + + bill = ( + self.env["account.move"] + .with_context(default_move_type="in_invoice") + .create(invoice_vals) + ) + return { + "type": "ir.actions.act_window", + "name": _("Advance Bill"), + "res_model": "account.move", + "view_mode": "form", + "res_id": bill.id, + } + + def action_create_final_bill(self): + self.ensure_one() + if self.sep_val_final_bill_id: + raise UserError( + _( + "A Final Bill already exists for purchase order '%s'. " + "Please unset or delete it before creating a new one." + ) + % self.name + ) + if not self._check_all_received(): + raise UserError( + _( + "Cannot create Final Bill for '%s': some receipt-tracked " + "items have not been fully received yet." + ) + % self.name + ) + draft_advances = self.sep_val_advance_bill_ids.filtered( + lambda m: m.state == "draft" + ) + if draft_advances: + raise UserError( + _( + "Please post or cancel all draft Advance Bills before " + "creating the Final Bill for '%s'." + ) + % self.name + ) + + precision = self.env["decimal.precision"].precision_get( + "Product Unit of Measure" + ) + invoiceable_lines = self.order_line.filtered( + lambda l: not l.display_type + and not float_is_zero(l.qty_to_invoice, precision_digits=precision) + ) + if not invoiceable_lines: + raise UserError( + _( + "No invoiceable lines found on purchase order '%s'. " + "All items may already be fully invoiced." + ) + % self.name + ) + + order = self.with_company(self.company_id) + invoice_vals = order._prepare_invoice() + invoice_vals["sep_val_move_type"] = "final_bill" + invoice_vals["sep_val_purchase_id"] = order.id + + sequence = 10 + pending_section = None + for line in order.order_line: + if line.display_type == "line_section": + pending_section = line + continue + if line.display_type: + continue + if float_is_zero(line.qty_to_invoice, precision_digits=precision): + continue + if pending_section: + section_vals = pending_section._prepare_account_move_line() + section_vals["sequence"] = sequence + invoice_vals["invoice_line_ids"].append(Command.create(section_vals)) + sequence += 1 + pending_section = None + line_vals = line._prepare_account_move_line() + line_vals["sequence"] = sequence + invoice_vals["invoice_line_ids"].append(Command.create(line_vals)) + sequence += 1 + + bill = ( + self.env["account.move"] + .with_context(default_move_type="in_invoice") + .create(invoice_vals) + ) + self.sep_val_final_bill_id = bill + return { + "type": "ir.actions.act_window", + "name": _("Final Bill"), + "res_model": "account.move", + "view_mode": "form", + "res_id": bill.id, + } + + def action_view_advance_bills(self): + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": _("Advance Bills"), + "res_model": "account.move", + "view_mode": "tree,form", + "domain": [ + ("sep_val_purchase_id", "=", self.id), + ("sep_val_move_type", "=", "advance_bill"), + ], + "context": {"create": False}, + } + + def action_view_sep_val_documents(self): + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": _("Separate Valuation Documents"), + "res_model": "account.move", + "view_mode": "tree,form", + "domain": [ + ("sep_val_purchase_id", "=", self.id), + ( + "sep_val_move_type", + "in", + ("final_bill", "advance_application", "adjustment_entry"), + ), + ], + "context": {"create": False}, + } + + def action_unset_final_bill(self): + self.ensure_one() + final = self.sep_val_final_bill_id + if not final: + return {"type": "ir.actions.act_window_close"} + if final.state == "posted": + raise UserError( + _( + "The Final Bill '%s' is posted and has been reconciled with " + "advance credit notes. Please reset it to draft (which will " + "also break the reconciliation) before unsetting." + ) + % final.name + ) + final.write( + { + "sep_val_move_type": False, + "sep_val_purchase_id": False, + } + ) + self.sep_val_final_bill_id = False + + def _check_all_received(self): + precision = self.env["decimal.precision"].precision_get( + "Product Unit of Measure" + ) + for line in self.order_line.filtered( + lambda l: not l.display_type + and l.product_id.purchase_method == "receive" + ): + if ( + float_compare( + line.qty_received, + line.product_qty, + precision_digits=precision, + ) + < 0 + ): + return False + return True + + def _get_sep_val_advance_account(self, line): + self.ensure_one() + accounts = line.product_id.product_tmpl_id.get_product_accounts( + fiscal_pos=self.fiscal_position_id + ) + account = accounts.get("expense") + if account: + return account + raise UserError( + _( + "Could not determine an account for purchase order line '%s'. " + "Please configure the product or category expense account." + ) + % line.name + ) + + def _prepare_advance_credit_note_vals(self, advance_bill): + self.ensure_one() + advance_lines = advance_bill.invoice_line_ids + if not advance_lines: + raise UserError( + _( + "Advance Bill '%s' has no invoice lines to derive the application " + "credit note from." + ) + % advance_bill.display_name + ) + line_commands = [] + for line in advance_lines: + if line.display_type: + line_commands.append( + Command.create( + { + "display_type": line.display_type, + "name": line.name, + "sequence": line.sequence, + } + ) + ) + continue + line_commands.append( + Command.create( + { + "name": _("Reverse Advance: %s") % line.name, + "quantity": line.quantity, + "account_id": line.account_id.id, + "price_unit": line.price_unit, + "tax_ids": False, + "analytic_distribution": line.analytic_distribution, + "sequence": line.sequence, + } + ) + ) + return { + "move_type": "in_refund", + "partner_id": self.partner_id.id, + "invoice_origin": self.name, + "invoice_date": advance_bill.invoice_date or fields.Date.today(), + "currency_id": advance_bill.currency_id.id, + "fiscal_position_id": self.fiscal_position_id.id, + "company_id": self.company_id.id, + "sep_val_move_type": "advance_application", + "sep_val_purchase_id": self.id, + "sep_val_origin_move_id": advance_bill.id, + "invoice_line_ids": line_commands, + } + + def _create_paired_credit_note(self, advance_bill): + """Create and post a credit note that reverses the advance bill. + + Called from ``account.move._post`` when an advance bill is posted. + The credit note mirrors the advance bill 1:1 so the P&L impact of + the pair is zero. The AP-side line stays unreconciled until the + final bill is posted, at which point it is offset against the + final bill's payable line. + """ + self.ensure_one() + existing = advance_bill.sep_val_application_move_ids.filtered( + lambda m: m.state != "cancel" + ) + if existing: + return existing[:1] + vals = self._prepare_advance_credit_note_vals(advance_bill) + credit_note = self.env["account.move"].create(vals) + credit_note.action_post() + return credit_note + + def _settle_final_bill(self, final_bill): + """Reconcile the final bill payable line with paired credit notes. + + Any residual difference is posted to the adjustment account via a + separate journal entry, then all payable lines are reconciled + together so the final bill (and credit notes) are marked paid. + """ + self.ensure_one() + company = self.company_id + payable_account = final_bill.partner_id.with_company( + company + ).property_account_payable_id + if not payable_account: + raise UserError( + _("Vendor '%s' has no payable account configured.") + % final_bill.partner_id.display_name + ) + + final_ap_lines = final_bill.line_ids.filtered( + lambda l: l.account_id == payable_account and not l.reconciled + ) + cn_ap_lines = self.sep_val_application_credit_note_ids.filtered( + lambda m: m.state == "posted" + ).line_ids.filtered( + lambda l: l.account_id == payable_account and not l.reconciled + ) + ap_lines = final_ap_lines | cn_ap_lines + if not ap_lines: + return + + balance_sum = sum(ap_lines.mapped("balance")) + rounding = company.currency_id.rounding + + adjustment = self.env["account.move"] + if not float_is_zero(balance_sum, precision_rounding=rounding): + adjustment = self._create_settlement_adjustment( + final_bill, payable_account, balance_sum + ) + ap_lines |= adjustment.line_ids.filtered( + lambda l: l.account_id == payable_account + ) + + ap_lines.reconcile() + self.sep_val_application_credit_note_ids.filtered( + lambda m: m.state == "posted" and not m.sep_val_target_move_id + ).write({"sep_val_target_move_id": final_bill.id}) + return adjustment + + def _create_settlement_adjustment(self, final_bill, payable_account, balance_sum): + company = self.company_id + adjustment_account = company.sep_val_variance_account_id + if not adjustment_account: + raise UserError( + _( + "Please configure the Settlement Adjustment Account in " + "Accounting settings before posting the Final Bill." + ) + ) + journal = self.env["account.journal"].search( + [("type", "=", "general"), ("company_id", "=", company.id)], + limit=1, + ) + if not journal: + raise UserError( + _("No Miscellaneous journal was found for company '%s'.") + % company.display_name + ) + + # ap_offset is what we must add on the payable side to bring the + # combined balance back to zero (so the reconciliation closes out). + ap_offset = -balance_sum + ref = _("PO Settlement Adjustment - %s") % self.name + line_vals = [ + Command.create( + { + "account_id": payable_account.id, + "partner_id": final_bill.partner_id.id, + "name": ref, + "debit": ap_offset if ap_offset > 0 else 0.0, + "credit": -ap_offset if ap_offset < 0 else 0.0, + } + ), + Command.create( + { + "account_id": adjustment_account.id, + "name": ref, + "debit": balance_sum if balance_sum > 0 else 0.0, + "credit": -balance_sum if balance_sum < 0 else 0.0, + } + ), + ] + adjustment = self.env["account.move"].create( + { + "journal_id": journal.id, + "date": final_bill.invoice_date or fields.Date.today(), + "ref": ref, + "company_id": company.id, + "line_ids": line_vals, + "sep_val_move_type": "adjustment_entry", + "sep_val_purchase_id": self.id, + } + ) + adjustment.action_post() + return adjustment diff --git a/purchase_provisional_billing/models/res_company.py b/purchase_provisional_billing/models/res_company.py new file mode 100644 index 00000000..4858e90a --- /dev/null +++ b/purchase_provisional_billing/models/res_company.py @@ -0,0 +1,15 @@ +# Copyright 2025 Quartile Limited +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + sep_val_variance_account_id = fields.Many2one( + "account.account", + string="Settlement Adjustment Account", + help="Account used to absorb the difference between the Final Bill " + "amount and the sum of paired credit notes when the Final Bill is " + "settled against advance bills.", + ) diff --git a/purchase_provisional_billing/models/res_config_settings.py b/purchase_provisional_billing/models/res_config_settings.py new file mode 100644 index 00000000..ebc863ac --- /dev/null +++ b/purchase_provisional_billing/models/res_config_settings.py @@ -0,0 +1,12 @@ +# Copyright 2025 Quartile Limited +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + sep_val_variance_account_id = fields.Many2one( + related="company_id.sep_val_variance_account_id", + readonly=False, + ) diff --git a/purchase_provisional_billing/readme/DESCRIPTION.md b/purchase_provisional_billing/readme/DESCRIPTION.md new file mode 100644 index 00000000..6c0cfb0f --- /dev/null +++ b/purchase_provisional_billing/readme/DESCRIPTION.md @@ -0,0 +1,49 @@ +# Purchase Provisional Billing + +Implements a provisional billing flow for purchase orders where vendor bills +can be issued and paid before the final, product-linked vendor bill is +created. + +## Flow + +1. Enable **Separate Valuation Mode** on the purchase order. +2. Receive goods normally; stock valuation is posted against GRNI at receipt + as in standard Odoo. +3. Create one or more **Advance Bills** from the PO. Advance bills carry no + product link (so `qty_invoiced` and stock valuation are not affected) and + use the line's expense account. +4. When an Advance Bill is **posted**, the module automatically creates and + posts a paired **Application Credit Note** that mirrors the Advance Bill + line-for-line. The pair has zero net P&L impact, but the credit note's + payable line remains open, waiting to be reconciled with the Final Bill. +5. The Advance Bill itself is paid through the normal payment workflow. +6. Once all items have been received and all Advance Bills are posted, click + **Create Final Bill** on the PO. This creates a *standard* Odoo vendor + bill — product-linked, updating `qty_invoiced`, going through the normal + GRNI / price-difference flow. +7. When the Final Bill is **posted**, the module automatically: + - Computes the residual difference between the Final Bill's payable line + and the sum of the paired credit notes' payable lines. + - If non-zero, posts a **Settlement Adjustment** journal entry whose + payable line offsets the residual. + - Reconciles the Final Bill, the Application Credit Notes, and (if any) + the Adjustment entry's payable line together — closing out the Final + Bill as fully paid. + +## Why this design + +Advance bills are kept as plain, product-less vendor bills so that the +"per-unit cost" reflected in `qty_invoiced` and any reporting that depends on +it is driven only by the Final Bill (which uses the PO price and updates +stock valuation through Odoo's standard price-difference logic). Differences +between what was actually paid (advance bills) and the PO/receipt value are +absorbed in a dedicated adjustment account, keeping the rest of the +accounting in line with stock movements. + +## Configuration + +Go to **Accounting > Configuration > Settings > Separate Valuation Mode**: + +- **Settlement Adjustment Account**: The account used to absorb the + difference between the Final Bill amount and the sum of paired credit + notes when the Final Bill is settled. diff --git a/purchase_provisional_billing/views/account_move_views.xml b/purchase_provisional_billing/views/account_move_views.xml new file mode 100644 index 00000000..d0816669 --- /dev/null +++ b/purchase_provisional_billing/views/account_move_views.xml @@ -0,0 +1,57 @@ + + + + account.move.form.sep.val.inherit + account.move + + + + + Advance Bill + Final Bill + Application Credit Note + Adjustment Entry + + + + + + + + + + + + + + diff --git a/purchase_provisional_billing/views/purchase_order_views.xml b/purchase_provisional_billing/views/purchase_order_views.xml new file mode 100644 index 00000000..2255f52c --- /dev/null +++ b/purchase_provisional_billing/views/purchase_order_views.xml @@ -0,0 +1,90 @@ + + + + purchase.order.form.sep.val.inherit + purchase.order + + + + + + + + + + {'invisible': ['|', '|', ('state', 'not in', ('purchase', 'done')), ('invoice_status', 'in', ('no', 'invoiced')), ('separate_valuation', '=', True)]} + + + + + {'invisible': ['|', '|', '|', ('state', 'not in', ('purchase', 'done')), ('invoice_status', 'not in', ('no', 'invoiced')), ('order_line', '=', []), ('separate_valuation', '=', True)]} + + + + + + + + + + + + + + + + + + + diff --git a/purchase_provisional_billing/views/res_config_settings_views.xml b/purchase_provisional_billing/views/res_config_settings_views.xml new file mode 100644 index 00000000..f82e2237 --- /dev/null +++ b/purchase_provisional_billing/views/res_config_settings_views.xml @@ -0,0 +1,30 @@ + + + + res.config.settings.view.form.sep.val + res.config.settings + + + + Separate Valuation Mode + + + + + + + Account used to absorb the difference between the Final + Bill amount and the sum of paired advance credit notes. + + + + + + + + + + + + +