diff --git a/purchase_auto_bill_on_receipt/README.rst b/purchase_auto_bill_on_receipt/README.rst new file mode 100644 index 0000000..66568e1 --- /dev/null +++ b/purchase_auto_bill_on_receipt/README.rst @@ -0,0 +1,149 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +============================= +Purchase Auto Bill on Receipt +============================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:137338ab34ea871f820d22f70aaf33c322560ab59cfd7f7425b56ee6b4aacdf8 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fpurchase--workflow-lightgray.png?logo=github + :target: https://github.com/OCA/purchase-workflow/tree/19.0/purchase_auto_bill_on_receipt + :alt: OCA/purchase-workflow +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/purchase-workflow-19-0/purchase-workflow-19-0-purchase_auto_bill_on_receipt + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/purchase-workflow&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module automatically creates and posts a Vendor Bill when a +purchase receipt is validated. It also creates and posts a Credit Note +when a purchase return is validated, provided the return lines are +marked as *Update quantities on SO/PO* in the return wizard. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Whether a Purchase Order is auto-billed when its receipt is validated is +resolved from two configuration levels, plus a per-order kill switch: + +1. **Company** → *Settings > Purchase > Invoicing > Auto Bill on + Receipt* (boolean). Sets the default for all vendors. +2. **Vendor** → *Auto Bill on Receipt* (selection: *Auto* / *No Auto* / + empty), on the vendor form. Leave empty to inherit from the company. + *Auto* or *No Auto* overrides the company default for this vendor. +3. **Purchase Order** → *Block Auto Bill* (boolean, on the *Other + Information* tab). When ticked, suppresses auto-billing for this + order regardless of the company or vendor settings. + +The bill date uses the timezone set on the company's contact. To set it, +install the +`partner_tz `__ +module and configure the timezone on the company's contact. + +Usage +===== + +Once auto-billing is configured, validate a purchase receipt as usual. A +Vendor Bill is automatically created and posted shortly after via the +scheduled action. The bill only includes PO lines whose product bill +control policy is *On received quantities* and that have a positive +quantity to invoice. + +**Returns / Credit Notes:** When a return is validated and its return +lines have *Update quantities on SO/PO* enabled in the return wizard, a +Credit Note is automatically created and posted for the returned +quantities. The credit note is only created when a Vendor Bill has +already been posted (i.e. the PO line has a negative *Quantity to +Invoice*). + +The bill/credit note date is the receipt/return validation date +converted to the company's timezone (falls back to UTC when no timezone +is set). + +If creation or posting fails, the error is logged in the Purchase Order +chatter and a To-Do activity is scheduled for follow-up. + +**Case 1 — Cancelling an auto-bill:** An auto-posted bill can be +cancelled like any other Vendor Bill. However, the module will not +automatically create a new bill for the same receipt. + +**Case 2 — Reprocessing an auto-bill:** This is not supported. Once the +scheduled action processes a receipt or return, it will not be picked up +again. To bill or refund the same quantities, create the document +manually from the Purchase Order. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Quartile + +Contributors +------------ + +- `Quartile `__: + + - Aung Ko Ko Lin + - Yoshi Tashiro + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-yostashiro| image:: https://github.com/yostashiro.png?size=40px + :target: https://github.com/yostashiro + :alt: yostashiro +.. |maintainer-aungkokolin1997| image:: https://github.com/aungkokolin1997.png?size=40px + :target: https://github.com/aungkokolin1997 + :alt: aungkokolin1997 + +Current `maintainers `__: + +|maintainer-yostashiro| |maintainer-aungkokolin1997| + +This module is part of the `OCA/purchase-workflow `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/purchase_auto_bill_on_receipt/__init__.py b/purchase_auto_bill_on_receipt/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/purchase_auto_bill_on_receipt/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/purchase_auto_bill_on_receipt/__manifest__.py b/purchase_auto_bill_on_receipt/__manifest__.py new file mode 100644 index 0000000..1036ae4 --- /dev/null +++ b/purchase_auto_bill_on_receipt/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2026 Quartile +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) + +{ + "name": "Purchase Auto Bill on Receipt", + "summary": "Automatically create and post Vendor Bills when receipts are validated", + "version": "19.0.1.0.0", + "author": "Quartile, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/purchase-workflow", + "license": "AGPL-3", + "category": "Purchase Management", + "depends": ["purchase_stock"], + "data": [ + "data/ir_cron.xml", + "views/res_config_settings_views.xml", + "views/res_partner_views.xml", + "views/purchase_order_views.xml", + ], + "maintainers": ["yostashiro", "aungkokolin1997"], + "installable": True, +} diff --git a/purchase_auto_bill_on_receipt/data/ir_cron.xml b/purchase_auto_bill_on_receipt/data/ir_cron.xml new file mode 100644 index 0000000..84d03f0 --- /dev/null +++ b/purchase_auto_bill_on_receipt/data/ir_cron.xml @@ -0,0 +1,12 @@ + + + + Purchase: Auto Bill on Receipt + + code + model._cron_auto_bill() + 1 + days + + + diff --git a/purchase_auto_bill_on_receipt/i18n/ja.po b/purchase_auto_bill_on_receipt/i18n/ja.po new file mode 100644 index 0000000..fb0f405 --- /dev/null +++ b/purchase_auto_bill_on_receipt/i18n/ja.po @@ -0,0 +1,187 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * purchase_auto_bill_on_receipt +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ja_JP\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#. module: purchase_auto_bill_on_receipt +#. odoo-python +#: code:addons/purchase_auto_bill_on_receipt/models/purchase_order.py:0 +msgid "Auto %(doc)s creation failed on %(src)s %(picking)s: %(error)s" +msgstr "%(src)s %(picking)s での %(doc)s の自動作成に失敗しました: %(error)s" + +#. module: purchase_auto_bill_on_receipt +#. odoo-python +#: code:addons/purchase_auto_bill_on_receipt/models/purchase_order.py:0 +msgid "Auto %(doc)s creation failed — manual review needed" +msgstr "%(doc)s の自動作成に失敗しました — 手動確認が必要です" + +#. module: purchase_auto_bill_on_receipt +#. odoo-python +#: code:addons/purchase_auto_bill_on_receipt/models/purchase_order.py:0 +msgid "" +"Auto %(doc)s posting failed on %(src)s %(picking)s (%(doc)s %(bill)s remains" +" in draft): %(error)s" +msgstr "" +"%(src)s %(picking)s での %(doc)s の仕訳の自動作成に失敗しました (%(doc)s %(bill)s は下書きのままです): %(error)s" + +#. module: purchase_auto_bill_on_receipt +#. odoo-python +#: code:addons/purchase_auto_bill_on_receipt/models/purchase_order.py:0 +msgid "Auto %(doc)s posting failed — manual review needed" +msgstr "%(doc)s の仕訳の自動作成に失敗しました — 手動確認が必要です" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model.fields.selection,name:purchase_auto_bill_on_receipt.selection__res_partner__auto_bill_on_receipt__auto +msgid "Auto Bill" +msgstr "自動請求書" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_stock_picking__auto_bill_pending +msgid "Auto Bill Pending" +msgstr "自動請求書保留中" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_res_company__auto_bill_on_receipt +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_res_config_settings__auto_bill_on_receipt +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_res_partner__auto_bill_on_receipt +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_res_users__auto_bill_on_receipt +msgid "Auto Bill on Purchase Receipt" +msgstr "購買入荷時の自動請求書" + +#. module: purchase_auto_bill_on_receipt +#: model_terms:ir.ui.view,arch_db:purchase_auto_bill_on_receipt.res_config_settings_view_form +msgid "" +"Automatically create and post a Vendor Bill when a purchase receipt is " +"validated, and a Credit Note when a purchase return is validated. Can be " +"overridden per vendor." +msgstr "" +"購買入荷の確認時に仕入先請求書と仕訳を自動作成、購買返品の確認時にクレジットノートとその仕訳を自動作成します。仕入先ごとに上書き設定が可能です。" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_purchase_order__block_auto_bill +msgid "Block Auto Bill" +msgstr "自動請求書をブロック" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model,name:purchase_auto_bill_on_receipt.model_res_company +msgid "Companies" +msgstr "会社" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model,name:purchase_auto_bill_on_receipt.model_res_partner +msgid "Contact" +msgstr "連絡先" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_purchase_order__display_name +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_res_company__display_name +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_res_config_settings__display_name +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_res_partner__display_name +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_stock_picking__display_name +msgid "Display Name" +msgstr "表示名" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_purchase_order__id +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_res_company__id +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_res_config_settings__id +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_res_partner__id +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_stock_picking__id +msgid "ID" +msgstr "ID" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model.fields.selection,name:purchase_auto_bill_on_receipt.selection__res_partner__auto_bill_on_receipt__no_auto +msgid "No Auto Bill" +msgstr "自動請求書なし" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model.fields,help:purchase_auto_bill_on_receipt.field_res_partner__auto_bill_on_receipt +#: model:ir.model.fields,help:purchase_auto_bill_on_receipt.field_res_users__auto_bill_on_receipt +msgid "" +"Override the company default for auto-billing on purchase receipt. Leave " +"empty to inherit the company setting." +msgstr "" +"購買入荷時の自動請求書作成に関する会社のデフォルト設定を上書きします。会社の設定を継承するには空のままにしてください。" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model,name:purchase_auto_bill_on_receipt.model_res_config_settings +msgid "Procurement purchase grouping settings" +msgstr "調達購買グループ設定" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model,name:purchase_auto_bill_on_receipt.model_purchase_order +msgid "Purchase Order" +msgstr "購買オーダ" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.actions.server,name:purchase_auto_bill_on_receipt.ir_cron_auto_bill_ir_actions_server +msgid "Purchase: Auto Bill on Receipt" +msgstr "購買: 入荷時の自動請求書" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model.fields,help:purchase_auto_bill_on_receipt.field_stock_picking__auto_bill_pending +msgid "" +"Set when a validated receipt or return is queued for auto-billing. The " +"scheduled action picks it up to create and post the Vendor Bill or Credit " +"Note." +msgstr "" +"確認済みの入荷または返品が自動請求書作成のキューに追加されたときにセットされます。スケジュールアクションがこれを取得し、仕入先請求書またはクレジットノートとその仕訳を作成します。" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model,name:purchase_auto_bill_on_receipt.model_stock_picking +msgid "Transfer" +msgstr "転送" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model.fields,help:purchase_auto_bill_on_receipt.field_purchase_order__block_auto_bill +msgid "" +"When enabled, suppresses automatic bill creation on receipt for this order, " +"regardless of the company or vendor settings." +msgstr "" +"有効にすると、会社または仕入先の設定に関わらず、この注文の入荷時に自動請求書の作成を抑制します。" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model.fields,help:purchase_auto_bill_on_receipt.field_res_company__auto_bill_on_receipt +#: model:ir.model.fields,help:purchase_auto_bill_on_receipt.field_res_config_settings__auto_bill_on_receipt +msgid "" +"When enabled, validating an incoming purchase receipt automatically creates " +"and posts a Vendor Bill. This is the default for all vendors and can be " +"overridden per vendor." +msgstr "" +"有効にすると、仕入入荷の確認時に自動的に仕入先請求書と仕訳を作成します。これはすべての仕入先のデフォルト設定であり、仕入先ごとに上書きできます。" + +#. module: purchase_auto_bill_on_receipt +#. odoo-python +#: code:addons/purchase_auto_bill_on_receipt/models/purchase_order.py:0 +msgid "bill" +msgstr "請求書" + +#. module: purchase_auto_bill_on_receipt +#. odoo-python +#: code:addons/purchase_auto_bill_on_receipt/models/purchase_order.py:0 +msgid "credit note" +msgstr "クレジットノート" + +#. module: purchase_auto_bill_on_receipt +#. odoo-python +#: code:addons/purchase_auto_bill_on_receipt/models/purchase_order.py:0 +msgid "receipt" +msgstr "入荷" + +#. module: purchase_auto_bill_on_receipt +#. odoo-python +#: code:addons/purchase_auto_bill_on_receipt/models/purchase_order.py:0 +msgid "return" +msgstr "返品" diff --git a/purchase_auto_bill_on_receipt/i18n/purchase_auto_bill_on_receipt.pot b/purchase_auto_bill_on_receipt/i18n/purchase_auto_bill_on_receipt.pot new file mode 100644 index 0000000..e2384bc --- /dev/null +++ b/purchase_auto_bill_on_receipt/i18n/purchase_auto_bill_on_receipt.pot @@ -0,0 +1,180 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * purchase_auto_bill_on_receipt +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: purchase_auto_bill_on_receipt +#. odoo-python +#: code:addons/purchase_auto_bill_on_receipt/models/purchase_order.py:0 +msgid "Auto %(doc)s creation failed on %(src)s %(picking)s: %(error)s" +msgstr "" + +#. module: purchase_auto_bill_on_receipt +#. odoo-python +#: code:addons/purchase_auto_bill_on_receipt/models/purchase_order.py:0 +msgid "Auto %(doc)s creation failed — manual review needed" +msgstr "" + +#. module: purchase_auto_bill_on_receipt +#. odoo-python +#: code:addons/purchase_auto_bill_on_receipt/models/purchase_order.py:0 +msgid "" +"Auto %(doc)s posting failed on %(src)s %(picking)s (%(doc)s %(bill)s remains" +" in draft): %(error)s" +msgstr "" + +#. module: purchase_auto_bill_on_receipt +#. odoo-python +#: code:addons/purchase_auto_bill_on_receipt/models/purchase_order.py:0 +msgid "Auto %(doc)s posting failed — manual review needed" +msgstr "" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model.fields.selection,name:purchase_auto_bill_on_receipt.selection__res_partner__auto_bill_on_receipt__auto +msgid "Auto Bill" +msgstr "" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_stock_picking__auto_bill_pending +msgid "Auto Bill Pending" +msgstr "" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_res_company__auto_bill_on_receipt +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_res_config_settings__auto_bill_on_receipt +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_res_partner__auto_bill_on_receipt +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_res_users__auto_bill_on_receipt +msgid "Auto Bill on Purchase Receipt" +msgstr "" + +#. module: purchase_auto_bill_on_receipt +#: model_terms:ir.ui.view,arch_db:purchase_auto_bill_on_receipt.res_config_settings_view_form +msgid "" +"Automatically create and post a Vendor Bill when a purchase receipt is " +"validated, and a Credit Note when a purchase return is validated. Can be " +"overridden per vendor." +msgstr "" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_purchase_order__block_auto_bill +msgid "Block Auto Bill" +msgstr "" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model,name:purchase_auto_bill_on_receipt.model_res_company +msgid "Companies" +msgstr "" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model,name:purchase_auto_bill_on_receipt.model_res_partner +msgid "Contact" +msgstr "" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_purchase_order__display_name +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_res_company__display_name +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_res_config_settings__display_name +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_res_partner__display_name +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_stock_picking__display_name +msgid "Display Name" +msgstr "" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_purchase_order__id +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_res_company__id +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_res_config_settings__id +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_res_partner__id +#: model:ir.model.fields,field_description:purchase_auto_bill_on_receipt.field_stock_picking__id +msgid "ID" +msgstr "" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model.fields.selection,name:purchase_auto_bill_on_receipt.selection__res_partner__auto_bill_on_receipt__no_auto +msgid "No Auto Bill" +msgstr "" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model.fields,help:purchase_auto_bill_on_receipt.field_res_partner__auto_bill_on_receipt +#: model:ir.model.fields,help:purchase_auto_bill_on_receipt.field_res_users__auto_bill_on_receipt +msgid "" +"Override the company default for auto-billing on purchase receipt. Leave " +"empty to inherit the company setting." +msgstr "" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model,name:purchase_auto_bill_on_receipt.model_res_config_settings +msgid "Procurement purchase grouping settings" +msgstr "" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model,name:purchase_auto_bill_on_receipt.model_purchase_order +msgid "Purchase Order" +msgstr "" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.actions.server,name:purchase_auto_bill_on_receipt.ir_cron_auto_bill_ir_actions_server +msgid "Purchase: Auto Bill on Receipt" +msgstr "" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model.fields,help:purchase_auto_bill_on_receipt.field_stock_picking__auto_bill_pending +msgid "" +"Set when a validated receipt or return is queued for auto-billing. The " +"scheduled action picks it up to create and post the Vendor Bill or Credit " +"Note." +msgstr "" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model,name:purchase_auto_bill_on_receipt.model_stock_picking +msgid "Transfer" +msgstr "" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model.fields,help:purchase_auto_bill_on_receipt.field_purchase_order__block_auto_bill +msgid "" +"When enabled, suppresses automatic bill creation on receipt for this order, " +"regardless of the company or vendor settings." +msgstr "" + +#. module: purchase_auto_bill_on_receipt +#: model:ir.model.fields,help:purchase_auto_bill_on_receipt.field_res_company__auto_bill_on_receipt +#: model:ir.model.fields,help:purchase_auto_bill_on_receipt.field_res_config_settings__auto_bill_on_receipt +msgid "" +"When enabled, validating an incoming purchase receipt automatically creates " +"and posts a Vendor Bill. This is the default for all vendors and can be " +"overridden per vendor." +msgstr "" + +#. module: purchase_auto_bill_on_receipt +#. odoo-python +#: code:addons/purchase_auto_bill_on_receipt/models/purchase_order.py:0 +msgid "bill" +msgstr "" + +#. module: purchase_auto_bill_on_receipt +#. odoo-python +#: code:addons/purchase_auto_bill_on_receipt/models/purchase_order.py:0 +msgid "credit note" +msgstr "" + +#. module: purchase_auto_bill_on_receipt +#. odoo-python +#: code:addons/purchase_auto_bill_on_receipt/models/purchase_order.py:0 +msgid "receipt" +msgstr "" + +#. module: purchase_auto_bill_on_receipt +#. odoo-python +#: code:addons/purchase_auto_bill_on_receipt/models/purchase_order.py:0 +msgid "return" +msgstr "" diff --git a/purchase_auto_bill_on_receipt/models/__init__.py b/purchase_auto_bill_on_receipt/models/__init__.py new file mode 100644 index 0000000..e49b4f4 --- /dev/null +++ b/purchase_auto_bill_on_receipt/models/__init__.py @@ -0,0 +1,5 @@ +from . import res_company +from . import res_config_settings +from . import res_partner +from . import purchase_order +from . import stock_picking diff --git a/purchase_auto_bill_on_receipt/models/purchase_order.py b/purchase_auto_bill_on_receipt/models/purchase_order.py new file mode 100644 index 0000000..28ad646 --- /dev/null +++ b/purchase_auto_bill_on_receipt/models/purchase_order.py @@ -0,0 +1,170 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) + +import logging + +from odoo import Command, fields, models + +_logger = logging.getLogger(__name__) + + +class PurchaseOrder(models.Model): + _inherit = "purchase.order" + + block_auto_bill = fields.Boolean( + help="When enabled, suppresses automatic bill creation on receipt " + "for this order, regardless of the company or vendor settings.", + ) + + def _auto_bill_enabled(self): + self.ensure_one() + if self.block_auto_bill: + return False + partner_setting = self.partner_id.commercial_partner_id.auto_bill_on_receipt + if partner_setting == "auto": + return True + if partner_setting == "no_auto": + return False + return self.company_id.auto_bill_on_receipt + + def _auto_bill_log_failure( + self, picking, step, exception, bill=False, refund=False + ): + self.ensure_one() + doc = self.env._("credit note") if refund else self.env._("bill") + src = self.env._("return") if refund else self.env._("receipt") + if step == "create": + summary = self.env._( + "Auto %(doc)s creation failed — manual review needed", + doc=doc, + ) + body = self.env._( + "Auto %(doc)s creation failed on %(src)s %(picking)s: %(error)s", + doc=doc, + src=src, + picking=picking.name, + error=exception, + ) + else: + summary = self.env._( + "Auto %(doc)s posting failed — manual review needed", + doc=doc, + ) + body = self.env._( + "Auto %(doc)s posting failed on %(src)s %(picking)s " + "(%(doc)s %(bill)s remains in draft): %(error)s", + doc=doc, + src=src, + picking=picking.name, + bill=bill.display_name if bill else "", + error=exception, + ) + self.message_post(body=body, message_type="notification") + self.activity_schedule( + "mail.mail_activity_data_todo", + summary=summary, + user_id=(self.user_id or self.env.user).id, + ) + + def _get_picking_line_qty(self, picking, line, refund=False): + self.ensure_one() + qty = 0.0 + moves = picking.move_ids.filtered(lambda m: m.purchase_line_id == line) + if refund: + moves = moves.filtered("to_refund") + for move in moves: + qty += move.product_uom._compute_quantity( + move.quantity, line.product_uom_id + ) + return qty + + def _auto_bill_create(self, picking, eligible_lines, refund=False): + self.ensure_one() + move_type = "in_refund" if refund else "in_invoice" + invoice_vals = self.with_context(default_move_type=move_type)._prepare_invoice() + # Billing runs under the cron user, whose timezone is unreliable, so + # derive the bill date from the company partner's timezone instead. + tz = self.company_id.partner_id.tz or "UTC" + invoice_vals["invoice_date"] = fields.Date.context_today( + self.with_context(tz=tz), picking.date_done + ) + invoice_lines = [] + sequence = 10 + pending_section = None + for line in self.order_line: + if line.display_type in ("line_section", "line_subsection"): + pending_section = line + continue + if line not in eligible_lines: + continue + picking_qty = self._get_picking_line_qty(picking, line, refund=refund) + if refund: + qty = min(picking_qty, abs(line.qty_to_invoice)) + else: + qty = min(picking_qty, line.qty_to_invoice) + if qty <= 0: + continue + if pending_section: + section_vals = pending_section._prepare_account_move_line() + section_vals["sequence"] = sequence + invoice_lines.append(Command.create(section_vals)) + sequence += 1 + pending_section = None + line_vals = line._prepare_account_move_line() + line_vals["quantity"] = qty + line_vals["sequence"] = sequence + invoice_lines.append(Command.create(line_vals)) + sequence += 1 + invoice_vals["invoice_line_ids"] = invoice_lines + return ( + self.env["account.move"] + .with_company(self.company_id) + .with_context(default_move_type=move_type) + .create(invoice_vals) + ) + + def _is_return_picking(self, picking): + return picking.picking_type_code != "incoming" and any( + m.to_refund for m in picking.move_ids if m.purchase_line_id + ) + + def _auto_bill_for_picking(self, picking): + self.ensure_one() + refund = self._is_return_picking(picking) + moves = picking.move_ids + if refund: + moves = moves.filtered("to_refund") + eligible_lines = moves.purchase_line_id.filtered( + lambda line: line.product_id.purchase_method == "receive" + and (line.qty_to_invoice < 0 if refund else line.qty_to_invoice > 0) + ) + if not eligible_lines: + return self.env["account.move"] + try: + # Isolate the create in a savepoint: a database-level error aborts + # the cursor, and we must roll back to a clean state before the + # failure logging below can write to it. + with self.env.cr.savepoint(): + bill = self._auto_bill_create(picking, eligible_lines, refund=refund) + except Exception as e: + _logger.exception( + "Auto-%s creation failed for PO %s / picking %s", + "refund" if refund else "bill", + self.name, + picking.name, + ) + self._auto_bill_log_failure(picking, "create", e, refund=refund) + return self.env["account.move"] + try: + with self.env.cr.savepoint(): + bill.action_post() + except Exception as e: + _logger.exception( + "Auto-%s posting failed for PO %s / picking %s / %s", + "refund" if refund else "bill", + self.name, + picking.name, + bill.name, + ) + self._auto_bill_log_failure(picking, "post", e, bill=bill, refund=refund) + return bill diff --git a/purchase_auto_bill_on_receipt/models/res_company.py b/purchase_auto_bill_on_receipt/models/res_company.py new file mode 100644 index 0000000..57fccc6 --- /dev/null +++ b/purchase_auto_bill_on_receipt/models/res_company.py @@ -0,0 +1,15 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# 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" + + auto_bill_on_receipt = fields.Boolean( + string="Auto Bill on Purchase Receipt", + help="When enabled, validating an incoming purchase receipt " + "automatically creates and posts a Vendor Bill. This is the default " + "for all vendors and can be overridden per vendor.", + ) diff --git a/purchase_auto_bill_on_receipt/models/res_config_settings.py b/purchase_auto_bill_on_receipt/models/res_config_settings.py new file mode 100644 index 0000000..a6d260d --- /dev/null +++ b/purchase_auto_bill_on_receipt/models/res_config_settings.py @@ -0,0 +1,13 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# 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" + + auto_bill_on_receipt = fields.Boolean( + related="company_id.auto_bill_on_receipt", + readonly=False, + ) diff --git a/purchase_auto_bill_on_receipt/models/res_partner.py b/purchase_auto_bill_on_receipt/models/res_partner.py new file mode 100644 index 0000000..36eeb9a --- /dev/null +++ b/purchase_auto_bill_on_receipt/models/res_partner.py @@ -0,0 +1,18 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) + +from odoo import fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + auto_bill_on_receipt = fields.Selection( + selection=[ + ("auto", "Auto Bill"), + ("no_auto", "No Auto Bill"), + ], + string="Auto Bill on Purchase Receipt", + help="Override the company default for auto-billing on purchase " + "receipt. Leave empty to inherit the company setting.", + ) diff --git a/purchase_auto_bill_on_receipt/models/stock_picking.py b/purchase_auto_bill_on_receipt/models/stock_picking.py new file mode 100644 index 0000000..b80d540 --- /dev/null +++ b/purchase_auto_bill_on_receipt/models/stock_picking.py @@ -0,0 +1,53 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) + +import logging + +from odoo import fields, models + +_logger = logging.getLogger(__name__) + + +class StockPicking(models.Model): + _inherit = "stock.picking" + + auto_bill_pending = fields.Boolean( + copy=False, + help="Set when a validated receipt or return is queued for " + "auto-billing. The scheduled action picks it up to create and post " + "the Vendor Bill or Credit Note.", + ) + + def _auto_create_vendor_bill(self): + self.ensure_one() + self.auto_bill_pending = False + purchase = self.purchase_id + if not purchase or not purchase._auto_bill_enabled(): + return + purchase._auto_bill_for_picking(self) + + def _cron_auto_bill(self): + for picking in self.search([("auto_bill_pending", "=", True)]): + try: + with self.env.cr.savepoint(): + picking._auto_create_vendor_bill() + except Exception: + _logger.exception("Failed to auto-bill picking %s", picking.name) + + def button_validate(self): + res = super().button_validate() + to_process = self.filtered( + lambda p: p.state == "done" + and p.purchase_id + and p.purchase_id._auto_bill_enabled() + and ( + p.picking_type_code == "incoming" + or any(m.to_refund for m in p.move_ids if m.purchase_line_id) + ) + ) + if to_process: + to_process.auto_bill_pending = True + self.env.ref( + "purchase_auto_bill_on_receipt.ir_cron_auto_bill" + ).sudo()._trigger() + return res diff --git a/purchase_auto_bill_on_receipt/pyproject.toml b/purchase_auto_bill_on_receipt/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/purchase_auto_bill_on_receipt/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/purchase_auto_bill_on_receipt/readme/CONFIGURE.md b/purchase_auto_bill_on_receipt/readme/CONFIGURE.md new file mode 100644 index 0000000..4cd9eb8 --- /dev/null +++ b/purchase_auto_bill_on_receipt/readme/CONFIGURE.md @@ -0,0 +1,16 @@ +Whether a Purchase Order is auto-billed when its receipt is validated is +resolved from two configuration levels, plus a per-order kill switch: + +1. **Company** → *Settings > Purchase > Invoicing > Auto Bill on Receipt* + (boolean). Sets the default for all vendors. +2. **Vendor** → *Auto Bill on Receipt* (selection: *Auto* / *No Auto* / + empty), on the vendor form. Leave empty to inherit from the company. + *Auto* or *No Auto* overrides the company default for this vendor. +3. **Purchase Order** → *Block Auto Bill* (boolean, on the *Other + Information* tab). When ticked, suppresses auto-billing for this order + regardless of the company or vendor settings. + +The bill date uses the timezone set on the company's contact. To set it, +install the +[partner_tz](https://github.com/OCA/partner-contact/tree/19.0/partner_tz) +module and configure the timezone on the company's contact. diff --git a/purchase_auto_bill_on_receipt/readme/CONTRIBUTORS.md b/purchase_auto_bill_on_receipt/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..2721fe7 --- /dev/null +++ b/purchase_auto_bill_on_receipt/readme/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +- [Quartile](https://www.quartile.co): + - Aung Ko Ko Lin + - Yoshi Tashiro diff --git a/purchase_auto_bill_on_receipt/readme/DESCRIPTION.md b/purchase_auto_bill_on_receipt/readme/DESCRIPTION.md new file mode 100644 index 0000000..5dbb635 --- /dev/null +++ b/purchase_auto_bill_on_receipt/readme/DESCRIPTION.md @@ -0,0 +1,4 @@ +This module automatically creates and posts a Vendor Bill when a purchase +receipt is validated. It also creates and posts a Credit Note when a +purchase return is validated, provided the return lines are marked as +*Update quantities on SO/PO* in the return wizard. diff --git a/purchase_auto_bill_on_receipt/readme/USAGE.md b/purchase_auto_bill_on_receipt/readme/USAGE.md new file mode 100644 index 0000000..9051c75 --- /dev/null +++ b/purchase_auto_bill_on_receipt/readme/USAGE.md @@ -0,0 +1,27 @@ +Once auto-billing is configured, validate a purchase receipt as usual. +A Vendor Bill is automatically created and posted shortly after via the +scheduled action. The bill only includes PO lines whose product bill +control policy is *On received quantities* and that have a positive +quantity to invoice. + +**Returns / Credit Notes:** When a return is validated and its return +lines have *Update quantities on SO/PO* enabled in the return wizard, a +Credit Note is automatically created and posted for the returned +quantities. The credit note is only created when a Vendor Bill has +already been posted (i.e. the PO line has a negative *Quantity to +Invoice*). + +The bill/credit note date is the receipt/return validation date converted +to the company's timezone (falls back to UTC when no timezone is set). + +If creation or posting fails, the error is logged in the Purchase +Order chatter and a To-Do activity is scheduled for follow-up. + +**Case 1 — Cancelling an auto-bill:** An auto-posted bill can be +cancelled like any other Vendor Bill. However, the module will not +automatically create a new bill for the same receipt. + +**Case 2 — Reprocessing an auto-bill:** This is not supported. Once the +scheduled action processes a receipt or return, it will not be picked up +again. To bill or refund the same quantities, create the document +manually from the Purchase Order. diff --git a/purchase_auto_bill_on_receipt/static/description/index.html b/purchase_auto_bill_on_receipt/static/description/index.html new file mode 100644 index 0000000..a61caa5 --- /dev/null +++ b/purchase_auto_bill_on_receipt/static/description/index.html @@ -0,0 +1,485 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Purchase Auto Bill on Receipt

+ +

Beta License: AGPL-3 OCA/purchase-workflow Translate me on Weblate Try me on Runboat

+

This module automatically creates and posts a Vendor Bill when a +purchase receipt is validated. It also creates and posts a Credit Note +when a purchase return is validated, provided the return lines are +marked as Update quantities on SO/PO in the return wizard.

+

Table of contents

+ +
+

Configuration

+

Whether a Purchase Order is auto-billed when its receipt is validated is +resolved from two configuration levels, plus a per-order kill switch:

+
    +
  1. CompanySettings > Purchase > Invoicing > Auto Bill on +Receipt (boolean). Sets the default for all vendors.
  2. +
  3. VendorAuto Bill on Receipt (selection: Auto / No Auto / +empty), on the vendor form. Leave empty to inherit from the company. +Auto or No Auto overrides the company default for this vendor.
  4. +
  5. Purchase OrderBlock Auto Bill (boolean, on the Other +Information tab). When ticked, suppresses auto-billing for this +order regardless of the company or vendor settings.
  6. +
+

The bill date uses the timezone set on the company’s contact. To set it, +install the +partner_tz +module and configure the timezone on the company’s contact.

+
+
+

Usage

+

Once auto-billing is configured, validate a purchase receipt as usual. A +Vendor Bill is automatically created and posted shortly after via the +scheduled action. The bill only includes PO lines whose product bill +control policy is On received quantities and that have a positive +quantity to invoice.

+

Returns / Credit Notes: When a return is validated and its return +lines have Update quantities on SO/PO enabled in the return wizard, a +Credit Note is automatically created and posted for the returned +quantities. The credit note is only created when a Vendor Bill has +already been posted (i.e. the PO line has a negative Quantity to +Invoice).

+

The bill/credit note date is the receipt/return validation date +converted to the company’s timezone (falls back to UTC when no timezone +is set).

+

If creation or posting fails, the error is logged in the Purchase Order +chatter and a To-Do activity is scheduled for follow-up.

+

Case 1 — Cancelling an auto-bill: An auto-posted bill can be +cancelled like any other Vendor Bill. However, the module will not +automatically create a new bill for the same receipt.

+

Case 2 — Reprocessing an auto-bill: This is not supported. Once the +scheduled action processes a receipt or return, it will not be picked up +again. To bill or refund the same quantities, create the document +manually from the Purchase Order.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Quartile
  • +
+
+
+

Contributors

+
    +
  • Quartile:
      +
    • Aung Ko Ko Lin
    • +
    • Yoshi Tashiro
    • +
    +
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainers:

+

yostashiro aungkokolin1997

+

This module is part of the OCA/purchase-workflow project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/purchase_auto_bill_on_receipt/tests/__init__.py b/purchase_auto_bill_on_receipt/tests/__init__.py new file mode 100644 index 0000000..3e495b6 --- /dev/null +++ b/purchase_auto_bill_on_receipt/tests/__init__.py @@ -0,0 +1 @@ +from . import test_auto_bill_on_receipt diff --git a/purchase_auto_bill_on_receipt/tests/test_auto_bill_on_receipt.py b/purchase_auto_bill_on_receipt/tests/test_auto_bill_on_receipt.py new file mode 100644 index 0000000..4fda24b --- /dev/null +++ b/purchase_auto_bill_on_receipt/tests/test_auto_bill_on_receipt.py @@ -0,0 +1,393 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) + + +from unittest.mock import patch + +from odoo import Command, fields +from odoo.tests import tagged +from odoo.tools import mute_logger + +from odoo.addons.base.tests.common import BaseCommon + + +@tagged("post_install", "-at_install") +class TestAutoBillOnReceipt(BaseCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.warehouse = cls.env["stock.warehouse"].search( + [("company_id", "=", cls.company.id)], limit=1 + ) + cls.stock_location = cls.warehouse.lot_stock_id + cls.supplier_location = cls.env.ref("stock.stock_location_suppliers") + cls.picking_type_in = cls.warehouse.in_type_id + cls.uom_unit = cls.env.ref("uom.product_uom_unit") + + cls.vendor = cls.env["res.partner"].create({"name": "Test Vendor"}) + cls.vendor_auto = cls.env["res.partner"].create( + {"name": "Auto Vendor", "auto_bill_on_receipt": "auto"} + ) + cls.vendor_no_auto = cls.env["res.partner"].create( + {"name": "No-Auto Vendor", "auto_bill_on_receipt": "no_auto"} + ) + + cls.product = cls._create_product("Storable Product") + cls.product_billed_on_order = cls._create_product( + "Billed On Order Product", purchase_method="purchase" + ) + + @classmethod + def _create_product(cls, name, **vals): + return cls.env["product.product"].create( + { + "name": name, + "is_storable": True, + "standard_price": 10.0, + "list_price": 20.0, + "uom_id": cls.uom_unit.id, + **vals, + } + ) + + def _line(self, product, qty=1.0, price=10.0): + return Command.create( + { + "product_id": product.id, + "product_qty": qty, + "product_uom_id": product.uom_id.id, + "price_unit": price, + "tax_ids": [Command.clear()], + } + ) + + def _section(self, name): + return Command.create( + {"display_type": "line_section", "name": name, "product_qty": 0.0} + ) + + def _create_po(self, lines, partner=None, confirm=True): + po = self.env["purchase.order"].create( + {"partner_id": (partner or self.vendor).id, "order_line": lines} + ) + if confirm: + po.button_confirm() + return po + + def _run_auto_bill_cron(self): + # button_validate only triggers the cron; run it explicitly so the + # bills are created within the test transaction. + self.env["stock.picking"]._cron_auto_bill() + + def _receive(self, po, quantity=None, run_cron=True): + pickings = po.picking_ids.filtered(lambda p: p.state != "done") + if quantity is not None: + pickings.move_ids.quantity = quantity + pickings.with_context(skip_backorder=True).button_validate() + if run_cron: + self._run_auto_bill_cron() + return pickings + + def _validate(self, picking, quantity): + picking.move_ids.quantity = quantity + picking.with_context(skip_backorder=True).button_validate() + self._run_auto_bill_cron() + + def _return(self, picking, quantity=None, to_refund=True, run_cron=True): + """Create and validate a return for the given picking.""" + return_wizard = ( + self.env["stock.return.picking"] + .with_context(active_id=picking.id, active_model="stock.picking") + .create({}) + ) + for move_line in return_wizard.product_return_moves: + move_line.to_refund = to_refund + if quantity is not None: + move_line.quantity = quantity + action = return_wizard.action_create_returns() + return_picking = self.env["stock.picking"].browse(action["res_id"]) + if quantity is not None: + return_picking.move_ids.quantity = quantity + return_picking.with_context(skip_backorder=True).button_validate() + if run_cron: + self._run_auto_bill_cron() + return return_picking + + def _bills_of(self, po): + return po.invoice_ids.sorted("id") + + def test_company_default_creates_bill(self): + self.company.auto_bill_on_receipt = True + po = self._create_po([self._line(self.product, 5.0)]) + picking = self._receive(po) + bills = self._bills_of(po) + self.assertEqual(len(bills), 1) + self.assertEqual(bills.state, "posted") + self.assertEqual(bills.invoice_line_ids.quantity, 5.0) + self.assertEqual(bills.invoice_origin, po.name) + self.assertEqual(bills.invoice_date, picking.date_done.date()) + + def test_company_default_off_no_bill(self): + self.company.auto_bill_on_receipt = False + po = self._create_po([self._line(self.product, 5.0)]) + self._receive(po) + self.assertFalse(self._bills_of(po)) + + def test_vendor_override_auto_with_company_off(self): + self.company.auto_bill_on_receipt = False + po = self._create_po([self._line(self.product, 3.0)], partner=self.vendor_auto) + self._receive(po) + bills = self._bills_of(po) + self.assertEqual(len(bills), 1) + self.assertEqual(bills.state, "posted") + + def test_vendor_override_no_auto_with_company_on(self): + self.company.auto_bill_on_receipt = True + po = self._create_po( + [self._line(self.product, 2.0)], partner=self.vendor_no_auto + ) + self._receive(po) + self.assertFalse(self._bills_of(po)) + + def test_block_auto_bill_on_po(self): + self.company.auto_bill_on_receipt = True + po = self._create_po([self._line(self.product, 4.0)]) + po.block_auto_bill = True + self._receive(po) + self.assertFalse(self._bills_of(po)) + + def test_billed_on_ordered_qty_not_auto_billed(self): + self.company.auto_bill_on_receipt = True + po = self._create_po( + [ + self._line(self.product, 2.0), + self._line(self.product_billed_on_order, 3.0, price=20.0), + ] + ) + self._receive(po) + bills = self._bills_of(po) + self.assertEqual(len(bills), 1) + self.assertEqual(bills.state, "posted") + # Only the line billed on received quantities is invoiced. + self.assertEqual(bills.invoice_line_ids.product_id, self.product) + self.assertEqual(bills.invoice_line_ids.quantity, 2.0) + + def test_partial_receipt_creates_one_bill_per_receipt(self): + self.company.auto_bill_on_receipt = True + po = self._create_po([self._line(self.product, 10.0)]) + first_picking = po.picking_ids + self._validate(first_picking, 4.0) + bills = self._bills_of(po) + self.assertEqual(len(bills), 1) + self.assertEqual(bills.invoice_line_ids.quantity, 4.0) + self.assertEqual(bills.state, "posted") + + backorder = po.picking_ids - first_picking + self.assertEqual(len(backorder), 1) + self._validate(backorder, 6.0) + bills = self._bills_of(po) + self.assertEqual(len(bills), 2) + second_bill = bills - bills[0] + self.assertEqual(second_bill.invoice_line_ids.quantity, 6.0) + self.assertEqual(second_bill.state, "posted") + + def test_receipt_without_po_does_nothing(self): + self.company.auto_bill_on_receipt = True + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_in.id, + "location_id": self.supplier_location.id, + "location_dest_id": self.stock_location.id, + "move_ids": [ + Command.create( + { + "product_id": self.product.id, + "product_uom_qty": 1.0, + "product_uom": self.product.uom_id.id, + "location_id": self.supplier_location.id, + "location_dest_id": self.stock_location.id, + } + ) + ], + } + ) + picking.action_confirm() + picking.move_ids.quantity = 1.0 + picking.move_ids.picked = True + picking.button_validate() + self.assertFalse( + self.env["account.move"].search( + [ + ("move_type", "=", "in_invoice"), + ("invoice_origin", "like", picking.name), + ] + ) + ) + + def test_idempotent_on_second_call(self): + self.company.auto_bill_on_receipt = True + po = self._create_po([self._line(self.product, 5.0)]) + self._receive(po) + self.assertEqual(len(self._bills_of(po)), 1) + po.picking_ids._auto_create_vendor_bill() + self.assertEqual(len(self._bills_of(po)), 1) + + def test_sections_carried_over_only_when_needed(self): + self.company.auto_bill_on_receipt = True + po = self._create_po( + [ + self._section("Billed Section"), + self._line(self.product, 2.0), + self._section("Skipped Section"), + self._line(self.product_billed_on_order, 3.0, price=20.0), + ] + ) + self._receive(po) + bills = self._bills_of(po) + self.assertEqual(len(bills), 1) + + sections = bills.invoice_line_ids.filtered( + lambda line: line.display_type == "line_section" + ) + # Only the section preceding an eligible line is carried over. + self.assertEqual(sections.name, "Billed Section") + + product_lines = bills.invoice_line_ids.filtered( + lambda line: line.display_type == "product" + ) + self.assertEqual(product_lines.product_id, self.product) + # Section header keeps its place above its product line. + self.assertLess(sections.sequence, product_lines.sequence) + + def test_commercial_partner_setting_applies_to_contact(self): + # A PO placed on a child contact uses the vendor company's policy. + self.company.auto_bill_on_receipt = False + vendor = self.env["res.partner"].create( + {"name": "Vendor Co", "is_company": True, "auto_bill_on_receipt": "auto"} + ) + contact = self.env["res.partner"].create( + {"name": "Vendor Contact", "parent_id": vendor.id} + ) + po = self._create_po([self._line(self.product, 3.0)], partner=contact) + self._receive(po) + bills = self._bills_of(po) + self.assertEqual(len(bills), 1) + self.assertEqual(bills.state, "posted") + + def test_does_not_over_bill_when_partially_invoiced(self): + self.company.auto_bill_on_receipt = True + po = self._create_po([self._line(self.product, 10.0)]) + # Validate the full receipt but hold the cron so we can inject a + # manual bill before the auto-bill runs. + self._receive(po, run_cron=False) + # Manually bill 4 of the 10 received units. + po.action_create_invoice() + manual_bill = po.invoice_ids + manual_bill.invoice_line_ids.quantity = 4.0 + manual_bill.invoice_date = fields.Date.today() + manual_bill.action_post() + self.assertEqual(po.order_line.qty_to_invoice, 6.0) + # Now let the auto-bill cron run: it must bill only the remaining 6. + self._run_auto_bill_cron() + auto_bill = po.invoice_ids - manual_bill + self.assertEqual(len(auto_bill), 1) + self.assertEqual(auto_bill.invoice_line_ids.quantity, 6.0) + # The line is fully invoiced, not over-invoiced. + self.assertEqual(po.order_line.qty_invoiced, 10.0) + + @mute_logger( + "odoo.addons.purchase_auto_bill_on_receipt.models.purchase_order", + "odoo.addons.purchase_auto_bill_on_receipt.models.stock_picking", + ) + def test_creation_failure_logs_to_chatter(self): + self.company.auto_bill_on_receipt = True + po = self._create_po([self._line(self.product, 5.0)]) + self._receive(po, run_cron=False) + with patch.object( + type(po), + "_auto_bill_create", + side_effect=Exception("test creation error"), + ): + self._run_auto_bill_cron() + self.assertFalse(self._bills_of(po)) + self.assertIn("Auto bill creation failed", po.message_ids[0].body) + activity = po.activity_ids.filtered( + lambda a: a.activity_type_id == self.env.ref("mail.mail_activity_data_todo") + ) + self.assertTrue(activity) + + @mute_logger( + "odoo.addons.purchase_auto_bill_on_receipt.models.purchase_order", + "odoo.addons.purchase_auto_bill_on_receipt.models.stock_picking", + ) + def test_posting_failure_logs_to_chatter(self): + self.company.auto_bill_on_receipt = True + po = self._create_po([self._line(self.product, 5.0)]) + self._receive(po, run_cron=False) + with patch.object( + type(self.env["account.move"]), + "action_post", + side_effect=Exception("test posting error"), + ): + self._run_auto_bill_cron() + bills = self._bills_of(po) + self.assertEqual(len(bills), 1) + self.assertEqual(bills.state, "draft") + self.assertIn("Auto bill posting failed", po.message_ids[0].body) + activity = po.activity_ids.filtered( + lambda a: a.activity_type_id == self.env.ref("mail.mail_activity_data_todo") + ) + self.assertTrue(activity) + + # ── Return / Credit Note tests ── + + def test_return_with_to_refund_creates_credit_note(self): + self.company.auto_bill_on_receipt = True + po = self._create_po([self._line(self.product, 5.0)]) + picking = self._receive(po) + self.assertEqual(len(self._bills_of(po)), 1) + # Return 2 units with to_refund=True + self._return(picking, quantity=2.0, to_refund=True) + invoices = self._bills_of(po) + self.assertEqual(len(invoices), 2) + credit_note = invoices.filtered(lambda m: m.move_type == "in_refund") + self.assertEqual(len(credit_note), 1) + self.assertEqual(credit_note.state, "posted") + self.assertEqual(credit_note.invoice_line_ids.quantity, 2.0) + + def test_return_without_to_refund_no_credit_note(self): + self.company.auto_bill_on_receipt = True + po = self._create_po([self._line(self.product, 5.0)]) + picking = self._receive(po) + self._return(picking, quantity=2.0, to_refund=False) + invoices = self._bills_of(po) + # Only the original bill, no credit note. + self.assertEqual(len(invoices), 1) + self.assertEqual(invoices.move_type, "in_invoice") + + def test_return_auto_bill_disabled_no_credit_note(self): + self.company.auto_bill_on_receipt = False + po = self._create_po( + [self._line(self.product, 5.0)], partner=self.vendor_no_auto + ) + picking = self._receive(po) + self._return(picking, quantity=2.0, to_refund=True) + self.assertFalse(po.invoice_ids.filtered(lambda m: m.move_type == "in_refund")) + + @mute_logger( + "odoo.addons.purchase_auto_bill_on_receipt.models.purchase_order", + "odoo.addons.purchase_auto_bill_on_receipt.models.stock_picking", + ) + def test_return_creation_failure_logs_to_chatter(self): + self.company.auto_bill_on_receipt = True + po = self._create_po([self._line(self.product, 5.0)]) + picking = self._receive(po) + self._return(picking, quantity=2.0, run_cron=False) + with patch.object( + type(po), + "_auto_bill_create", + side_effect=Exception("test refund creation error"), + ): + self._run_auto_bill_cron() + self.assertFalse(po.invoice_ids.filtered(lambda m: m.move_type == "in_refund")) + self.assertIn("Auto credit note creation failed", po.message_ids[0].body) diff --git a/purchase_auto_bill_on_receipt/views/purchase_order_views.xml b/purchase_auto_bill_on_receipt/views/purchase_order_views.xml new file mode 100644 index 0000000..7390622 --- /dev/null +++ b/purchase_auto_bill_on_receipt/views/purchase_order_views.xml @@ -0,0 +1,12 @@ + + + + purchase.order + + + + + + + + diff --git a/purchase_auto_bill_on_receipt/views/res_config_settings_views.xml b/purchase_auto_bill_on_receipt/views/res_config_settings_views.xml new file mode 100644 index 0000000..5375408 --- /dev/null +++ b/purchase_auto_bill_on_receipt/views/res_config_settings_views.xml @@ -0,0 +1,21 @@ + + + + res.config.settings + + + + + + + + + + diff --git a/purchase_auto_bill_on_receipt/views/res_partner_views.xml b/purchase_auto_bill_on_receipt/views/res_partner_views.xml new file mode 100644 index 0000000..d021a45 --- /dev/null +++ b/purchase_auto_bill_on_receipt/views/res_partner_views.xml @@ -0,0 +1,16 @@ + + + + res.partner + + + + + + + + +