From edf2b0afd08ac8ebf0821d3a66fa2caea7d4e9bd Mon Sep 17 00:00:00 2001 From: kanda999 Date: Thu, 29 Jan 2026 03:36:02 +0000 Subject: [PATCH 1/6] [ADD] purchase_invoice_separate_valuation --- purchase_invoice_separate_valuation/README.md | 121 ++++++++ .../__init__.py | 2 + .../__manifest__.py | 34 +++ .../models/__init__.py | 3 + .../models/account_move.py | 266 ++++++++++++++++++ .../models/purchase.py | 32 +++ .../models/res_config_settings.py | 27 ++ .../security/ir.model.access.csv | 3 + .../views/account_move_views.xml | 27 ++ .../views/purchase_views.xml | 46 +++ .../views/res_config_settings_views.xml | 34 +++ .../wizards/__init__.py | 1 + .../wizards/account_move_reversal_views.xml | 34 +++ .../wizards/purchase_make_invoice_advance.py | 95 +++++++ 14 files changed, 725 insertions(+) create mode 100644 purchase_invoice_separate_valuation/README.md create mode 100644 purchase_invoice_separate_valuation/__init__.py create mode 100644 purchase_invoice_separate_valuation/__manifest__.py create mode 100644 purchase_invoice_separate_valuation/models/__init__.py create mode 100644 purchase_invoice_separate_valuation/models/account_move.py create mode 100644 purchase_invoice_separate_valuation/models/purchase.py create mode 100644 purchase_invoice_separate_valuation/models/res_config_settings.py create mode 100644 purchase_invoice_separate_valuation/security/ir.model.access.csv create mode 100644 purchase_invoice_separate_valuation/views/account_move_views.xml create mode 100644 purchase_invoice_separate_valuation/views/purchase_views.xml create mode 100644 purchase_invoice_separate_valuation/views/res_config_settings_views.xml create mode 100644 purchase_invoice_separate_valuation/wizards/__init__.py create mode 100644 purchase_invoice_separate_valuation/wizards/account_move_reversal_views.xml create mode 100644 purchase_invoice_separate_valuation/wizards/purchase_make_invoice_advance.py diff --git a/purchase_invoice_separate_valuation/README.md b/purchase_invoice_separate_valuation/README.md new file mode 100644 index 00000000..d2f81865 --- /dev/null +++ b/purchase_invoice_separate_valuation/README.md @@ -0,0 +1,121 @@ +# Purchase Invoice Separate Valuation + +## 概要 + +このモジュールは、Odoo 16.0において仕入先請求書の作成と在庫評価を完全に分離する機能を提供します。 + +## 機能 + +### 1. 複数請求書作成機能 +- 従来のOdooでは1つの購買オーダに対して請求済数量が管理されているため、複数回の請求書作成が制限されていました +- このモジュールにより、「最終請求」フラグがOFFの場合は何度でも請求書を作成できます + +### 2. 在庫評価からの完全分離 +- **すべての仕入先請求書(非最終・最終含む)**で、stock.valuation.layerへの直接的な影響を排除 +- 請求書計上は単純に仕入先負債(買掛金)のみを計上 +- 在庫評価は入庫時の価格で確定し、請求書では変更されません + +### 3. 最終請求によるGRNI残高調整 +- 最終請求フラグがONの請求書確定時に、**別の調整仕訳**を自動作成 +- この調整仕訳は以下を実現: + - 入庫時に計上されたGRNIと、POに紐づく請求書のGRNI合計を比較 + - GRNI(Stock Interim Received / 受入未請求)勘定の残高をゼロに調整 + - 仕入差額勘定に差分を反映 + +### 4. 購買オーダステータス管理 +- 最終請求確定時に購買オーダが「請求済」ステータスに変更 + +## 仕組み + +### 入庫時 +``` +在庫資産 10,000 / GRNI 10,000 (入庫時の標準価格/FIFO価格で計上) +``` + +### 非最終請求書(5,000円) +``` +GRNI 5,000 / 買掛金 5,000 (在庫評価には影響なし) +``` + +### 最終請求書(5,000円) +``` +【請求書仕訳】 +GRNI 5,000 / 買掛金 5,000 (通常の請求書計上) + +【自動作成される価格差異調整仕訳】 +(調整仕訳は作成されません - 合計10,000円で入庫時と一致) +``` + +### 最終請求書でのGRNI調整例(合計12,000円の場合) +``` +【請求書仕訳】 +GRNI 7,000 / 買掛金 7,000 (残りの請求) + +【自動作成される価格差異調整仕訳】 +仕入差額 2,000 / GRNI 2,000 (入庫10,000円 vs 請求12,000円の差額) +``` + +## 使用方法 + +### 1. 通常の請求書作成(最終請求OFF) +``` +購買管理 → 購買オーダ → [オーダを選択] → 「Create Invoice (Advanced)」ボタン +→ 「Final Invoice」のチェックをOFFのまま → 「Create Invoice」 +``` + +### 2. 最終請求書作成(最終請求ON) +``` +購買管理 → 購買オーダ → [オーダを選択] → 「Create Invoice (Advanced)」ボタン +→ 「Final Invoice」をチェックON → 「Create Invoice」 +``` + +確定すると、GRNI調整仕訳が自動的に作成され、請求書フォームの「Price Adjustment Entry」フィールドからアクセスできます。 + +## 技術仕様 + +### オーバーライドされるメソッド + +1. **account.move._post()** + - すべての仕入先請求書でpurchase_stockのAnglo-Saxon処理をバイパス + - 最終請求書確定時に`_sync_price_adjustment_entry()`を呼び出し + +2. **account.move._sync_price_adjustment_entry()** + - 最終請求書専用の新規メソッド + - PO単位でGRNIの受領・請求合計を比較 + - 別の仕訳(account.move)としてGRNI調整を起票 + +3. **account.move.line._create_stock_valuation_layer()** + - 仕入先請求書起因のvaluation layer作成をブロック + +4. **purchase.order.line._compute_qty_invoiced()** + - 最終請求フラグがONの請求書のみをqty_invoiced計算に含める + +### 新しいフィールド + +- **account.move.is_final_invoice**: 最終請求フラグ(Boolean) +- **account.move.price_adjustment_move_id**: 価格調整仕訳へのリンク(Many2one) + +### 新しいウィザード + +- **purchase.make_invoice_advance**: 拡張請求書作成ウィザード + +## インストール + +1. モジュールをaddons-pathにコピー +2. Odooを再起動 +3. アプリメニューで「Purchase Invoice Separate Valuation」を検索してインストール + +## 依存関係 + +- purchase +- purchase_stock +- stock_account +- account + +## ライセンス + +AGPL-3 + +## 著者 + +作成日: 2026年1月28日 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..73ba7d1b --- /dev/null +++ b/purchase_invoice_separate_valuation/__manifest__.py @@ -0,0 +1,34 @@ +{ + 'name': 'Purchase Invoice Separate Valuation', + 'version': '16.0.1.0.0', + 'category': 'Purchase', + 'summary': 'Separate purchase invoicing from stock valuation', + 'description': """ +Purchase Invoice Separate Valuation +=================================== + +This module separates purchase invoicing from stock valuation: + +* Allow multiple invoices creation without updating qty_invoiced +* Prevent vendor bill price adjustments from affecting stock.valuation.layer +* Add "Final Invoice" flag in invoice wizard +* Update purchase order status only when Final Invoice is confirmed + """, + 'depends': [ + 'purchase', + 'purchase_stock', + 'stock_account', + 'account', + ], + 'data': [ + 'security/ir.model.access.csv', + 'wizards/account_move_reversal_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..19975121 --- /dev/null +++ b/purchase_invoice_separate_valuation/models/__init__.py @@ -0,0 +1,3 @@ +from . import purchase +from . import account_move +from . import res_config_settings 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..9f6248f5 --- /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.tools import float_compare + + +class AccountMove(models.Model): + _inherit = 'account.move' + + is_final_invoice = fields.Boolean( + string='Final Invoice', + default=False, + help='When checked, this invoice will update purchase order quantities and create price difference adjustment entries' + ) + price_adjustment_move_id = fields.Many2one( + 'account.move', + string='Price Adjustment Entry', + readonly=True, + help='Accounting entry created to adjust the price difference between receipt and final invoice' + ) + + def _post(self, soft=True): + """ + Override purchase_stock module's _post() to completely bypass + price difference application for ALL vendor bills. + + This replicates purchase_stock._post() but excludes vendor bills from processing. + """ + from odoo.tools import float_is_zero + from odoo.tools.misc import groupby + + # Separate vendor bills from other invoices + vendor_bills = self.filtered(lambda m: m.move_type in ('in_invoice', 'in_refund', 'in_receipt')) + other_invoices = self - vendor_bills + + result = self.env['account.move'] + + # Process other invoices normally (with purchase_stock logic) + if other_invoices: + result |= super(AccountMove, other_invoices)._post(soft) + + # Process vendor bills WITHOUT purchase_stock logic + if vendor_bills: + # Skip to stock_account and account modules only + # by importing the base class before purchase_stock + from odoo.addons.stock_account.models.account_move import AccountMove as StockAccountMove + result |= StockAccountMove._post(vendor_bills, soft) + + # Sync price adjustment entries for vendor bills + for bill in vendor_bills.filtered(lambda b: b.state == 'posted'): + bill._sync_price_adjustment_entry() + + return result + + def _sync_price_adjustment_entry(self): + """Create or update a separate accounting entry to adjust GRNI balance for final invoices""" + self.ensure_one() + + import logging + _logger = logging.getLogger(__name__) + + _logger.info('=== _sync_price_adjustment_entry called for invoice %s ===', self.name) + _logger.info('is_final_invoice: %s, move_type: %s', self.is_final_invoice, self.move_type) + + if not self.is_final_invoice or self.move_type not in ('in_invoice', 'in_refund', 'in_receipt'): + _logger.info('Skipping: not a final vendor bill') + if self.price_adjustment_move_id: + _logger.info('Removing existing adjustment entry because invoice is not final') + self._remove_price_adjustment_entry() + return + + purchase = self.purchase_id + if not purchase: + purchase = self.invoice_line_ids.mapped('purchase_line_id.order_id')[:1] + if purchase: + _logger.info('Resolved purchase order from invoice lines: %s', purchase.name) + if not purchase: + _logger.warning('Skipping: invoice is not linked to any purchase order') + return + + # Get the configured price adjustment account + price_adjustment_account = self.company_id.purchase_price_adjustment_account_id + if not price_adjustment_account: + _logger.warning('Skipping: price adjustment account not configured in company settings') + return + + _logger.info('Price adjustment account: %s', price_adjustment_account.display_name) + + adjustment_lines = [] + + _logger.info('Invoice is linked to purchase order: %s', purchase.name) + + stock_moves = purchase.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' + ) + _logger.info('Done stock moves: %d', len(stock_moves)) + + # Identify GRNI accounts from PO products + stock_input_account_ids = set() + for line in purchase.order_line.filtered(lambda l: l.product_id): + product_accounts = line.product_id.product_tmpl_id._get_product_accounts() + stock_input_account = product_accounts.get('stock_input') + if stock_input_account: + stock_input_account_ids.add(stock_input_account.id) + + # Receipt side (GRNI from stock input) - use accounting entries from stock moves + receipt_balances = {} + if stock_moves and stock_input_account_ids: + receipt_amls = stock_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' + ) + else: + receipt_amls = self.env['account.move.line'] + + for line in receipt_amls: + receipt_balances.setdefault(line.account_id.id, 0.0) + receipt_balances[line.account_id.id] += line.balance + + # Invoice side (GRNI from vendor bills) - only invoice lines tied to this PO + po_invoice_lines = purchase.order_line.mapped('invoice_lines').filtered( + lambda l: l.move_id.state == 'posted' + and l.move_id.move_type in ('in_invoice', 'in_refund', 'in_receipt') + ) + if stock_input_account_ids: + invoice_lines = po_invoice_lines.filtered(lambda l: l.account_id.id in stock_input_account_ids) + else: + invoice_lines = self.env['account.move.line'] + + invoice_balances = {} + for line in invoice_lines: + invoice_balances.setdefault(line.account_id.id, 0.0) + invoice_balances[line.account_id.id] += line.balance + + account_ids = set(receipt_balances.keys()) | set(invoice_balances.keys()) + + precision_rounding = self.company_id.currency_id.rounding + for account_id in account_ids: + receipt_balance = receipt_balances.get(account_id, 0.0) + invoice_balance = invoice_balances.get(account_id, 0.0) + total_balance = receipt_balance + invoice_balance + + if float_compare(abs(total_balance), 0.0, precision_rounding=precision_rounding) == 0: + continue + + stock_input_account = self.env['account.account'].browse(account_id) + _logger.info( + 'GRNI balance for account %s: receipt=%s, invoice=%s, total=%s', + stock_input_account.display_name, receipt_balance, invoice_balance, total_balance + ) + + amount = abs(total_balance) + if total_balance > 0: + # Debit balance in GRNI -> credit GRNI, debit expense + adjustment_lines.append({ + 'name': _('GRNI adjustment (Final Invoice %s)') % self.name, + 'account_id': price_adjustment_account.id, + 'debit': amount, + 'credit': 0.0, + 'partner_id': self.partner_id.id, + }) + adjustment_lines.append({ + 'name': _('GRNI adjustment (Final Invoice %s)') % self.name, + 'account_id': stock_input_account.id, + 'debit': 0.0, + 'credit': amount, + 'partner_id': self.partner_id.id, + }) + else: + # Credit balance in GRNI -> debit GRNI, credit expense + adjustment_lines.append({ + 'name': _('GRNI adjustment (Final Invoice %s)') % self.name, + 'account_id': stock_input_account.id, + 'debit': amount, + 'credit': 0.0, + 'partner_id': self.partner_id.id, + }) + adjustment_lines.append({ + 'name': _('GRNI adjustment (Final Invoice %s)') % self.name, + 'account_id': price_adjustment_account.id, + 'debit': 0.0, + 'credit': amount, + 'partner_id': self.partner_id.id, + }) + + # Create the adjustment entry if there are lines + if adjustment_lines: + if self.price_adjustment_move_id: + _logger.info('Removing existing adjustment entry before re-creating') + self._remove_price_adjustment_entry() + + _logger.info('Creating price adjustment entry with %d lines', len(adjustment_lines)) + adjustment_move_vals = { + 'move_type': 'entry', + 'journal_id': self.journal_id.id, + 'date': self.date, + 'ref': _('GRNI Adjustment - Final Invoice %s') % self.name, + 'line_ids': [(0, 0, line) for line in adjustment_lines], + } + + adjustment_move = self.env['account.move'].create(adjustment_move_vals) + adjustment_move.action_post() + + # Link adjustment entry to this invoice + self.with_context(skip_price_adjustment_sync=True).write({ + 'price_adjustment_move_id': adjustment_move.id, + }) + _logger.info('GRNI adjustment entry created and posted: %s', adjustment_move.name) + else: + _logger.info('No adjustment lines created - GRNI already balanced') + if self.price_adjustment_move_id: + _logger.info('Removing existing adjustment entry because GRNI is balanced') + self._remove_price_adjustment_entry() + + def _remove_price_adjustment_entry(self): + """Remove existing adjustment entry (created by this module).""" + self.ensure_one() + adjustment_move = self.price_adjustment_move_id + if not adjustment_move: + return + if adjustment_move.state == 'posted': + adjustment_move.button_draft() + adjustment_move.unlink() + self.with_context(skip_price_adjustment_sync=True).write({ + 'price_adjustment_move_id': False, + }) + + def write(self, vals): + res = super().write(vals) + if self.env.context.get('skip_price_adjustment_sync'): + return res + if 'is_final_invoice' in vals or 'purchase_id' in vals: + for move in self.filtered( + lambda m: m.state == 'posted' and m.move_type in ('in_invoice', 'in_refund', 'in_receipt') + ): + move._sync_price_adjustment_entry() + return res + + @api.model + def _prepare_invoice_vals_from_purchase(self, purchase_order, final_invoice=False): + """Prepare invoice values from purchase order with final_invoice flag""" + vals = purchase_order._prepare_invoice() + vals['is_final_invoice'] = final_invoice + return vals + + +class AccountMoveLine(models.Model): + _inherit = 'account.move.line' + + def _create_stock_valuation_layer(self): + """Override to prevent stock valuation layer creation from vendor bills""" + if self.move_id.move_type in ('in_invoice', 'in_refund', 'in_receipt'): + # Skip SVL creation for all vendor bills + return self.env['stock.valuation.layer'] + return super()._create_stock_valuation_layer() + + def _apply_price_difference(self): + """ + Block price difference SVL creation for vendor bills. + This method is triggered by stock_account during posting. + """ + non_vendor_lines = self.filtered( + lambda l: l.move_id.move_type not in ('in_invoice', 'in_refund', 'in_receipt') + ) + if not non_vendor_lines: + return self.env['stock.valuation.layer'], self.env['account.move.line'] + return super(AccountMoveLine, non_vendor_lines)._apply_price_difference() diff --git a/purchase_invoice_separate_valuation/models/purchase.py b/purchase_invoice_separate_valuation/models/purchase.py new file mode 100644 index 00000000..9431d150 --- /dev/null +++ b/purchase_invoice_separate_valuation/models/purchase.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +from odoo import api, fields, models +from odoo.tools.float_utils import float_is_zero + + +class PurchaseOrderLine(models.Model): + _inherit = 'purchase.order.line' + + @api.depends('invoice_lines.move_id.state', 'invoice_lines.quantity', 'qty_received', 'product_uom_qty', 'order_id.state') + def _compute_qty_invoiced(self): + """Override to prevent qty_invoiced update unless final_invoice flag is set""" + for line in self: + # compute qty_invoiced only from final invoices + qty = 0.0 + for inv_line in line._get_invoice_lines(): + if inv_line.move_id.state not in ['cancel'] or inv_line.move_id.payment_state == 'invoicing_legacy': + # Only count invoices marked as final_invoice + if inv_line.move_id.is_final_invoice: + if inv_line.move_id.move_type == 'in_invoice': + qty += inv_line.product_uom_id._compute_quantity(inv_line.quantity, line.product_uom) + elif inv_line.move_id.move_type == 'in_refund': + qty -= inv_line.product_uom_id._compute_quantity(inv_line.quantity, line.product_uom) + line.qty_invoiced = qty + + # compute qty_to_invoice + if line.order_id.state in ['purchase', 'done']: + if line.product_id.purchase_method == 'purchase': + line.qty_to_invoice = line.product_qty - line.qty_invoiced + else: + line.qty_to_invoice = line.qty_received - line.qty_invoiced + else: + line.qty_to_invoice = 0 \ No newline at end of file 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..77e436bb --- /dev/null +++ b/purchase_invoice_separate_valuation/models/res_config_settings.py @@ -0,0 +1,27 @@ +# -*- 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)]", + ) + + +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)]", + ) \ No newline at end of file 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..70efb54f --- /dev/null +++ b/purchase_invoice_separate_valuation/views/account_move_views.xml @@ -0,0 +1,27 @@ + + + + + account.move.form.inherit.final.invoice + account.move + + + + + + + + + + + + account.invoice.supplier.tree.inherit.final.invoice + account.move + + + + + + + + \ No newline at end of file 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..dd984fa7 --- /dev/null +++ b/purchase_invoice_separate_valuation/views/purchase_views.xml @@ -0,0 +1,46 @@ + + + + + purchase.order.form.inherit.invoice.wizard + purchase.order + + + + + + + + + + + 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

+
+
+
+
+
+
+
+
+
+
+
+
+ + + +