From 3751379f1e9a4c215fb6eb898b4ccc67659b9ace Mon Sep 17 00:00:00 2001 From: stephanchrst Date: Tue, 10 May 2022 21:51:50 +0700 Subject: initial commit 2 --- addons/purchase_stock/__init__.py | 17 + addons/purchase_stock/__manifest__.py | 37 + addons/purchase_stock/data/mail_data.xml | 32 + addons/purchase_stock/data/purchase_stock_data.xml | 22 + addons/purchase_stock/data/purchase_stock_demo.xml | 71 ++ addons/purchase_stock/i18n/ar.po | 803 ++++++++++++ addons/purchase_stock/i18n/az.po | 393 ++++++ addons/purchase_stock/i18n/bg.po | 809 ++++++++++++ addons/purchase_stock/i18n/bn.po | 778 ++++++++++++ addons/purchase_stock/i18n/bs.po | 399 ++++++ addons/purchase_stock/i18n/ca.po | 802 ++++++++++++ addons/purchase_stock/i18n/ckb.po | 777 ++++++++++++ addons/purchase_stock/i18n/cs.po | 828 ++++++++++++ addons/purchase_stock/i18n/da.po | 834 ++++++++++++ addons/purchase_stock/i18n/de.po | 817 ++++++++++++ addons/purchase_stock/i18n/el.po | 784 ++++++++++++ addons/purchase_stock/i18n/eo.po | 749 +++++++++++ addons/purchase_stock/i18n/es.po | 803 ++++++++++++ addons/purchase_stock/i18n/es_MX.po | 825 ++++++++++++ addons/purchase_stock/i18n/et.po | 829 ++++++++++++ addons/purchase_stock/i18n/eu.po | 789 ++++++++++++ addons/purchase_stock/i18n/fa.po | 789 ++++++++++++ addons/purchase_stock/i18n/fi.po | 791 ++++++++++++ addons/purchase_stock/i18n/fr.po | 837 ++++++++++++ addons/purchase_stock/i18n/gu.po | 394 ++++++ addons/purchase_stock/i18n/he.po | 802 ++++++++++++ addons/purchase_stock/i18n/hi.po | 773 ++++++++++++ addons/purchase_stock/i18n/hr.po | 797 ++++++++++++ addons/purchase_stock/i18n/hu.po | 809 ++++++++++++ addons/purchase_stock/i18n/id.po | 800 ++++++++++++ addons/purchase_stock/i18n/is.po | 399 ++++++ addons/purchase_stock/i18n/it.po | 817 ++++++++++++ addons/purchase_stock/i18n/ja.po | 798 ++++++++++++ addons/purchase_stock/i18n/ka.po | 782 ++++++++++++ addons/purchase_stock/i18n/km.po | 794 ++++++++++++ addons/purchase_stock/i18n/ko.po | 799 ++++++++++++ addons/purchase_stock/i18n/lb.po | 424 +++++++ addons/purchase_stock/i18n/lt.po | 797 ++++++++++++ addons/purchase_stock/i18n/lv.po | 786 ++++++++++++ addons/purchase_stock/i18n/mn.po | 811 ++++++++++++ addons/purchase_stock/i18n/nb.po | 791 ++++++++++++ addons/purchase_stock/i18n/nl.po | 832 ++++++++++++ addons/purchase_stock/i18n/pl.po | 791 ++++++++++++ addons/purchase_stock/i18n/pt.po | 791 ++++++++++++ addons/purchase_stock/i18n/pt_BR.po | 833 ++++++++++++ addons/purchase_stock/i18n/purchase_stock.pot | 773 ++++++++++++ addons/purchase_stock/i18n/ro.po | 829 ++++++++++++ addons/purchase_stock/i18n/ru.po | 801 ++++++++++++ addons/purchase_stock/i18n/si.po | 773 ++++++++++++ addons/purchase_stock/i18n/sk.po | 800 ++++++++++++ addons/purchase_stock/i18n/sl.po | 794 ++++++++++++ addons/purchase_stock/i18n/sr.po | 395 ++++++ addons/purchase_stock/i18n/sv.po | 785 ++++++++++++ addons/purchase_stock/i18n/th.po | 790 ++++++++++++ addons/purchase_stock/i18n/tr.po | 834 ++++++++++++ addons/purchase_stock/i18n/uk.po | 808 ++++++++++++ addons/purchase_stock/i18n/ur.po | 773 ++++++++++++ addons/purchase_stock/i18n/vi.po | 826 ++++++++++++ addons/purchase_stock/i18n/zh_CN.po | 804 ++++++++++++ addons/purchase_stock/i18n/zh_TW.po | 784 ++++++++++++ addons/purchase_stock/models/__init__.py | 11 + addons/purchase_stock/models/account_invoice.py | 198 +++ addons/purchase_stock/models/product.py | 71 ++ addons/purchase_stock/models/purchase.py | 559 ++++++++ addons/purchase_stock/models/res_company.py | 12 + .../purchase_stock/models/res_config_settings.py | 21 + addons/purchase_stock/models/res_partner.py | 43 + addons/purchase_stock/models/stock.py | 287 +++++ addons/purchase_stock/models/stock_rule.py | 325 +++++ addons/purchase_stock/report/__init__.py | 7 + addons/purchase_stock/report/purchase_report.py | 70 ++ .../report/purchase_report_templates.xml | 46 + .../report/purchase_report_views.xml | 15 + .../report/report_stock_forecasted.py | 32 + .../report/report_stock_forecasted.xml | 12 + addons/purchase_stock/report/report_stock_rule.py | 17 + addons/purchase_stock/report/report_stock_rule.xml | 32 + .../purchase_stock/report/vendor_delay_report.py | 87 ++ .../purchase_stock/report/vendor_delay_report.xml | 35 + addons/purchase_stock/security/ir.model.access.csv | 15 + .../static/src/js/tours/purchase_stock.js | 49 + addons/purchase_stock/tests/__init__.py | 20 + addons/purchase_stock/tests/common.py | 57 + .../test_anglo_saxon_valuation_reconciliation.py | 212 ++++ addons/purchase_stock/tests/test_average_price.py | 132 ++ addons/purchase_stock/tests/test_create_picking.py | 516 ++++++++ addons/purchase_stock/tests/test_fifo_price.py | 366 ++++++ addons/purchase_stock/tests/test_fifo_returns.py | 89 ++ .../tests/test_move_cancel_propagation.py | 293 +++++ .../purchase_stock/tests/test_onchange_product.py | 121 ++ .../purchase_stock/tests/test_product_template.py | 26 + .../tests/test_purchase_delete_order.py | 42 + .../tests/test_purchase_lead_time.py | 341 +++++ addons/purchase_stock/tests/test_purchase_order.py | 332 +++++ .../tests/test_purchase_order_process.py | 29 + .../tests/test_purchase_stock_report.py | 147 +++ .../purchase_stock/tests/test_reordering_rule.py | 520 ++++++++ .../purchase_stock/tests/test_replenish_wizard.py | 249 ++++ addons/purchase_stock/tests/test_routes.py | 53 + addons/purchase_stock/tests/test_stockvaluation.py | 1328 ++++++++++++++++++++ addons/purchase_stock/views/assets.xml | 8 + .../views/product_category_views.xml | 13 + addons/purchase_stock/views/purchase_views.xml | 72 ++ .../views/res_config_settings_views.xml | 78 ++ addons/purchase_stock/views/res_partner_views.xml | 30 + .../views/stock_production_lot_views.xml | 35 + addons/purchase_stock/views/stock_rule_views.xml | 17 + addons/purchase_stock/views/stock_views.xml | 40 + 108 files changed, 48914 insertions(+) create mode 100644 addons/purchase_stock/__init__.py create mode 100644 addons/purchase_stock/__manifest__.py create mode 100644 addons/purchase_stock/data/mail_data.xml create mode 100644 addons/purchase_stock/data/purchase_stock_data.xml create mode 100644 addons/purchase_stock/data/purchase_stock_demo.xml create mode 100644 addons/purchase_stock/i18n/ar.po create mode 100644 addons/purchase_stock/i18n/az.po create mode 100644 addons/purchase_stock/i18n/bg.po create mode 100644 addons/purchase_stock/i18n/bn.po create mode 100644 addons/purchase_stock/i18n/bs.po create mode 100644 addons/purchase_stock/i18n/ca.po create mode 100644 addons/purchase_stock/i18n/ckb.po create mode 100644 addons/purchase_stock/i18n/cs.po create mode 100644 addons/purchase_stock/i18n/da.po create mode 100644 addons/purchase_stock/i18n/de.po create mode 100644 addons/purchase_stock/i18n/el.po create mode 100644 addons/purchase_stock/i18n/eo.po create mode 100644 addons/purchase_stock/i18n/es.po create mode 100644 addons/purchase_stock/i18n/es_MX.po create mode 100644 addons/purchase_stock/i18n/et.po create mode 100644 addons/purchase_stock/i18n/eu.po create mode 100644 addons/purchase_stock/i18n/fa.po create mode 100644 addons/purchase_stock/i18n/fi.po create mode 100644 addons/purchase_stock/i18n/fr.po create mode 100644 addons/purchase_stock/i18n/gu.po create mode 100644 addons/purchase_stock/i18n/he.po create mode 100644 addons/purchase_stock/i18n/hi.po create mode 100644 addons/purchase_stock/i18n/hr.po create mode 100644 addons/purchase_stock/i18n/hu.po create mode 100644 addons/purchase_stock/i18n/id.po create mode 100644 addons/purchase_stock/i18n/is.po create mode 100644 addons/purchase_stock/i18n/it.po create mode 100644 addons/purchase_stock/i18n/ja.po create mode 100644 addons/purchase_stock/i18n/ka.po create mode 100644 addons/purchase_stock/i18n/km.po create mode 100644 addons/purchase_stock/i18n/ko.po create mode 100644 addons/purchase_stock/i18n/lb.po create mode 100644 addons/purchase_stock/i18n/lt.po create mode 100644 addons/purchase_stock/i18n/lv.po create mode 100644 addons/purchase_stock/i18n/mn.po create mode 100644 addons/purchase_stock/i18n/nb.po create mode 100644 addons/purchase_stock/i18n/nl.po create mode 100644 addons/purchase_stock/i18n/pl.po create mode 100644 addons/purchase_stock/i18n/pt.po create mode 100644 addons/purchase_stock/i18n/pt_BR.po create mode 100644 addons/purchase_stock/i18n/purchase_stock.pot create mode 100644 addons/purchase_stock/i18n/ro.po create mode 100644 addons/purchase_stock/i18n/ru.po create mode 100644 addons/purchase_stock/i18n/si.po create mode 100644 addons/purchase_stock/i18n/sk.po create mode 100644 addons/purchase_stock/i18n/sl.po create mode 100644 addons/purchase_stock/i18n/sr.po create mode 100644 addons/purchase_stock/i18n/sv.po create mode 100644 addons/purchase_stock/i18n/th.po create mode 100644 addons/purchase_stock/i18n/tr.po create mode 100644 addons/purchase_stock/i18n/uk.po create mode 100644 addons/purchase_stock/i18n/ur.po create mode 100644 addons/purchase_stock/i18n/vi.po create mode 100644 addons/purchase_stock/i18n/zh_CN.po create mode 100644 addons/purchase_stock/i18n/zh_TW.po create mode 100644 addons/purchase_stock/models/__init__.py create mode 100644 addons/purchase_stock/models/account_invoice.py create mode 100644 addons/purchase_stock/models/product.py create mode 100644 addons/purchase_stock/models/purchase.py create mode 100644 addons/purchase_stock/models/res_company.py create mode 100644 addons/purchase_stock/models/res_config_settings.py create mode 100644 addons/purchase_stock/models/res_partner.py create mode 100644 addons/purchase_stock/models/stock.py create mode 100644 addons/purchase_stock/models/stock_rule.py create mode 100644 addons/purchase_stock/report/__init__.py create mode 100644 addons/purchase_stock/report/purchase_report.py create mode 100644 addons/purchase_stock/report/purchase_report_templates.xml create mode 100644 addons/purchase_stock/report/purchase_report_views.xml create mode 100644 addons/purchase_stock/report/report_stock_forecasted.py create mode 100644 addons/purchase_stock/report/report_stock_forecasted.xml create mode 100644 addons/purchase_stock/report/report_stock_rule.py create mode 100644 addons/purchase_stock/report/report_stock_rule.xml create mode 100644 addons/purchase_stock/report/vendor_delay_report.py create mode 100644 addons/purchase_stock/report/vendor_delay_report.xml create mode 100644 addons/purchase_stock/security/ir.model.access.csv create mode 100644 addons/purchase_stock/static/src/js/tours/purchase_stock.js create mode 100644 addons/purchase_stock/tests/__init__.py create mode 100644 addons/purchase_stock/tests/common.py create mode 100644 addons/purchase_stock/tests/test_anglo_saxon_valuation_reconciliation.py create mode 100644 addons/purchase_stock/tests/test_average_price.py create mode 100644 addons/purchase_stock/tests/test_create_picking.py create mode 100644 addons/purchase_stock/tests/test_fifo_price.py create mode 100644 addons/purchase_stock/tests/test_fifo_returns.py create mode 100644 addons/purchase_stock/tests/test_move_cancel_propagation.py create mode 100644 addons/purchase_stock/tests/test_onchange_product.py create mode 100644 addons/purchase_stock/tests/test_product_template.py create mode 100644 addons/purchase_stock/tests/test_purchase_delete_order.py create mode 100644 addons/purchase_stock/tests/test_purchase_lead_time.py create mode 100644 addons/purchase_stock/tests/test_purchase_order.py create mode 100644 addons/purchase_stock/tests/test_purchase_order_process.py create mode 100644 addons/purchase_stock/tests/test_purchase_stock_report.py create mode 100644 addons/purchase_stock/tests/test_reordering_rule.py create mode 100644 addons/purchase_stock/tests/test_replenish_wizard.py create mode 100644 addons/purchase_stock/tests/test_routes.py create mode 100644 addons/purchase_stock/tests/test_stockvaluation.py create mode 100644 addons/purchase_stock/views/assets.xml create mode 100644 addons/purchase_stock/views/product_category_views.xml create mode 100644 addons/purchase_stock/views/purchase_views.xml create mode 100644 addons/purchase_stock/views/res_config_settings_views.xml create mode 100644 addons/purchase_stock/views/res_partner_views.xml create mode 100644 addons/purchase_stock/views/stock_production_lot_views.xml create mode 100644 addons/purchase_stock/views/stock_rule_views.xml create mode 100644 addons/purchase_stock/views/stock_views.xml (limited to 'addons/purchase_stock') diff --git a/addons/purchase_stock/__init__.py b/addons/purchase_stock/__init__.py new file mode 100644 index 00000000..849b2eb3 --- /dev/null +++ b/addons/purchase_stock/__init__.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models +from . import report + +from odoo import api, SUPERUSER_ID + + +def _create_buy_rules(cr, registry): + """ This hook is used to add a default buy_pull_id on every warehouse. It is + necessary if the purchase_stock module is installed after some warehouses + were already created. + """ + env = api.Environment(cr, SUPERUSER_ID, {}) + warehouse_ids = env['stock.warehouse'].search([('buy_pull_id', '=', False)]) + warehouse_ids.write({'buy_to_resupply': True}) diff --git a/addons/purchase_stock/__manifest__.py b/addons/purchase_stock/__manifest__.py new file mode 100644 index 00000000..56913800 --- /dev/null +++ b/addons/purchase_stock/__manifest__.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Purchase Stock', + 'version': '1.2', + 'category': 'Inventory/Purchase', + 'sequence': 60, + 'summary': 'Purchase Orders, Receipts, Vendor Bills for Stock', + 'description': "", + 'depends': ['stock_account', 'purchase'], + 'data': [ + 'security/ir.model.access.csv', + 'data/purchase_stock_data.xml', + 'data/mail_data.xml', + 'views/assets.xml', + 'report/vendor_delay_report.xml', + 'views/purchase_views.xml', + 'views/stock_views.xml', + 'views/stock_rule_views.xml', + 'views/res_config_settings_views.xml', + 'views/res_partner_views.xml', + 'views/stock_production_lot_views.xml', + 'views/product_category_views.xml', + 'report/purchase_report_views.xml', + 'report/purchase_report_templates.xml', + 'report/report_stock_forecasted.xml', + 'report/report_stock_rule.xml', + ], + 'demo': [ + 'data/purchase_stock_demo.xml', + ], + 'installable': True, + 'auto_install': True, + 'post_init_hook': '_create_buy_rules', + 'license': 'LGPL-3', +} diff --git a/addons/purchase_stock/data/mail_data.xml b/addons/purchase_stock/data/mail_data.xml new file mode 100644 index 00000000..849e04af --- /dev/null +++ b/addons/purchase_stock/data/mail_data.xml @@ -0,0 +1,32 @@ + + + + + + diff --git a/addons/purchase_stock/data/purchase_stock_data.xml b/addons/purchase_stock/data/purchase_stock_data.xml new file mode 100644 index 00000000..859d4cc1 --- /dev/null +++ b/addons/purchase_stock/data/purchase_stock_data.xml @@ -0,0 +1,22 @@ + + + + + + + + + Buy + + 5 + + + + + + + + + diff --git a/addons/purchase_stock/data/purchase_stock_demo.xml b/addons/purchase_stock/data/purchase_stock_demo.xml new file mode 100644 index 00000000..585514a6 --- /dev/null +++ b/addons/purchase_stock/data/purchase_stock_demo.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + draft + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/purchase_stock/i18n/ar.po b/addons/purchase_stock/i18n/ar.po new file mode 100644 index 00000000..453186f9 --- /dev/null +++ b/addons/purchase_stock/i18n/ar.po @@ -0,0 +1,803 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * purchase_stock +# +# Translators: +# Sherif Abd Ekmoniem , 2020 +# Mustafa Rawi , 2020 +# amrnegm , 2020 +# Martin Trigaux, 2020 +# Mahmoud M. Soliman , 2020 +# Osoul , 2020 +# Mohammed Albasha , 2020 +# Ghaith Gammar , 2020 +# Osama Ahmaro , 2020 +# Ahmed AL-Haddad , 2020 +# Dooreen AlMehdar , 2020 +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-11-27 14:12+0000\n" +"PO-Revision-Date: 2020-09-07 08:17+0000\n" +"Last-Translator: Dooreen AlMehdar , 2020\n" +"Language-Team: Arabic (https://www.transifex.com/odoo/teams/41243/ar/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Language: ar\n" +"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.purchase_order_view_form_inherit +msgid "% On-Time Delivery" +msgstr "" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "

Corresponding receipt not found.

" +msgstr "" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "" +"

Those dates couldn’t be modified accordingly on the receipt %s which had " +"already been validated.

" +msgstr "" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "

Those dates have been updated accordingly on the receipt %s.

" +msgstr "" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.purchase_order_view_form_inherit +msgid "" +"" + +#. module: purchase_stock +#: model:ir.model.fields,help:purchase_stock.field_purchase_order_line__qty_received_method +msgid "" +"According to product configuration, the received quantity can be automatically computed by mechanism :\n" +" - Manual: the quantity is set manually on the line\n" +" - Stock Moves: the quantity comes from confirmed pickings\n" +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_stock_rule__action +msgid "Action" +msgstr "Acció" + +#. module: purchase_stock +#: model:ir.model.fields,help:purchase_stock.field_purchase_report__avg_receipt_delay +msgid "" +"Amount of time between expected and effective receipt date. Due to a hack " +"needed to calculate this, every record will show the same " +"average value, therefore only use this as an aggregated value with " +"group_operator=avg" +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_report__avg_receipt_delay +msgid "Average Receipt Delay" +msgstr "" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/stock.py:0 +#: model:ir.model.fields.selection,name:purchase_stock.selection__stock_rule__action__buy +#: model:stock.location.route,name:purchase_stock.route_warehouse0_buy +#: model_terms:ir.ui.view,arch_db:purchase_stock.purchase_report_stock_rule +#, python-format +msgid "Buy" +msgstr "Compra" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_stock_warehouse__buy_pull_id +msgid "Buy rule" +msgstr "Regla de compra" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_stock_warehouse__buy_to_resupply +msgid "Buy to Resupply" +msgstr "Compra per subministrar" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_res_company +msgid "Companies" +msgstr "Empreses" + +#. module: purchase_stock +#: model:ir.model.fields,help:purchase_stock.field_purchase_order__effective_date +msgid "Completion date of the first receipt order." +msgstr "" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_res_config_settings +msgid "Config Settings" +msgstr "Configuració" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_res_partner +msgid "Contact" +msgstr "Contacte" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_stock_move__created_purchase_line_id +msgid "Created Purchase Order Line" +msgstr "Línia de comandes de compra creada" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order_line__product_description_variants +msgid "Custom Description" +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,help:purchase_stock.field_res_company__days_to_purchase +#: model:ir.model.fields,help:purchase_stock.field_res_config_settings__days_to_purchase +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_config_settings_view_form_stock +msgid "Days needed to confirm a PO, define when a PO should be validated" +msgstr "" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/stock_rule.py:0 +#: model:ir.model.fields,field_description:purchase_stock.field_res_company__days_to_purchase +#: model:ir.model.fields,field_description:purchase_stock.field_res_config_settings__days_to_purchase +#, python-format +msgid "Days to Purchase" +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order__picking_type_id +msgid "Deliver To" +msgstr "Entregar a" + +#. module: purchase_stock +#: model:ir.model.fields,help:purchase_stock.field_product_product__route_ids +#: model:ir.model.fields,help:purchase_stock.field_product_template__route_ids +msgid "" +"Depending on the modules installed, this will allow you to define the route " +"of the product: whether it will be bought, manufactured, replenished on " +"order, etc." +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order__default_location_dest_id_usage +msgid "Destination Location Type" +msgstr "Tipus d'ubicació de destí" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_account_move__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_product_product__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_product_template__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order_line__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_report__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_report_stock_report_product_product_replenishment__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_report_stock_report_stock_rule__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_res_company__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_res_config_settings__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_res_partner__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_stock_move__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_stock_picking__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_stock_production_lot__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_stock_return_picking__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_stock_rule__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_stock_warehouse__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_stock_warehouse_orderpoint__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_vendor_delay_report__display_name +msgid "Display Name" +msgstr "Nom mostrat" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_config_settings_view_form_purchase +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_config_settings_view_form_stock +msgid "Documentation" +msgstr "Documentació " + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order_line__move_dest_ids +msgid "Downstream Moves" +msgstr "Moviments avall" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.purchase_report_product_product_replenishment +msgid "Draft PO" +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_res_config_settings__module_stock_dropshipping +msgid "Dropshipping" +msgstr "Dropshipping" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order__effective_date +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_report__effective_date +#: model:ir.model.fields,field_description:purchase_stock.field_vendor_delay_report__date +msgid "Effective Date" +msgstr "Data efectiva" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.vendor_delay_report_filter +msgid "Effective Date Last Year" +msgstr "" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.exception_on_po +msgid "Exception(s) occurred on the purchase order(s):" +msgstr "Excepció(ns) ocorregudes en l'ordre(s) de compra" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.exception_on_po +msgid "Exception(s):" +msgstr "Excepció(ns):" + +#. module: purchase_stock +#. openerp-web +#: code:addons/purchase_stock/static/src/js/tours/purchase_stock.js:0 +#, python-format +msgid "Generate the draft vendor bill." +msgstr "" + +#. module: purchase_stock +#. openerp-web +#: code:addons/purchase_stock/static/src/js/tours/purchase_stock.js:0 +#, python-format +msgid "Go back to the purchase order to generate the vendor bill." +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_account_move__id +#: model:ir.model.fields,field_description:purchase_stock.field_product_product__id +#: model:ir.model.fields,field_description:purchase_stock.field_product_template__id +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order__id +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order_line__id +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_report__id +#: model:ir.model.fields,field_description:purchase_stock.field_report_stock_report_product_product_replenishment__id +#: model:ir.model.fields,field_description:purchase_stock.field_report_stock_report_stock_rule__id +#: model:ir.model.fields,field_description:purchase_stock.field_res_company__id +#: model:ir.model.fields,field_description:purchase_stock.field_res_config_settings__id +#: model:ir.model.fields,field_description:purchase_stock.field_res_partner__id +#: model:ir.model.fields,field_description:purchase_stock.field_stock_move__id +#: model:ir.model.fields,field_description:purchase_stock.field_stock_picking__id +#: model:ir.model.fields,field_description:purchase_stock.field_stock_production_lot__id +#: model:ir.model.fields,field_description:purchase_stock.field_stock_return_picking__id +#: model:ir.model.fields,field_description:purchase_stock.field_stock_rule__id +#: model:ir.model.fields,field_description:purchase_stock.field_stock_warehouse__id +#: model:ir.model.fields,field_description:purchase_stock.field_stock_warehouse_orderpoint__id +#: model:ir.model.fields,field_description:purchase_stock.field_vendor_delay_report__id +msgid "ID" +msgstr "ID" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.purchase_order_view_form_inherit +msgid "Incoming Shipments" +msgstr "Albarans d'entrada" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order__incoterm_id +msgid "Incoterm" +msgstr "Incoterm" + +#. module: purchase_stock +#: model:ir.model.fields,help:purchase_stock.field_purchase_order__incoterm_id +msgid "" +"International Commercial Terms are a series of predefined commercial terms " +"used in international transactions." +msgstr "" +"Els termes de comerç internacional són una sèrie de condicions comercials " +"utilitzades en les transaccions internacionals." + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order__is_shipped +msgid "Is Shipped" +msgstr "S'ha enviat" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_res_config_settings__is_installed_sale +msgid "Is the Sale Module Installed" +msgstr "Està instal·lat el mòdul de vendes. " + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_account_move +msgid "Journal Entry" +msgstr "Assentament comptable" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_account_move____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_product_product____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_product_template____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order_line____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_report____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_report_stock_report_product_product_replenishment____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_report_stock_report_stock_rule____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_res_company____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_res_config_settings____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_res_partner____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_stock_move____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_stock_picking____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_stock_production_lot____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_stock_return_picking____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_stock_rule____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_stock_warehouse____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_stock_warehouse_orderpoint____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_vendor_delay_report____last_update +msgid "Last Modified on" +msgstr "Última modificació el " + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_config_settings_view_form_purchase +msgid "Logistics" +msgstr "Logística" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_stock_production_lot +msgid "Lot/Serial" +msgstr "Lot/núm. de sèrie" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/stock_rule.py:0 +#, python-format +msgid "Manual Replenishment" +msgstr "" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.exception_on_po +msgid "Manual actions may be needed." +msgstr "Accions manuals poden ser requerides." + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_config_settings_view_form_stock +msgid "" +"Margin of error for vendor lead times. When the system generates Purchase " +"Orders for reordering products,they will be scheduled that many days earlier" +" to cope with unexpected vendor delays." +msgstr "" +"Marge d'error pels terminis d'entrega del venedor. Quan el sistema genera " +"comandes per productes, es programaran dies abans per fer front a retards " +"inesperats del venedor." + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_stock_warehouse_orderpoint +msgid "Minimum Inventory Rule" +msgstr "Regla d'inventari mínim" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_config_settings_view_form_stock +msgid "Move forward expected delivery dates by" +msgstr "Avançar la data d'entrega prevista" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.exception_on_po +msgid "Next transfer(s) impacted:" +msgstr "Properes transferències impactades: " + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_partner_view_purchase_buttons_inherit +msgid "No data yet" +msgstr "" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/account_invoice.py:0 +#, python-format +msgid "" +"Odoo is not able to generate the anglo saxon entries. The total valuation of" +" %s is zero." +msgstr "" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.vendor_delay_report_view_graph +msgid "On-Time Delivery" +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order__on_time_rate +#: model:ir.model.fields,field_description:purchase_stock.field_res_partner__on_time_rate +#: model:ir.model.fields,field_description:purchase_stock.field_res_users__on_time_rate +#: model:ir.model.fields,field_description:purchase_stock.field_vendor_delay_report__on_time_rate +msgid "On-Time Delivery Rate" +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_vendor_delay_report__qty_on_time +msgid "On-Time Quantity" +msgstr "" + +#. module: purchase_stock +#: model:ir.actions.act_window,name:purchase_stock.action_purchase_vendor_delay_report +#: model_terms:ir.ui.view,arch_db:purchase_stock.vendor_delay_report_filter +msgid "On-time Delivery" +msgstr "" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_partner_view_purchase_buttons_inherit +msgid "On-time Rate" +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order_line__orderpoint_id +msgid "Orderpoint" +msgstr "Punt de comanda" + +#. module: purchase_stock +#: model:ir.model.fields,help:purchase_stock.field_purchase_order__on_time_rate +#: model:ir.model.fields,help:purchase_stock.field_res_partner__on_time_rate +#: model:ir.model.fields,help:purchase_stock.field_res_users__on_time_rate +msgid "" +"Over the past 12 months; the number of products received on time divided by " +"the number of ordered products." +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order__picking_count +msgid "Picking count" +msgstr "Número de recollida" + +#. module: purchase_stock +#. openerp-web +#: code:addons/purchase_stock/static/src/js/tours/purchase_stock.js:0 +#, python-format +msgid "Process all the receipt quantities." +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order__group_id +msgid "Procurement Group" +msgstr "Grup de proveïment " + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_product_product +#: model:ir.model.fields,field_description:purchase_stock.field_vendor_delay_report__product_id +msgid "Product" +msgstr "Producte" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_vendor_delay_report__category_id +msgid "Product Category" +msgstr "Categoria del producte" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_product_template +msgid "Product Template" +msgstr "Plantilla de producte" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order_line__propagate_cancel +msgid "Propagate cancellation" +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_res_partner__purchase_line_ids +#: model:ir.model.fields,field_description:purchase_stock.field_res_users__purchase_line_ids +msgid "Purchase Lines" +msgstr "" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_purchase_order +msgid "Purchase Order" +msgstr "Comanda de compra" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_purchase_order_line +#: model:ir.model.fields,field_description:purchase_stock.field_product_product__purchase_order_line_ids +#: model:ir.model.fields,field_description:purchase_stock.field_stock_move__purchase_line_id +msgid "Purchase Order Line" +msgstr "Línia de la comanda de compra" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_stock_picking__purchase_id +#: model:ir.model.fields,field_description:purchase_stock.field_stock_production_lot__purchase_order_ids +#: model_terms:ir.ui.view,arch_db:purchase_stock.stock_production_lot_view_form +msgid "Purchase Orders" +msgstr "Comandes de compra" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_purchase_report +msgid "Purchase Report" +msgstr "Informe de compra" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/stock_rule.py:0 +#, python-format +msgid "Purchase Security Lead Time" +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_stock_production_lot__purchase_order_count +msgid "Purchase order count" +msgstr "Comptador d'ordres de compra" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.purchase_order_view_form_inherit +msgid "Receipt" +msgstr "Rebut" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.purchase_order_view_form_inherit +msgid "Receive Products" +msgstr "Rebre productes" + +#. module: purchase_stock +#. openerp-web +#: code:addons/purchase_stock/static/src/js/tours/purchase_stock.js:0 +#, python-format +msgid "Receive the ordered products." +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order_line__qty_received_method +msgid "Received Qty Method" +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order__picking_ids +msgid "Receptions" +msgstr "Recepcions" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/stock_rule.py:0 +#, python-format +msgid "Replenishment Report" +msgstr "" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_config_settings_view_form_purchase +msgid "Request your vendors to deliver to your customers" +msgstr "Sol·licita als venedors l'entrega als teus clients." + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order_line__move_ids +msgid "Reservation" +msgstr "Reserva" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_stock_return_picking +msgid "Return Picking" +msgstr "Retorna albarà" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_product_product__route_ids +#: model:ir.model.fields,field_description:purchase_stock.field_product_template__route_ids +msgid "Routes" +msgstr "Rutes" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_config_settings_view_form_stock +msgid "Schedule receivings earlier to avoid delays" +msgstr "Programa les recepcions abans per a evitar retards" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_stock_warehouse_orderpoint__show_supplier +msgid "Show supplier column" +msgstr "" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_stock_move +msgid "Stock Move" +msgstr "Moviment d'estoc" + +#. module: purchase_stock +#: model:ir.model.fields.selection,name:purchase_stock.selection__purchase_order_line__qty_received_method__stock_moves +#: model_terms:ir.ui.view,arch_db:purchase_stock.purchase_order_line_view_form_inherit +msgid "Stock Moves" +msgstr "Moviments d'estoc" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_report_stock_report_product_product_replenishment +msgid "Stock Replenishment Report" +msgstr "" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_stock_rule +msgid "Stock Rule" +msgstr "Regla d'estoc" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_report_stock_report_stock_rule +msgid "Stock rule report" +msgstr "Informe de norma d'estoc" + +#. module: purchase_stock +#: model:ir.model.fields,help:purchase_stock.field_purchase_order__default_location_dest_id_usage +msgid "Technical field used to display the Drop Ship Address" +msgstr "Camp tècnic utilitzat per mostrar l'adreça de Drop Ship" + +#. module: purchase_stock +#: model:ir.model.fields,help:purchase_stock.field_product_product__purchase_order_line_ids +msgid "Technical: used to compute quantities." +msgstr "Tècnic: utilitzat per calcular les quantitats." + +#. module: purchase_stock +#: code:addons/purchase_stock/models/stock.py:0 +#, python-format +msgid "The following replenishment order has been generated" +msgstr "" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "" +"The quantities on your purchase order indicate less than billed. You should " +"ask for a refund." +msgstr "" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/stock_rule.py:0 +#, python-format +msgid "" +"There is no matching vendor price to generate the purchase order for product" +" %s (no vendor defined, minimum quantity not reached, dates not valid, ...)." +" Go on the product form and complete the list of vendors." +msgstr "" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_config_settings_view_form_purchase +msgid "" +"This adds a dropshipping route to apply on products in order to request your" +" vendors to deliver to your customers. A product to dropship will generate a" +" purchase request for quotation once the sales order confirmed. This is a " +"on-demand flow. The requested delivery address will be the customer delivery" +" address and not your warehouse." +msgstr "" +"Això afegeix una ruta d'expedició a aplicar en productes amb l'objectiu de " +"demanar als teus proveïdors de realitzar entregues als teus clients. Un " +"producte destinat a la navegació generarà una sol·licitud pressupost de " +"compra un cop confirmada la comanda de venda. Es tracta d’un flux sota " +"demanda. L’adreça de lliurament sol·licitada serà l’adreça de lliurament del" +" client i no el vostre magatzem." + +#. module: purchase_stock +#: model:ir.model.fields,help:purchase_stock.field_purchase_order__picking_type_id +msgid "This will determine operation type of incoming shipment" +msgstr "Això determinarà el tipus d'operació de l'enviament en camí" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_vendor_delay_report__qty_total +msgid "Total Quantity" +msgstr "Quantitat total" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_stock_picking +msgid "Transfer" +msgstr "Transferència" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "" +"Unable to cancel purchase order %s as some receptions have already been " +"done." +msgstr "" +"No es pot cancel·lar la comanda de compra %s, ja que algunes recepcions ja " +"s'han completat." + +#. module: purchase_stock +#. openerp-web +#: code:addons/purchase_stock/static/src/js/tours/purchase_stock.js:0 +#, python-format +msgid "Validate the receipt of all ordered products." +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_stock_warehouse_orderpoint__supplier_id +#: model:ir.model.fields,field_description:purchase_stock.field_vendor_delay_report__partner_id +msgid "Vendor" +msgstr "Proveïdor " + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_vendor_delay_report +msgid "Vendor Delay Report" +msgstr "" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/stock_rule.py:0 +#, python-format +msgid "Vendor Lead Time" +msgstr "" + +#. module: purchase_stock +#: model_terms:ir.actions.act_window,help:purchase_stock.action_purchase_vendor_delay_report +msgid "Vendor On-time Delivery analysis" +msgstr "" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_stock_warehouse +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_report__picking_type_id +msgid "Warehouse" +msgstr "Magatzem" + +#. module: purchase_stock +#: model:ir.model.fields,help:purchase_stock.field_stock_warehouse__buy_to_resupply +msgid "When products are bought, they can be delivered to this warehouse" +msgstr "Quan es comprin productes, podran ser enviats a aquest magatzem." + +#. module: purchase_stock +#: code:addons/purchase_stock/models/stock_rule.py:0 +#, python-format +msgid "" +"When products are needed in %s,
a request for quotation is " +"created to fulfill the need." +msgstr "" +"Quan els productes són necessaris a %s,
es crea una sol·licitud " +"de pressupost per satisfer la necessitat." + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "" +"You cannot decrease the ordered quantity below the received quantity.\n" +"Create a return first." +msgstr "" +"No pots disminuir la quantitat encomanada per sota de la quantitat rebuda.\n" +"Crea primer una devolució." + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "You must set a Vendor Location for this partner %s" +msgstr "Ha d'assignar una localització de venedor per aquest soci %s" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/stock_rule.py:0 +#: code:addons/purchase_stock/models/stock_rule.py:0 +#: code:addons/purchase_stock/models/stock_rule.py:0 +#, python-format +msgid "day(s)" +msgstr "Dia/es" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_config_settings_view_form_stock +msgid "days" +msgstr "dies" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.exception_on_po +msgid "of" +msgstr "de" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.exception_on_po +msgid "ordered instead of" +msgstr "Encomanat en lloc de" diff --git a/addons/purchase_stock/i18n/ckb.po b/addons/purchase_stock/i18n/ckb.po new file mode 100644 index 00000000..2af602e9 --- /dev/null +++ b/addons/purchase_stock/i18n/ckb.po @@ -0,0 +1,777 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * purchase_stock +# +# Translators: +# Haval Abdulkarim , 2020 +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-11-27 14:12+0000\n" +"PO-Revision-Date: 2020-09-07 08:17+0000\n" +"Last-Translator: Haval Abdulkarim , 2020\n" +"Language-Team: Central Kurdish (https://www.transifex.com/odoo/teams/41243/ckb/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Language: ckb\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.purchase_order_view_form_inherit +msgid "% On-Time Delivery" +msgstr "" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "

Corresponding receipt not found.

" +msgstr "" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "" +"

Those dates couldn’t be modified accordingly on the receipt %s which had " +"already been validated.

" +msgstr "" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "

Those dates have been updated accordingly on the receipt %s.

" +msgstr "" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.purchase_order_view_form_inherit +msgid "" +"" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "

Those dates have been updated accordingly on the receipt %s.

" +msgstr "

Ces dates ont été correctement modifiées sur la réception %s.

" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.purchase_order_view_form_inherit +msgid "" +"" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_partner_view_purchase_buttons_inherit +msgid "On-time Rate" +msgstr "Taux On-Time" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.stock_production_lot_view_form +msgid "Purchases" +msgstr "Achats" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.report_purchaseorder_document +#: model_terms:ir.ui.view,arch_db:purchase_stock.report_purchasequotation_document +msgid "Incoterm:" +msgstr "Incoterm:" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.report_purchaseorder_document +#: model_terms:ir.ui.view,arch_db:purchase_stock.report_purchasequotation_document +msgid "Shipping address:" +msgstr "Adresse de livraison :" + +#. module: purchase_stock +#: model:ir.model.fields,help:purchase_stock.field_purchase_order_line__qty_received_method +msgid "" +"According to product configuration, the received quantity can be automatically computed by mechanism :\n" +" - Manual: the quantity is set manually on the line\n" +" - Stock Moves: the quantity comes from confirmed pickings\n" +msgstr "" +"Selon la configuration du produit, la quantité reçue peut être automatiquement calculée :\n" +" - Manuel: la quantité est indiquée manuellement sur la ligne\n" +" - Mouvements de stock: la quantité provient de transferts confirmés\n" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_stock_rule__action +msgid "Action" +msgstr "Action" + +#. module: purchase_stock +#: model:ir.model.fields,help:purchase_stock.field_purchase_report__avg_receipt_delay +msgid "" +"Amount of time between expected and effective receipt date. Due to a hack " +"needed to calculate this, every record will show the same " +"average value, therefore only use this as an aggregated value with " +"group_operator=avg" +msgstr "" +"Période entre la date prévue et effective. A cause d'un hack pour calculer " +"cela, chaque enregistrement montrera la même valeur moyenne, a " +"n'utiliser donc que comme une valeur agrégée ave group_operator=avg" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_report__avg_receipt_delay +msgid "Average Receipt Delay" +msgstr "Délai de réception moyen" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/stock.py:0 +#: model:ir.model.fields.selection,name:purchase_stock.selection__stock_rule__action__buy +#: model:stock.location.route,name:purchase_stock.route_warehouse0_buy +#: model_terms:ir.ui.view,arch_db:purchase_stock.purchase_report_stock_rule +#, python-format +msgid "Buy" +msgstr "Acheter" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_stock_warehouse__buy_pull_id +msgid "Buy rule" +msgstr "Règle d'achat" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_stock_warehouse__buy_to_resupply +msgid "Buy to Resupply" +msgstr "Acheter pour réapprovisionner" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_res_company +msgid "Companies" +msgstr "Sociétés" + +#. module: purchase_stock +#: model:ir.model.fields,help:purchase_stock.field_purchase_order__effective_date +msgid "Completion date of the first receipt order." +msgstr "Date de complétude de la première réception." + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_res_config_settings +msgid "Config Settings" +msgstr "Paramètres de configuration" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_res_partner +msgid "Contact" +msgstr "Contact" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_stock_move__created_purchase_line_id +msgid "Created Purchase Order Line" +msgstr "Ligne de commande créée" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order_line__product_description_variants +msgid "Custom Description" +msgstr "Description personnalisée" + +#. module: purchase_stock +#: model:ir.model.fields,help:purchase_stock.field_res_company__days_to_purchase +#: model:ir.model.fields,help:purchase_stock.field_res_config_settings__days_to_purchase +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_config_settings_view_form_stock +msgid "Days needed to confirm a PO, define when a PO should be validated" +msgstr "" +"Jours nécessaires pour confirmer une commande , définit quand une commande " +"devrait être validée" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/stock_rule.py:0 +#: model:ir.model.fields,field_description:purchase_stock.field_res_company__days_to_purchase +#: model:ir.model.fields,field_description:purchase_stock.field_res_config_settings__days_to_purchase +#, python-format +msgid "Days to Purchase" +msgstr "Jours pour Acheter" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order__picking_type_id +msgid "Deliver To" +msgstr "Livrer à" + +#. module: purchase_stock +#: model:ir.model.fields,help:purchase_stock.field_product_product__route_ids +#: model:ir.model.fields,help:purchase_stock.field_product_template__route_ids +msgid "" +"Depending on the modules installed, this will allow you to define the route " +"of the product: whether it will be bought, manufactured, replenished on " +"order, etc." +msgstr "" +"En fonction des modules installés, cela va vous permettre de définir les " +"routes sur l'article: acheter, fabriquer, réapprovisionner sur commande, " +"etc." + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order__default_location_dest_id_usage +msgid "Destination Location Type" +msgstr "Type d'emplacement de destination" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_account_move__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_product_product__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_product_template__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order_line__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_report__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_report_stock_report_product_product_replenishment__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_report_stock_report_stock_rule__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_res_company__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_res_config_settings__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_res_partner__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_stock_move__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_stock_picking__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_stock_production_lot__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_stock_return_picking__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_stock_rule__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_stock_warehouse__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_stock_warehouse_orderpoint__display_name +#: model:ir.model.fields,field_description:purchase_stock.field_vendor_delay_report__display_name +msgid "Display Name" +msgstr "Nom affiché" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_config_settings_view_form_purchase +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_config_settings_view_form_stock +msgid "Documentation" +msgstr "Documentation" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order_line__move_dest_ids +msgid "Downstream Moves" +msgstr "Mouvements en aval" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.purchase_report_product_product_replenishment +msgid "Draft PO" +msgstr "Ordre Brouillon" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_res_config_settings__module_stock_dropshipping +msgid "Dropshipping" +msgstr "Livraison directe" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order__effective_date +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_report__effective_date +#: model:ir.model.fields,field_description:purchase_stock.field_vendor_delay_report__date +msgid "Effective Date" +msgstr "Date effective" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.vendor_delay_report_filter +msgid "Effective Date Last Year" +msgstr "Date Effective Année Précédente" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.exception_on_po +msgid "Exception(s) occurred on the purchase order(s):" +msgstr "Des exceptions sont survenues sur le(s) bon(s) de commande:" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.exception_on_po +msgid "Exception(s):" +msgstr "Exception(s):" + +#. module: purchase_stock +#. openerp-web +#: code:addons/purchase_stock/static/src/js/tours/purchase_stock.js:0 +#, python-format +msgid "Generate the draft vendor bill." +msgstr "Générer la facture fournisseur en brouillon." + +#. module: purchase_stock +#. openerp-web +#: code:addons/purchase_stock/static/src/js/tours/purchase_stock.js:0 +#, python-format +msgid "Go back to the purchase order to generate the vendor bill." +msgstr "Retournez à la commande pour générer la facture fournisseur." + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_account_move__id +#: model:ir.model.fields,field_description:purchase_stock.field_product_product__id +#: model:ir.model.fields,field_description:purchase_stock.field_product_template__id +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order__id +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order_line__id +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_report__id +#: model:ir.model.fields,field_description:purchase_stock.field_report_stock_report_product_product_replenishment__id +#: model:ir.model.fields,field_description:purchase_stock.field_report_stock_report_stock_rule__id +#: model:ir.model.fields,field_description:purchase_stock.field_res_company__id +#: model:ir.model.fields,field_description:purchase_stock.field_res_config_settings__id +#: model:ir.model.fields,field_description:purchase_stock.field_res_partner__id +#: model:ir.model.fields,field_description:purchase_stock.field_stock_move__id +#: model:ir.model.fields,field_description:purchase_stock.field_stock_picking__id +#: model:ir.model.fields,field_description:purchase_stock.field_stock_production_lot__id +#: model:ir.model.fields,field_description:purchase_stock.field_stock_return_picking__id +#: model:ir.model.fields,field_description:purchase_stock.field_stock_rule__id +#: model:ir.model.fields,field_description:purchase_stock.field_stock_warehouse__id +#: model:ir.model.fields,field_description:purchase_stock.field_stock_warehouse_orderpoint__id +#: model:ir.model.fields,field_description:purchase_stock.field_vendor_delay_report__id +msgid "ID" +msgstr "ID" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.purchase_order_view_form_inherit +msgid "Incoming Shipments" +msgstr "Réceptions" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order__incoterm_id +msgid "Incoterm" +msgstr "Incoterm" + +#. module: purchase_stock +#: model:ir.model.fields,help:purchase_stock.field_purchase_order__incoterm_id +msgid "" +"International Commercial Terms are a series of predefined commercial terms " +"used in international transactions." +msgstr "" +"Les Incoterms sont une série prédéfinie de termes commerciaux utilisés dans " +"les transactions internationales." + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order__is_shipped +msgid "Is Shipped" +msgstr "Est expédié" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_res_config_settings__is_installed_sale +msgid "Is the Sale Module Installed" +msgstr "Le module Vente est-il installé ?" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_account_move +msgid "Journal Entry" +msgstr "Pièce comptable" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_account_move____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_product_product____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_product_template____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order_line____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_report____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_report_stock_report_product_product_replenishment____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_report_stock_report_stock_rule____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_res_company____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_res_config_settings____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_res_partner____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_stock_move____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_stock_picking____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_stock_production_lot____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_stock_return_picking____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_stock_rule____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_stock_warehouse____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_stock_warehouse_orderpoint____last_update +#: model:ir.model.fields,field_description:purchase_stock.field_vendor_delay_report____last_update +msgid "Last Modified on" +msgstr "Dernière modification le" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_config_settings_view_form_purchase +msgid "Logistics" +msgstr "Logistique" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_stock_production_lot +msgid "Lot/Serial" +msgstr "Lot/N° série" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/stock_rule.py:0 +#, python-format +msgid "Manual Replenishment" +msgstr "Réapprovisionnement manuel" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.exception_on_po +msgid "Manual actions may be needed." +msgstr "Des actions manuelles pourraient être requises. " + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_config_settings_view_form_stock +msgid "" +"Margin of error for vendor lead times. When the system generates Purchase " +"Orders for reordering products,they will be scheduled that many days earlier" +" to cope with unexpected vendor delays." +msgstr "" +"Marge d'erreur pour les délais des fournisseurs. Lorsque le système génère " +"des bons de commande de réapprovisionnement, ils sont programmés tant de " +"jours à l'avance pour faire face aux retards imprévus des fournisseurs." + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_stock_warehouse_orderpoint +msgid "Minimum Inventory Rule" +msgstr "Règle de stock minimum." + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_config_settings_view_form_stock +msgid "Move forward expected delivery dates by" +msgstr "Avancer les dates de livraison prévues de" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.exception_on_po +msgid "Next transfer(s) impacted:" +msgstr "Prochain(s) transfert(s) impacté(s):" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_partner_view_purchase_buttons_inherit +msgid "No data yet" +msgstr "Pas de données" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/account_invoice.py:0 +#, python-format +msgid "" +"Odoo is not able to generate the anglo saxon entries. The total valuation of" +" %s is zero." +msgstr "" +"Odoo n'est pas capable de générer les écritures anglo-saxonnes. La " +"valorisation totale de %s est zéro" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.vendor_delay_report_view_graph +msgid "On-Time Delivery" +msgstr "Livraison On-Time" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order__on_time_rate +#: model:ir.model.fields,field_description:purchase_stock.field_res_partner__on_time_rate +#: model:ir.model.fields,field_description:purchase_stock.field_res_users__on_time_rate +#: model:ir.model.fields,field_description:purchase_stock.field_vendor_delay_report__on_time_rate +msgid "On-Time Delivery Rate" +msgstr "Taux de Livraison On-Time" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_vendor_delay_report__qty_on_time +msgid "On-Time Quantity" +msgstr "Quantité On-Time" + +#. module: purchase_stock +#: model:ir.actions.act_window,name:purchase_stock.action_purchase_vendor_delay_report +#: model_terms:ir.ui.view,arch_db:purchase_stock.vendor_delay_report_filter +msgid "On-time Delivery" +msgstr "Livraison On-Time" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_partner_view_purchase_buttons_inherit +msgid "On-time Rate" +msgstr "Taux On-time" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order_line__orderpoint_id +msgid "Orderpoint" +msgstr "Point de commande" + +#. module: purchase_stock +#: model:ir.model.fields,help:purchase_stock.field_purchase_order__on_time_rate +#: model:ir.model.fields,help:purchase_stock.field_res_partner__on_time_rate +#: model:ir.model.fields,help:purchase_stock.field_res_users__on_time_rate +msgid "" +"Over the past 12 months; the number of products received on time divided by " +"the number of ordered products." +msgstr "" +"Au cours des 12 derniers mois; le nombre de produits reçus à temps divisé " +"par le nombre de produits commandés." + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order__picking_count +msgid "Picking count" +msgstr "Nombre des préparations" + +#. module: purchase_stock +#. openerp-web +#: code:addons/purchase_stock/static/src/js/tours/purchase_stock.js:0 +#, python-format +msgid "Process all the receipt quantities." +msgstr "Traiter toutes les quantités reçues" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order__group_id +msgid "Procurement Group" +msgstr "Groupe d'approvisionnement" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_product_product +#: model:ir.model.fields,field_description:purchase_stock.field_vendor_delay_report__product_id +msgid "Product" +msgstr "Article" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_vendor_delay_report__category_id +msgid "Product Category" +msgstr "Catégorie d'article" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_product_template +msgid "Product Template" +msgstr "Modèle d'article" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order_line__propagate_cancel +msgid "Propagate cancellation" +msgstr "Propager l'annulation" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_res_partner__purchase_line_ids +#: model:ir.model.fields,field_description:purchase_stock.field_res_users__purchase_line_ids +msgid "Purchase Lines" +msgstr "Lignes de Commande" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_purchase_order +msgid "Purchase Order" +msgstr "Commande fournisseur" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_purchase_order_line +#: model:ir.model.fields,field_description:purchase_stock.field_product_product__purchase_order_line_ids +#: model:ir.model.fields,field_description:purchase_stock.field_stock_move__purchase_line_id +msgid "Purchase Order Line" +msgstr "Ligne de commande d'achat" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_stock_picking__purchase_id +#: model:ir.model.fields,field_description:purchase_stock.field_stock_production_lot__purchase_order_ids +#: model_terms:ir.ui.view,arch_db:purchase_stock.stock_production_lot_view_form +msgid "Purchase Orders" +msgstr "Commandes fournisseur" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_purchase_report +msgid "Purchase Report" +msgstr "Rapport d'achat" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/stock_rule.py:0 +#, python-format +msgid "Purchase Security Lead Time" +msgstr "Délai de Sécurité Achats" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_stock_production_lot__purchase_order_count +msgid "Purchase order count" +msgstr "Nombre de bons de commande" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.purchase_order_view_form_inherit +msgid "Receipt" +msgstr "Reçu" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.purchase_order_view_form_inherit +msgid "Receive Products" +msgstr "Réception par article" + +#. module: purchase_stock +#. openerp-web +#: code:addons/purchase_stock/static/src/js/tours/purchase_stock.js:0 +#, python-format +msgid "Receive the ordered products." +msgstr "Recevoir les produits commandés." + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order_line__qty_received_method +msgid "Received Qty Method" +msgstr "Méthode Qté reçue" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order__picking_ids +msgid "Receptions" +msgstr "Réceptions" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/stock_rule.py:0 +#, python-format +msgid "Replenishment Report" +msgstr "Rapport de Réapprovisionnements" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_config_settings_view_form_purchase +msgid "Request your vendors to deliver to your customers" +msgstr "Demander à vos fournisseurs de livrer vos clients" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order_line__move_ids +msgid "Reservation" +msgstr "Réservation" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_stock_return_picking +msgid "Return Picking" +msgstr "Retour à la cueillette" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_product_product__route_ids +#: model:ir.model.fields,field_description:purchase_stock.field_product_template__route_ids +msgid "Routes" +msgstr "Routes" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_config_settings_view_form_stock +msgid "Schedule receivings earlier to avoid delays" +msgstr "Planifier les réceptions plus tôt pour éviter les délais" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_stock_warehouse_orderpoint__show_supplier +msgid "Show supplier column" +msgstr "Afficher colonne du fournisseur" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_stock_move +msgid "Stock Move" +msgstr "Stock déplacer" + +#. module: purchase_stock +#: model:ir.model.fields.selection,name:purchase_stock.selection__purchase_order_line__qty_received_method__stock_moves +#: model_terms:ir.ui.view,arch_db:purchase_stock.purchase_order_line_view_form_inherit +msgid "Stock Moves" +msgstr "Mouvements de stocks" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_report_stock_report_product_product_replenishment +msgid "Stock Replenishment Report" +msgstr "Rapport de Réapprovisionnements de Stock" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_stock_rule +msgid "Stock Rule" +msgstr "Règle de stock minimum" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_report_stock_report_stock_rule +msgid "Stock rule report" +msgstr "Rapport de règle de stock" + +#. module: purchase_stock +#: model:ir.model.fields,help:purchase_stock.field_purchase_order__default_location_dest_id_usage +msgid "Technical field used to display the Drop Ship Address" +msgstr "Champ technique utilisé pour afficher l'adresse de livraison directe." + +#. module: purchase_stock +#: model:ir.model.fields,help:purchase_stock.field_product_product__purchase_order_line_ids +msgid "Technical: used to compute quantities." +msgstr "Technique : utilisé pour calculer les quantités." + +#. module: purchase_stock +#: code:addons/purchase_stock/models/stock.py:0 +#, python-format +msgid "The following replenishment order has been generated" +msgstr "La commande de réapprovisionnement suivante a été générée" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "" +"The quantities on your purchase order indicate less than billed. You should " +"ask for a refund." +msgstr "" +"Les quantités sur votre bon de commande d'achat est moindre que la quantités" +" facturée. Vous devriez demander un remboursement." + +#. module: purchase_stock +#: code:addons/purchase_stock/models/stock_rule.py:0 +#, python-format +msgid "" +"There is no matching vendor price to generate the purchase order for product" +" %s (no vendor defined, minimum quantity not reached, dates not valid, ...)." +" Go on the product form and complete the list of vendors." +msgstr "" +"Il n'y a pas de prix fournisseur correspondant pour générer un bon d'achat " +"pour l'article %s (pas de fournsiseur défini, quantité minimum non " +"atteinte, dates invalides, ...). Aller sur la fiche produit et compléter la " +"liste des prix fournisseurs." + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_config_settings_view_form_purchase +msgid "" +"This adds a dropshipping route to apply on products in order to request your" +" vendors to deliver to your customers. A product to dropship will generate a" +" purchase request for quotation once the sales order confirmed. This is a " +"on-demand flow. The requested delivery address will be the customer delivery" +" address and not your warehouse." +msgstr "" +"Cela permet de demander une livraison directe des produits. Vos fournisseurs" +" livrent ainsi vos clients directement. Un produit en livraison directe " +"génère un appel d'offres d'achat une fois le bon de commande confirmé. Il " +"s'agit d'un flux à la demande. L'adresse de livraison demandée est celle de " +"votre client, et non de votre entrepôt." + +#. module: purchase_stock +#: model:ir.model.fields,help:purchase_stock.field_purchase_order__picking_type_id +msgid "This will determine operation type of incoming shipment" +msgstr "Cela déterminera le type d'opération des réceptions" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_vendor_delay_report__qty_total +msgid "Total Quantity" +msgstr "Quantité totale" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_stock_picking +msgid "Transfer" +msgstr "Transfert" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "" +"Unable to cancel purchase order %s as some receptions have already been " +"done." +msgstr "" +"Impossibilité d'annuler le bon de commande %s car des réceptions ont déjà " +"été effectuées. " + +#. module: purchase_stock +#. openerp-web +#: code:addons/purchase_stock/static/src/js/tours/purchase_stock.js:0 +#, python-format +msgid "Validate the receipt of all ordered products." +msgstr "Valider la réception pour tous les produits commandés." + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_stock_warehouse_orderpoint__supplier_id +#: model:ir.model.fields,field_description:purchase_stock.field_vendor_delay_report__partner_id +msgid "Vendor" +msgstr "Fournisseur" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_vendor_delay_report +msgid "Vendor Delay Report" +msgstr "Rapport des Retards Fournisseurs" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/stock_rule.py:0 +#, python-format +msgid "Vendor Lead Time" +msgstr "Délai Fournisseur" + +#. module: purchase_stock +#: model_terms:ir.actions.act_window,help:purchase_stock.action_purchase_vendor_delay_report +msgid "Vendor On-time Delivery analysis" +msgstr "Analyse Livraison On-Time Fournisseurs" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_stock_warehouse +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_report__picking_type_id +msgid "Warehouse" +msgstr "Entrepôt" + +#. module: purchase_stock +#: model:ir.model.fields,help:purchase_stock.field_stock_warehouse__buy_to_resupply +msgid "When products are bought, they can be delivered to this warehouse" +msgstr "Quand on achète des articles, ils peuvent être livrés à cet entrepôt" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/stock_rule.py:0 +#, python-format +msgid "" +"When products are needed in %s,
a request for quotation is " +"created to fulfill the need." +msgstr "" +"Quand des produits sont nécessaires en %s,
une demande de prix est " +"créée afin de pourvoir à ce besoin." + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "" +"You cannot decrease the ordered quantity below the received quantity.\n" +"Create a return first." +msgstr "" +"La quantité commandée ne peut pas être inférieure à la quantité reçue." + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "You must set a Vendor Location for this partner %s" +msgstr "Vous devez définir une adresse pour ce tiers, %s." + +#. module: purchase_stock +#: code:addons/purchase_stock/models/stock_rule.py:0 +#: code:addons/purchase_stock/models/stock_rule.py:0 +#: code:addons/purchase_stock/models/stock_rule.py:0 +#, python-format +msgid "day(s)" +msgstr "jour(s)" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_config_settings_view_form_stock +msgid "days" +msgstr "jours" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.exception_on_po +msgid "of" +msgstr "de" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.exception_on_po +msgid "ordered instead of" +msgstr "Commandé à la place de" diff --git a/addons/purchase_stock/i18n/gu.po b/addons/purchase_stock/i18n/gu.po new file mode 100644 index 00000000..f058a932 --- /dev/null +++ b/addons/purchase_stock/i18n/gu.po @@ -0,0 +1,394 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * purchase_stock +# +# Translators: +# Martin Trigaux, 2018 +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server saas~11.5\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2018-09-21 13:17+0000\n" +"PO-Revision-Date: 2018-09-21 13:17+0000\n" +"Last-Translator: Martin Trigaux, 2018\n" +"Language-Team: Gujarati (https://www.transifex.com/odoo/teams/41243/gu/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Language: gu\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.stock_production_lot_view_form +msgid "Purchase Orders" +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_stock_rule__action +msgid "Action" +msgstr "" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.purchase_report_stock_rule +#: model:stock.location.route,name:purchase_stock.route_warehouse0_buy +#: selection:stock.rule,action:0 +msgid "Buy" +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_stock_warehouse__buy_pull_id +msgid "Buy rule" +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_stock_warehouse__buy_to_resupply +msgid "Buy to Resupply" +msgstr "" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_res_config_settings +msgid "Config Settings" +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_stock_move__created_purchase_line_id +msgid "Created Purchase Order Line" +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order__picking_type_id +msgid "Deliver To" +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,help:purchase_stock.field_product_template__route_ids +msgid "" +"Depending on the modules installed, this will allow you to define the route " +"of the product: whether it will be bought, manufactured, MTO, etc." +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order__default_location_dest_id_usage +msgid "Destination Location Type" +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order_line__move_dest_ids +msgid "Downstream Moves" +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_res_config_settings__module_stock_dropshipping +msgid "Dropshipping" +msgstr "" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.exception_on_po +msgid "Exception(s) occurred on the purchase order(s):" +msgstr "" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.exception_on_po +msgid "Exception(s):" +msgstr "" + +#. module: purchase_stock +#: model:ir.ui.menu,name:purchase_stock.menu_action_picking_tree_in_move +msgid "Incoming Products" +msgstr "" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.purchase_order_view_form_inherit +msgid "Incoming Shipments" +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order__incoterm_id +msgid "Incoterm" +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,help:purchase_stock.field_purchase_order__incoterm_id +msgid "" +"International Commercial Terms are a series of predefined commercial terms " +"used in international transactions." +msgstr "" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_account_invoice +msgid "Invoice" +msgstr "બિલ" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order__is_shipped +msgid "Is Shipped" +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_res_config_settings__is_installed_sale +msgid "Is the Sale Module Installed" +msgstr "" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_config_settings_view_form_purchase +msgid "Logistics" +msgstr "" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_stock_production_lot +msgid "Lot/Serial" +msgstr "" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.exception_on_po +msgid "Manual actions may be needed." +msgstr "" + +#. module: purchase_stock +#: selection:stock.rule,action:0 +msgid "Manufacture" +msgstr "" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_config_settings_view_form_stock +msgid "" +"Margin of error for vendor lead times. When the system generates Purchase " +"Orders for reordering products,they will be scheduled that many days earlier" +" to cope with unexpected vendor delays." +msgstr "" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_stock_warehouse_orderpoint +msgid "Minimum Inventory Rule" +msgstr "" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_config_settings_view_form_stock +msgid "Move forward expected delivery dates by" +msgstr "" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.exception_on_po +msgid "Next transfer(s) impacted:" +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order_line__orderpoint_id +msgid "Orderpoint" +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order__picking_count +msgid "Picking count" +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order__group_id +msgid "Procurement Group" +msgstr "" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_product_template +msgid "Product Template" +msgstr "" + +#. module: purchase_stock +#: selection:stock.rule,action:0 +msgid "Pull & Push" +msgstr "" + +#. module: purchase_stock +#: selection:stock.rule,action:0 +msgid "Pull From" +msgstr "" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_purchase_order +msgid "Purchase Order" +msgstr "ખરીદી ઓર્ડર" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_purchase_order_line +#: model:ir.model.fields,field_description:purchase_stock.field_stock_move__purchase_line_id +msgid "Purchase Order Line" +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_stock_picking__purchase_id +#: model:ir.model.fields,field_description:purchase_stock.field_stock_production_lot__purchase_order_ids +#: model_terms:ir.ui.view,arch_db:purchase_stock.stock_production_lot_view_form +msgid "Purchase Orders" +msgstr "" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_purchase_report +msgid "Purchase Report" +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_stock_production_lot__purchase_order_count +msgid "Purchase order count" +msgstr "" + +#. module: purchase_stock +#: selection:stock.rule,action:0 +msgid "Push To" +msgstr "" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.purchase_order_view_form_inherit +msgid "Receipt" +msgstr "" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.purchase_order_view_form_inherit +msgid "Receive Products" +msgstr "" + +#. module: purchase_stock +#: model:ir.actions.act_window,name:purchase_stock.purchase_open_picking +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order__picking_ids +msgid "Receptions" +msgstr "" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_config_settings_view_form_purchase +msgid "Request your vendors to deliver to your customers" +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_order_line__move_ids +msgid "Reservation" +msgstr "" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_stock_return_picking +msgid "Return Picking" +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,field_description:purchase_stock.field_product_template__route_ids +msgid "Routes" +msgstr "" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_config_settings_view_form_stock +msgid "Schedule receivings earlier to avoid delays" +msgstr "" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_stock_move +msgid "Stock Move" +msgstr "" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.purchase_order_line_view_form_inherit +msgid "Stock Moves" +msgstr "" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_stock_rule +msgid "Stock Rule" +msgstr "" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_report_stock_report_stock_rule +msgid "Stock rule report" +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,help:purchase_stock.field_purchase_order__default_location_dest_id_usage +msgid "Technical field used to display the Drop Ship Address" +msgstr "" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:265 +#, python-format +msgid "" +"The quantities on your purchase order indicate less than billed. You should " +"ask for a refund. " +msgstr "" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/stock_rule.py:38 +#, python-format +msgid "" +"There is no vendor associated to the product %s. Please define a vendor for " +"this product." +msgstr "" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_config_settings_view_form_purchase +msgid "" +"This adds a dropshipping route to apply on products in order to request your" +" vendors to deliver to your customers. A product to dropship will generate a" +" purchase request for quotation once the sales order confirmed. This is a " +"on-demand flow. The requested delivery address will be the customer delivery" +" address and not your warehouse." +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,help:purchase_stock.field_purchase_order__picking_type_id +msgid "This will determine operation type of incoming shipment" +msgstr "" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_stock_picking +msgid "Transfer" +msgstr "" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:95 +#, python-format +msgid "" +"Unable to cancel purchase order %s as some receptions have already been " +"done." +msgstr "" + +#. module: purchase_stock +#: model:ir.model,name:purchase_stock.model_stock_warehouse +#: model:ir.model.fields,field_description:purchase_stock.field_purchase_report__picking_type_id +msgid "Warehouse" +msgstr "" + +#. module: purchase_stock +#: model:ir.model.fields,help:purchase_stock.field_stock_warehouse__buy_to_resupply +msgid "When products are bought, they can be delivered to this warehouse" +msgstr "" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/stock_rule.py:20 +#, python-format +msgid "" +"When products are needed in %s,
a request for quotation is " +"created to fulfill the need." +msgstr "" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:257 +#, python-format +msgid "" +"You cannot decrease the ordered quantity below the received quantity.\n" +"Create a return first." +msgstr "" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:186 +#, python-format +msgid "You must set a Vendor Location for this partner %s" +msgstr "" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_config_settings_view_form_stock +msgid "days" +msgstr "દિવસો" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.exception_on_po +msgid "of" +msgstr "" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.exception_on_po +msgid "ordered instead of" +msgstr "" diff --git a/addons/purchase_stock/i18n/he.po b/addons/purchase_stock/i18n/he.po new file mode 100644 index 00000000..ba75d2fb --- /dev/null +++ b/addons/purchase_stock/i18n/he.po @@ -0,0 +1,802 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * purchase_stock +# +# Translators: +# Martin Trigaux, 2020 +# ExcaliberX , 2020 +# Yihya Hugirat , 2020 +# hed shefetr , 2020 +# דודי מלכה , 2020 +# ZVI BLONDER , 2020 +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-11-27 14:12+0000\n" +"PO-Revision-Date: 2020-09-07 08:17+0000\n" +"Last-Translator: ZVI BLONDER , 2020\n" +"Language-Team: Hebrew (https://www.transifex.com/odoo/teams/41243/he/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Language: he\n" +"Plural-Forms: nplurals=4; plural=(n == 1 && n % 1 == 0) ? 0 : (n == 2 && n % 1 == 0) ? 1: (n % 10 == 0 && n % 1 == 0 && n > 10) ? 2 : 3;\n" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.purchase_order_view_form_inherit +msgid "% On-Time Delivery" +msgstr "" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "

Corresponding receipt not found.

" +msgstr "" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "" +"

Those dates couldn’t be modified accordingly on the receipt %s which had " +"already been validated.

" +msgstr "" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "

Those dates have been updated accordingly on the receipt %s.

" +msgstr "" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.purchase_order_view_form_inherit +msgid "" +"%s
,
a request for quotation is " +"created to fulfill the need." +msgstr "" +"כאשר מוצרים נדרשים ב %s,
בקשה להצעת מחיר נוצרת כדי למלא את " +"הצורך." + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "" +"You cannot decrease the ordered quantity below the received quantity.\n" +"Create a return first." +msgstr "" +"לא ניתן להקטין את הכמות המוזמנת מתחת לכמות שהתקבלה.\n" +"תחילה עליך ליצור החזרה." + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "You must set a Vendor Location for this partner %s" +msgstr "עליך להגדיר איתור ספק עבור ספק זה %s" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/stock_rule.py:0 +#: code:addons/purchase_stock/models/stock_rule.py:0 +#: code:addons/purchase_stock/models/stock_rule.py:0 +#, python-format +msgid "day(s)" +msgstr "ימים" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_config_settings_view_form_stock +msgid "days" +msgstr "ימים" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.exception_on_po +msgid "of" +msgstr "של" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.exception_on_po +msgid "ordered instead of" +msgstr "הוזמן במקום" diff --git a/addons/purchase_stock/i18n/hi.po b/addons/purchase_stock/i18n/hi.po new file mode 100644 index 00000000..a9f8ad0c --- /dev/null +++ b/addons/purchase_stock/i18n/hi.po @@ -0,0 +1,773 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * purchase_stock +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-11-27 14:12+0000\n" +"PO-Revision-Date: 2020-09-07 08:17+0000\n" +"Language-Team: Hindi (https://www.transifex.com/odoo/teams/41243/hi/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Language: hi\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.purchase_order_view_form_inherit +msgid "% On-Time Delivery" +msgstr "" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "

Corresponding receipt not found.

" +msgstr "" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "" +"

Those dates couldn’t be modified accordingly on the receipt %s which had " +"already been validated.

" +msgstr "" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "

Those dates have been updated accordingly on the receipt %s.

" +msgstr "" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.purchase_order_view_form_inherit +msgid "" +"%s,
a request for quotation is " +"created to fulfill the need." +msgstr "" +"Quando sono necessari prodotti in %s,
viene creata una " +"richiesta di preventivo per soddisfare l'esigenza." + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "" +"You cannot decrease the ordered quantity below the received quantity.\n" +"Create a return first." +msgstr "" +"Impossibile ridurre la quantità ordinata al di sotto della quantità ricevuta.\n" +"Creare prima un reso." + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "You must set a Vendor Location for this partner %s" +msgstr "Deve essere impostata una ubicazione fornitore per il partner %s" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/stock_rule.py:0 +#: code:addons/purchase_stock/models/stock_rule.py:0 +#: code:addons/purchase_stock/models/stock_rule.py:0 +#, python-format +msgid "day(s)" +msgstr "giorno/i" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_config_settings_view_form_stock +msgid "days" +msgstr "giorni" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.exception_on_po +msgid "of" +msgstr "di" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.exception_on_po +msgid "ordered instead of" +msgstr "ordinati al posto di" diff --git a/addons/purchase_stock/i18n/ja.po b/addons/purchase_stock/i18n/ja.po new file mode 100644 index 00000000..26ee3cfb --- /dev/null +++ b/addons/purchase_stock/i18n/ja.po @@ -0,0 +1,798 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * purchase_stock +# +# Translators: +# Shunho Kin , 2020 +# Martin Trigaux, 2020 +# Takahiro MURAKAMI , 2020 +# Norimichi Sugimoto , 2020 +# Yoon Nankyung , 2020 +# Yoshi Tashiro , 2020 +# Noma Yuki, 2020 +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-11-27 14:12+0000\n" +"PO-Revision-Date: 2020-09-07 08:17+0000\n" +"Last-Translator: Noma Yuki, 2020\n" +"Language-Team: Japanese (https://www.transifex.com/odoo/teams/41243/ja/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Language: ja\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.purchase_order_view_form_inherit +msgid "% On-Time Delivery" +msgstr "% 時間指定配送" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "

Corresponding receipt not found.

" +msgstr "

対応する領収書が見つかりません。

" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "" +"

Those dates couldn’t be modified accordingly on the receipt %s which had " +"already been validated.

" +msgstr "

これらの日付は、有効化された領収書%s に合わせて変更できませんでした。

" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "

Those dates have been updated accordingly on the receipt %s.

" +msgstr "

これらの日付は、領収書%sに応じて更新されました。

" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.purchase_order_view_form_inherit +msgid "" +"%s,
a request for quotation is " +"created to fulfill the need." +msgstr "" +" %s,
ürünlerine ihtiyaç duyulduğunda, ihtiyacı karşılamak için " +"bir teklif talebi yaratılır." + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "" +"You cannot decrease the ordered quantity below the received quantity.\n" +"Create a return first." +msgstr "" +"Sipariş edilen miktarı alınan miktarın altına indiremezsiniz. Önce bir dönüş" +" oluşturun." + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "You must set a Vendor Location for this partner %s" +msgstr "Bu iş ortağı %s için bir tedarikçi konumu belirlemelisiniz" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/stock_rule.py:0 +#: code:addons/purchase_stock/models/stock_rule.py:0 +#: code:addons/purchase_stock/models/stock_rule.py:0 +#, python-format +msgid "day(s)" +msgstr "Günler" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.res_config_settings_view_form_stock +msgid "days" +msgstr "gün" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.exception_on_po +msgid "of" +msgstr "ile ilgili" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.exception_on_po +msgid "ordered instead of" +msgstr "yerine sipariş edildi" diff --git a/addons/purchase_stock/i18n/uk.po b/addons/purchase_stock/i18n/uk.po new file mode 100644 index 00000000..8082cf60 --- /dev/null +++ b/addons/purchase_stock/i18n/uk.po @@ -0,0 +1,808 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * purchase_stock +# +# Translators: +# Martin Trigaux, 2020 +# ТАрас , 2020 +# Alina Lisnenko , 2020 +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-11-27 14:12+0000\n" +"PO-Revision-Date: 2020-09-07 08:17+0000\n" +"Last-Translator: Alina Lisnenko , 2020\n" +"Language-Team: Ukrainian (https://www.transifex.com/odoo/teams/41243/uk/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Language: uk\n" +"Plural-Forms: nplurals=4; plural=(n % 1 == 0 && n % 10 == 1 && n % 100 != 11 ? 0 : n % 1 == 0 && n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14) ? 1 : n % 1 == 0 && (n % 10 ==0 || (n % 10 >=5 && n % 10 <=9) || (n % 100 >=11 && n % 100 <=14 )) ? 2: 3);\n" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.purchase_order_view_form_inherit +msgid "% On-Time Delivery" +msgstr "" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "

Corresponding receipt not found.

" +msgstr "" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "" +"

Those dates couldn’t be modified accordingly on the receipt %s which had " +"already been validated.

" +msgstr "" + +#. module: purchase_stock +#: code:addons/purchase_stock/models/purchase.py:0 +#, python-format +msgid "

Those dates have been updated accordingly on the receipt %s.

" +msgstr "" + +#. module: purchase_stock +#: model_terms:ir.ui.view,arch_db:purchase_stock.purchase_order_view_form_inherit +msgid "" +" 0: + to_log[order_line] = (order_line.product_qty, pre_order_line_qty[order_line]) + if to_log: + order._log_decrease_ordered_quantity(to_log) + return res + + # -------------------------------------------------- + # Actions + # -------------------------------------------------- + + def button_approve(self, force=False): + result = super(PurchaseOrder, self).button_approve(force=force) + self._create_picking() + return result + + def button_cancel(self): + for order in self: + for move in order.order_line.mapped('move_ids'): + if move.state == 'done': + raise UserError(_('Unable to cancel purchase order %s as some receptions have already been done.') % (order.name)) + # If the product is MTO, change the procure_method of the closest move to purchase to MTS. + # The purpose is to link the po that the user will manually generate to the existing moves's chain. + if order.state in ('draft', 'sent', 'to approve', 'purchase'): + for order_line in order.order_line: + order_line.move_ids._action_cancel() + if order_line.move_dest_ids: + move_dest_ids = order_line.move_dest_ids + if order_line.propagate_cancel: + move_dest_ids._action_cancel() + else: + move_dest_ids.write({'procure_method': 'make_to_stock'}) + move_dest_ids._recompute_state() + + for pick in order.picking_ids.filtered(lambda r: r.state != 'cancel'): + pick.action_cancel() + + order.order_line.write({'move_dest_ids':[(5,0,0)]}) + + return super(PurchaseOrder, self).button_cancel() + + def action_view_picking(self): + """ This function returns an action that display existing picking orders of given purchase order ids. When only one found, show the picking immediately. + """ + result = self.env["ir.actions.actions"]._for_xml_id('stock.action_picking_tree_all') + # override the context to get rid of the default filtering on operation type + result['context'] = {'default_partner_id': self.partner_id.id, 'default_origin': self.name, 'default_picking_type_id': self.picking_type_id.id} + pick_ids = self.mapped('picking_ids') + # choose the view_mode accordingly + if not pick_ids or len(pick_ids) > 1: + result['domain'] = "[('id','in',%s)]" % (pick_ids.ids) + elif len(pick_ids) == 1: + res = self.env.ref('stock.view_picking_form', False) + form_view = [(res and res.id or False, 'form')] + if 'views' in result: + result['views'] = form_view + [(state,view) for state,view in result['views'] if view != 'form'] + else: + result['views'] = form_view + result['res_id'] = pick_ids.id + return result + + def _prepare_invoice(self): + invoice_vals = super()._prepare_invoice() + invoice_vals['invoice_incoterm_id'] = self.incoterm_id.id + return invoice_vals + + # -------------------------------------------------- + # Business methods + # -------------------------------------------------- + + def _log_decrease_ordered_quantity(self, purchase_order_lines_quantities): + + def _keys_in_sorted(move): + """ sort by picking and the responsible for the product the + move. + """ + return (move.picking_id.id, move.product_id.responsible_id.id) + + def _keys_in_groupby(move): + """ group by picking and the responsible for the product the + move. + """ + return (move.picking_id, move.product_id.responsible_id) + + def _render_note_exception_quantity_po(order_exceptions): + order_line_ids = self.env['purchase.order.line'].browse([order_line.id for order in order_exceptions.values() for order_line in order[0]]) + purchase_order_ids = order_line_ids.mapped('order_id') + move_ids = self.env['stock.move'].concat(*rendering_context.keys()) + impacted_pickings = move_ids.mapped('picking_id')._get_impacted_pickings(move_ids) - move_ids.mapped('picking_id') + values = { + 'purchase_order_ids': purchase_order_ids, + 'order_exceptions': order_exceptions.values(), + 'impacted_pickings': impacted_pickings, + } + return self.env.ref('purchase_stock.exception_on_po')._render(values=values) + + documents = self.env['stock.picking']._log_activity_get_documents(purchase_order_lines_quantities, 'move_ids', 'DOWN', _keys_in_sorted, _keys_in_groupby) + filtered_documents = {} + for (parent, responsible), rendering_context in documents.items(): + if parent._name == 'stock.picking': + if parent.state == 'cancel': + continue + filtered_documents[(parent, responsible)] = rendering_context + self.env['stock.picking']._log_activity(_render_note_exception_quantity_po, filtered_documents) + + def _get_destination_location(self): + self.ensure_one() + if self.dest_address_id: + return self.dest_address_id.property_stock_customer.id + return self.picking_type_id.default_location_dest_id.id + + @api.model + def _get_picking_type(self, company_id): + picking_type = self.env['stock.picking.type'].search([('code', '=', 'incoming'), ('warehouse_id.company_id', '=', company_id)]) + if not picking_type: + picking_type = self.env['stock.picking.type'].search([('code', '=', 'incoming'), ('warehouse_id', '=', False)]) + return picking_type[:1] + + def _prepare_picking(self): + if not self.group_id: + self.group_id = self.group_id.create({ + 'name': self.name, + 'partner_id': self.partner_id.id + }) + if not self.partner_id.property_stock_supplier.id: + raise UserError(_("You must set a Vendor Location for this partner %s", self.partner_id.name)) + return { + 'picking_type_id': self.picking_type_id.id, + 'partner_id': self.partner_id.id, + 'user_id': False, + 'date': self.date_order, + 'origin': self.name, + 'location_dest_id': self._get_destination_location(), + 'location_id': self.partner_id.property_stock_supplier.id, + 'company_id': self.company_id.id, + } + + def _create_picking(self): + StockPicking = self.env['stock.picking'] + for order in self.filtered(lambda po: po.state in ('purchase', 'done')): + if any(product.type in ['product', 'consu'] for product in order.order_line.product_id): + order = order.with_company(order.company_id) + pickings = order.picking_ids.filtered(lambda x: x.state not in ('done', 'cancel')) + if not pickings: + res = order._prepare_picking() + picking = StockPicking.with_user(SUPERUSER_ID).create(res) + else: + picking = pickings[0] + moves = order.order_line._create_stock_moves(picking) + moves = moves.filtered(lambda x: x.state not in ('done', 'cancel'))._action_confirm() + seq = 0 + for move in sorted(moves, key=lambda move: move.date): + seq += 5 + move.sequence = seq + moves._action_assign() + picking.message_post_with_view('mail.message_origin_link', + values={'self': picking, 'origin': order}, + subtype_id=self.env.ref('mail.mt_note').id) + return True + + def _add_picking_info(self, activity): + """Helper method to add picking info to the Date Updated activity when + vender updates date_planned of the po lines. + """ + validated_picking = self.picking_ids.filtered(lambda p: p.state == 'done') + if validated_picking: + activity.note += _("

Those dates couldn’t be modified accordingly on the receipt %s which had already been validated.

") % validated_picking[0].name + elif not self.picking_ids: + activity.note += _("

Corresponding receipt not found.

") + else: + activity.note += _("

Those dates have been updated accordingly on the receipt %s.

") % self.picking_ids[0].name + + def _create_update_date_activity(self, updated_dates): + activity = super()._create_update_date_activity(updated_dates) + self._add_picking_info(activity) + + def _update_update_date_activity(self, updated_dates, activity): + # remove old picking info to update it + note_lines = activity.note.split('

') + note_lines.pop() + activity.note = '

'.join(note_lines) + super()._update_update_date_activity(updated_dates, activity) + self._add_picking_info(activity) + + @api.model + def _get_orders_to_remind(self): + """When auto sending reminder mails, don't send for purchase order with + validated receipts.""" + return super()._get_orders_to_remind().filtered(lambda p: not p.effective_date) + + +class PurchaseOrderLine(models.Model): + _inherit = 'purchase.order.line' + + qty_received_method = fields.Selection(selection_add=[('stock_moves', 'Stock Moves')]) + + move_ids = fields.One2many('stock.move', 'purchase_line_id', string='Reservation', readonly=True, copy=False) + orderpoint_id = fields.Many2one('stock.warehouse.orderpoint', 'Orderpoint') + move_dest_ids = fields.One2many('stock.move', 'created_purchase_line_id', 'Downstream Moves') + product_description_variants = fields.Char('Custom Description') + propagate_cancel = fields.Boolean('Propagate cancellation', default=True) + + def _compute_qty_received_method(self): + super(PurchaseOrderLine, self)._compute_qty_received_method() + for line in self.filtered(lambda l: not l.display_type): + if line.product_id.type in ['consu', 'product']: + line.qty_received_method = 'stock_moves' + + @api.depends('move_ids.state', 'move_ids.product_uom_qty', 'move_ids.product_uom') + def _compute_qty_received(self): + super(PurchaseOrderLine, self)._compute_qty_received() + for line in self: + if line.qty_received_method == 'stock_moves': + total = 0.0 + # In case of a BOM in kit, the products delivered do not correspond to the products in + # the PO. Therefore, we can skip them since they will be handled later on. + for move in line.move_ids.filtered(lambda m: m.product_id == line.product_id): + if move.state == 'done': + if move.location_dest_id.usage == "supplier": + if move.to_refund: + total -= move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom) + elif move.origin_returned_move_id and move.origin_returned_move_id._is_dropshipped() and not move._is_dropshipped_returned(): + # Edge case: the dropship is returned to the stock, no to the supplier. + # In this case, the received quantity on the PO is set although we didn't + # receive the product physically in our stock. To avoid counting the + # quantity twice, we do nothing. + pass + elif ( + move.location_dest_id.usage == "internal" + and move.to_refund + and move.location_dest_id + not in self.env["stock.location"].search( + [("id", "child_of", move.warehouse_id.view_location_id.id)] + ) + ): + total -= move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom) + else: + total += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom) + line._track_qty_received(total) + line.qty_received = total + + @api.model_create_multi + def create(self, vals_list): + lines = super(PurchaseOrderLine, self).create(vals_list) + lines.filtered(lambda l: l.order_id.state == 'purchase')._create_or_update_picking() + return lines + + def write(self, values): + for line in self.filtered(lambda l: not l.display_type): + # PO date_planned overrides any PO line date_planned values + if values.get('date_planned'): + new_date = fields.Datetime.to_datetime(values['date_planned']) + self._update_move_date_deadline(new_date) + result = super(PurchaseOrderLine, self).write(values) + if 'product_qty' in values: + self.filtered(lambda l: l.order_id.state == 'purchase')._create_or_update_picking() + return result + + def unlink(self): + self.move_ids._action_cancel() + + ppg_cancel_lines = self.filtered(lambda line: line.propagate_cancel) + ppg_cancel_lines.move_dest_ids._action_cancel() + + not_ppg_cancel_lines = self.filtered(lambda line: not line.propagate_cancel) + not_ppg_cancel_lines.move_dest_ids.write({'procure_method': 'make_to_stock'}) + not_ppg_cancel_lines.move_dest_ids._recompute_state() + + return super().unlink() + + # -------------------------------------------------- + # Business methods + # -------------------------------------------------- + + def _update_move_date_deadline(self, new_date): + """ Updates corresponding move picking line deadline dates that are not yet completed. """ + moves_to_update = self.move_ids.filtered(lambda m: m.state not in ('done', 'cancel')) + if not moves_to_update: + moves_to_update = self.move_dest_ids.filtered(lambda m: m.state not in ('done', 'cancel')) + for move in moves_to_update: + move.date_deadline = new_date + relativedelta(days=move.company_id.po_lead) + + def _create_or_update_picking(self): + for line in self: + if line.product_id and line.product_id.type in ('product', 'consu'): + # Prevent decreasing below received quantity + if float_compare(line.product_qty, line.qty_received, line.product_uom.rounding) < 0: + raise UserError(_('You cannot decrease the ordered quantity below the received quantity.\n' + 'Create a return first.')) + + if float_compare(line.product_qty, line.qty_invoiced, line.product_uom.rounding) == -1: + # If the quantity is now below the invoiced quantity, create an activity on the vendor bill + # inviting the user to create a refund. + line.invoice_lines[0].move_id.activity_schedule( + 'mail.mail_activity_data_warning', + note=_('The quantities on your purchase order indicate less than billed. You should ask for a refund.')) + + # If the user increased quantity of existing line or created a new line + pickings = line.order_id.picking_ids.filtered(lambda x: x.state not in ('done', 'cancel') and x.location_dest_id.usage in ('internal', 'transit', 'customer')) + picking = pickings and pickings[0] or False + if not picking: + res = line.order_id._prepare_picking() + picking = self.env['stock.picking'].create(res) + + moves = line._create_stock_moves(picking) + moves._action_confirm()._action_assign() + + def _get_stock_move_price_unit(self): + self.ensure_one() + line = self[0] + order = line.order_id + price_unit = line.price_unit + price_unit_prec = self.env['decimal.precision'].precision_get('Product Price') + if line.taxes_id: + qty = line.product_qty or 1 + price_unit = line.taxes_id.with_context(round=False).compute_all( + price_unit, currency=line.order_id.currency_id, quantity=qty, product=line.product_id, partner=line.order_id.partner_id + )['total_void'] + price_unit = float_round(price_unit / qty, precision_digits=price_unit_prec) + if line.product_uom.id != line.product_id.uom_id.id: + price_unit *= line.product_uom.factor / line.product_id.uom_id.factor + if order.currency_id != order.company_id.currency_id: + price_unit = order.currency_id._convert( + price_unit, order.company_id.currency_id, self.company_id, self.date_order or fields.Date.today(), round=False) + return price_unit + + def _prepare_stock_moves(self, picking): + """ Prepare the stock moves data for one order line. This function returns a list of + dictionary ready to be used in stock.move's create() + """ + self.ensure_one() + res = [] + if self.product_id.type not in ['product', 'consu']: + return res + + qty = 0.0 + price_unit = self._get_stock_move_price_unit() + outgoing_moves, incoming_moves = self._get_outgoing_incoming_moves() + for move in outgoing_moves: + qty -= move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP') + for move in incoming_moves: + qty += move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP') + + move_dests = self.move_dest_ids + if not move_dests: + move_dests = self.move_ids.move_dest_ids.filtered(lambda m: m.state != 'cancel' and not m.location_dest_id.usage == 'supplier') + + if not move_dests: + qty_to_attach = 0 + qty_to_push = self.product_qty - qty + else: + move_dests_initial_demand = self.product_id.uom_id._compute_quantity( + sum(move_dests.filtered(lambda m: m.state != 'cancel' and not m.location_dest_id.usage == 'supplier').mapped('product_qty')), + self.product_uom, rounding_method='HALF-UP') + qty_to_attach = move_dests_initial_demand - qty + qty_to_push = self.product_qty - move_dests_initial_demand + + if float_compare(qty_to_attach, 0.0, precision_rounding=self.product_uom.rounding) > 0: + product_uom_qty, product_uom = self.product_uom._adjust_uom_quantities(qty_to_attach, self.product_id.uom_id) + res.append(self._prepare_stock_move_vals(picking, price_unit, product_uom_qty, product_uom)) + if float_compare(qty_to_push, 0.0, precision_rounding=self.product_uom.rounding) > 0: + product_uom_qty, product_uom = self.product_uom._adjust_uom_quantities(qty_to_push, self.product_id.uom_id) + extra_move_vals = self._prepare_stock_move_vals(picking, price_unit, product_uom_qty, product_uom) + extra_move_vals['move_dest_ids'] = False # don't attach + res.append(extra_move_vals) + return res + + def _prepare_stock_move_vals(self, picking, price_unit, product_uom_qty, product_uom): + self.ensure_one() + product = self.product_id.with_context(lang=self.order_id.dest_address_id.lang or self.env.user.lang) + description_picking = product._get_description(self.order_id.picking_type_id) + if self.product_description_variants: + description_picking += "\n" + self.product_description_variants + date_planned = self.date_planned or self.order_id.date_planned + return { + # truncate to 2000 to avoid triggering index limit error + # TODO: remove index in master? + 'name': (self.name or '')[:2000], + 'product_id': self.product_id.id, + 'date': date_planned, + 'date_deadline': date_planned + relativedelta(days=self.order_id.company_id.po_lead), + 'location_id': self.order_id.partner_id.property_stock_supplier.id, + 'location_dest_id': (self.orderpoint_id and not (self.move_ids | self.move_dest_ids)) and self.orderpoint_id.location_id.id or self.order_id._get_destination_location(), + 'picking_id': picking.id, + 'partner_id': self.order_id.dest_address_id.id, + 'move_dest_ids': [(4, x) for x in self.move_dest_ids.ids], + 'state': 'draft', + 'purchase_line_id': self.id, + 'company_id': self.order_id.company_id.id, + 'price_unit': price_unit, + 'picking_type_id': self.order_id.picking_type_id.id, + 'group_id': self.order_id.group_id.id, + 'origin': self.order_id.name, + 'description_picking': description_picking, + 'propagate_cancel': self.propagate_cancel, + 'warehouse_id': self.order_id.picking_type_id.warehouse_id.id, + 'product_uom_qty': product_uom_qty, + 'product_uom': product_uom.id, + } + + @api.model + def _prepare_purchase_order_line_from_procurement(self, product_id, product_qty, product_uom, company_id, values, po): + line_description = '' + if values.get('product_description_variants'): + line_description = values['product_description_variants'] + supplier = values.get('supplier') + res = self._prepare_purchase_order_line(product_id, product_qty, product_uom, company_id, supplier, po) + # We need to keep the vendor name set in _prepare_purchase_order_line. To avoid redundancy + # in the line name, we add the line_description only if different from the product name. + # This way, we shoud not lose any valuable information. + if line_description and product_id.name != line_description: + res['name'] += '\n' + line_description + res['move_dest_ids'] = [(4, x.id) for x in values.get('move_dest_ids', [])] + res['orderpoint_id'] = values.get('orderpoint_id', False) and values.get('orderpoint_id').id + res['propagate_cancel'] = values.get('propagate_cancel') + res['product_description_variants'] = values.get('product_description_variants') + return res + + def _create_stock_moves(self, picking): + values = [] + for line in self.filtered(lambda l: not l.display_type): + for val in line._prepare_stock_moves(picking): + values.append(val) + line.move_dest_ids.created_purchase_line_id = False + + return self.env['stock.move'].create(values) + + def _find_candidate(self, product_id, product_qty, product_uom, location_id, name, origin, company_id, values): + """ Return the record in self where the procument with values passed as + args can be merged. If it returns an empty record then a new line will + be created. + """ + description_picking = '' + if values.get('product_description_variants'): + description_picking = values['product_description_variants'] + lines = self.filtered( + lambda l: l.propagate_cancel == values['propagate_cancel'] + and ((values['orderpoint_id'] and not values['move_dest_ids']) and l.orderpoint_id == values['orderpoint_id'] or True) + ) + + # In case 'product_description_variants' is in the values, we also filter on the PO line + # name. This way, we can merge lines with the same description. To do so, we need the + # product name in the context of the PO partner. + if lines and values.get('product_description_variants'): + partner = self.mapped('order_id.partner_id')[:1] + product_lang = product_id.with_context( + lang=partner.lang, + partner_id=partner.id, + ) + name = product_lang.display_name + if product_lang.description_purchase: + name += '\n' + product_lang.description_purchase + lines = lines.filtered(lambda l: l.name == name + '\n' + description_picking) + if lines: + return lines[0] + + return lines and lines[0] or self.env['purchase.order.line'] + + def _get_outgoing_incoming_moves(self): + outgoing_moves = self.env['stock.move'] + incoming_moves = self.env['stock.move'] + + for move in self.move_ids.filtered(lambda r: r.state != 'cancel' and not r.scrapped and self.product_id == r.product_id): + if move.location_dest_id.usage == "supplier" and move.to_refund: + outgoing_moves |= move + elif move.location_dest_id.usage != "supplier": + if not move.origin_returned_move_id or (move.origin_returned_move_id and move.to_refund): + incoming_moves |= move + + return outgoing_moves, incoming_moves + + def _update_date_planned(self, updated_date): + move_to_update = self.move_ids.filtered(lambda m: m.state not in ['done', 'cancel']) + if not self.move_ids or move_to_update: # Only change the date if there is no move done or none + super()._update_date_planned(updated_date) + if move_to_update: + self._update_move_date_deadline(updated_date) + + @api.model + def _update_qty_received_method(self): + """Update qty_received_method for old PO before install this module.""" + self.search([])._compute_qty_received_method() diff --git a/addons/purchase_stock/models/res_company.py b/addons/purchase_stock/models/res_company.py new file mode 100644 index 00000000..b49d8cf9 --- /dev/null +++ b/addons/purchase_stock/models/res_company.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = 'res.company' + + days_to_purchase = fields.Float( + string='Days to Purchase', + help="Days needed to confirm a PO, define when a PO should be validated") diff --git a/addons/purchase_stock/models/res_config_settings.py b/addons/purchase_stock/models/res_config_settings.py new file mode 100644 index 00000000..5f459962 --- /dev/null +++ b/addons/purchase_stock/models/res_config_settings.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + module_stock_dropshipping = fields.Boolean("Dropshipping") + days_to_purchase = fields.Float( + related='company_id.days_to_purchase', readonly=False) + + is_installed_sale = fields.Boolean(string="Is the Sale Module Installed") + + def get_values(self): + res = super(ResConfigSettings, self).get_values() + res.update( + is_installed_sale=self.env['ir.module.module'].search([('name', '=', 'sale'), ('state', '=', 'installed')]).id, + ) + return res diff --git a/addons/purchase_stock/models/res_partner.py b/addons/purchase_stock/models/res_partner.py new file mode 100644 index 00000000..89604b83 --- /dev/null +++ b/addons/purchase_stock/models/res_partner.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import timedelta, datetime, time +from collections import defaultdict + +from odoo import api, fields, models + + +class ResPartner(models.Model): + _inherit = 'res.partner' + + purchase_line_ids = fields.One2many('purchase.order.line', 'partner_id', string="Purchase Lines") + on_time_rate = fields.Float( + "On-Time Delivery Rate", compute='_compute_on_time_rate', + help="Over the past 12 months; the number of products received on time divided by the number of ordered products.") + + @api.depends('purchase_line_ids') + def _compute_on_time_rate(self): + order_lines = self.env['purchase.order.line'].search([ + ('partner_id', 'in', self.ids), + ('date_order', '>', fields.Date.today() - timedelta(365)), + ('qty_received', '!=', 0), + ('order_id.state', 'in', ['done', 'purchase']) + ]).filtered(lambda l: l.product_id.sudo().product_tmpl_id.type != 'service') + lines_qty_done = defaultdict(lambda: 0) + moves = self.env['stock.move'].search([ + ('purchase_line_id', 'in', order_lines.ids), + ('state', '=', 'done')]).filtered(lambda m: m.date.date() <= m.purchase_line_id.date_planned.date()) + for move, qty_done in zip(moves, moves.mapped('quantity_done')): + lines_qty_done[move.purchase_line_id.id] += qty_done + partner_dict = {} + for line in order_lines: + on_time, ordered = partner_dict.get(line.partner_id, (0, 0)) + ordered += line.product_uom_qty + on_time += lines_qty_done[line.id] + partner_dict[line.partner_id] = (on_time, ordered) + seen_partner = self.env['res.partner'] + for partner, numbers in partner_dict.items(): + seen_partner |= partner + on_time, ordered = numbers + partner.on_time_rate = on_time / ordered * 100 if ordered else -1 # use negative number to indicate no data + (self - seen_partner).on_time_rate = -1 diff --git a/addons/purchase_stock/models/stock.py b/addons/purchase_stock/models/stock.py new file mode 100644 index 00000000..867026e9 --- /dev/null +++ b/addons/purchase_stock/models/stock.py @@ -0,0 +1,287 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.tools.float_utils import float_round + + +class StockPicking(models.Model): + _inherit = 'stock.picking' + + purchase_id = fields.Many2one('purchase.order', related='move_lines.purchase_line_id.order_id', + string="Purchase Orders", readonly=True) + + +class StockMove(models.Model): + _inherit = 'stock.move' + + purchase_line_id = fields.Many2one('purchase.order.line', + 'Purchase Order Line', ondelete='set null', index=True, readonly=True) + created_purchase_line_id = fields.Many2one('purchase.order.line', + 'Created Purchase Order Line', ondelete='set null', readonly=True, copy=False) + + @api.model + def _prepare_merge_moves_distinct_fields(self): + distinct_fields = super(StockMove, self)._prepare_merge_moves_distinct_fields() + distinct_fields += ['purchase_line_id', 'created_purchase_line_id'] + return distinct_fields + + @api.model + def _prepare_merge_move_sort_method(self, move): + move.ensure_one() + keys_sorted = super(StockMove, self)._prepare_merge_move_sort_method(move) + keys_sorted += [move.purchase_line_id.id, move.created_purchase_line_id.id] + return keys_sorted + + def _get_price_unit(self): + """ Returns the unit price for the move""" + self.ensure_one() + if self.purchase_line_id and self.product_id.id == self.purchase_line_id.product_id.id: + price_unit_prec = self.env['decimal.precision'].precision_get('Product Price') + line = self.purchase_line_id + order = line.order_id + price_unit = line.price_unit + if line.taxes_id: + qty = line.product_qty or 1 + price_unit = line.taxes_id.with_context(round=False).compute_all(price_unit, currency=line.order_id.currency_id, quantity=qty)['total_void'] + price_unit = float_round(price_unit / qty, precision_digits=price_unit_prec) + if line.product_uom.id != line.product_id.uom_id.id: + price_unit *= line.product_uom.factor / line.product_id.uom_id.factor + if order.currency_id != order.company_id.currency_id: + # The date must be today, and not the date of the move since the move move is still + # in assigned state. However, the move date is the scheduled date until move is + # done, then date of actual move processing. See: + # https://github.com/odoo/odoo/blob/2f789b6863407e63f90b3a2d4cc3be09815f7002/addons/stock/models/stock_move.py#L36 + price_unit = order.currency_id._convert( + price_unit, order.company_id.currency_id, order.company_id, fields.Date.context_today(self), round=False) + return price_unit + return super(StockMove, self)._get_price_unit() + + def _generate_valuation_lines_data(self, partner_id, qty, debit_value, credit_value, debit_account_id, credit_account_id, description): + """ Overridden from stock_account to support amount_currency on valuation lines generated from po + """ + self.ensure_one() + + rslt = super(StockMove, self)._generate_valuation_lines_data(partner_id, qty, debit_value, credit_value, debit_account_id, credit_account_id, description) + if self.purchase_line_id: + purchase_currency = self.purchase_line_id.currency_id + if purchase_currency != self.company_id.currency_id: + # Do not use price_unit since we want the price tax excluded. And by the way, qty + # is in the UOM of the product, not the UOM of the PO line. + purchase_price_unit = ( + self.purchase_line_id.price_subtotal / self.purchase_line_id.product_uom_qty + if self.purchase_line_id.product_uom_qty + else self.purchase_line_id.price_unit + ) + currency_move_valuation = purchase_currency.round(purchase_price_unit * abs(qty)) + rslt['credit_line_vals']['amount_currency'] = rslt['credit_line_vals']['credit'] and -currency_move_valuation or currency_move_valuation + rslt['credit_line_vals']['currency_id'] = purchase_currency.id + rslt['debit_line_vals']['amount_currency'] = rslt['debit_line_vals']['credit'] and -currency_move_valuation or currency_move_valuation + rslt['debit_line_vals']['currency_id'] = purchase_currency.id + return rslt + + def _prepare_extra_move_vals(self, qty): + vals = super(StockMove, self)._prepare_extra_move_vals(qty) + vals['purchase_line_id'] = self.purchase_line_id.id + return vals + + def _prepare_move_split_vals(self, uom_qty): + vals = super(StockMove, self)._prepare_move_split_vals(uom_qty) + vals['purchase_line_id'] = self.purchase_line_id.id + return vals + + def _clean_merged(self): + super(StockMove, self)._clean_merged() + self.write({'created_purchase_line_id': False}) + + def _get_upstream_documents_and_responsibles(self, visited): + if self.created_purchase_line_id and self.created_purchase_line_id.state not in ('done', 'cancel'): + return [(self.created_purchase_line_id.order_id, self.created_purchase_line_id.order_id.user_id, visited)] + elif self.purchase_line_id and self.purchase_line_id.state not in ('done', 'cancel'): + return[(self.purchase_line_id.order_id, self.purchase_line_id.order_id.user_id, visited)] + else: + return super(StockMove, self)._get_upstream_documents_and_responsibles(visited) + + def _get_related_invoices(self): + """ Overridden to return the vendor bills related to this stock move. + """ + rslt = super(StockMove, self)._get_related_invoices() + rslt += self.mapped('picking_id.purchase_id.invoice_ids').filtered(lambda x: x.state == 'posted') + return rslt + + def _get_source_document(self): + res = super()._get_source_document() + return self.purchase_line_id.order_id or res + + +class StockWarehouse(models.Model): + _inherit = 'stock.warehouse' + + buy_to_resupply = fields.Boolean('Buy to Resupply', default=True, + help="When products are bought, they can be delivered to this warehouse") + buy_pull_id = fields.Many2one('stock.rule', 'Buy rule') + + def _get_global_route_rules_values(self): + rules = super(StockWarehouse, self)._get_global_route_rules_values() + location_id = self.in_type_id.default_location_dest_id + rules.update({ + 'buy_pull_id': { + 'depends': ['reception_steps', 'buy_to_resupply'], + 'create_values': { + 'action': 'buy', + 'picking_type_id': self.in_type_id.id, + 'group_propagation_option': 'none', + 'company_id': self.company_id.id, + 'route_id': self._find_global_route('purchase_stock.route_warehouse0_buy', _('Buy')).id, + 'propagate_cancel': self.reception_steps != 'one_step', + }, + 'update_values': { + 'active': self.buy_to_resupply, + 'name': self._format_rulename(location_id, False, 'Buy'), + 'location_id': location_id.id, + 'propagate_cancel': self.reception_steps != 'one_step', + } + } + }) + return rules + + def _get_all_routes(self): + routes = super(StockWarehouse, self)._get_all_routes() + routes |= self.filtered(lambda self: self.buy_to_resupply and self.buy_pull_id and self.buy_pull_id.route_id).mapped('buy_pull_id').mapped('route_id') + return routes + + def get_rules_dict(self): + result = super(StockWarehouse, self).get_rules_dict() + for warehouse in self: + result[warehouse.id].update(warehouse._get_receive_rules_dict()) + return result + + def _get_routes_values(self): + routes = super(StockWarehouse, self)._get_routes_values() + routes.update(self._get_receive_routes_values('buy_to_resupply')) + return routes + + def _update_name_and_code(self, name=False, code=False): + res = super(StockWarehouse, self)._update_name_and_code(name, code) + warehouse = self[0] + #change the buy stock rule name + if warehouse.buy_pull_id and name: + warehouse.buy_pull_id.write({'name': warehouse.buy_pull_id.name.replace(warehouse.name, name, 1)}) + return res + + +class ReturnPicking(models.TransientModel): + _inherit = "stock.return.picking" + + def _prepare_move_default_values(self, return_line, new_picking): + vals = super(ReturnPicking, self)._prepare_move_default_values(return_line, new_picking) + vals['purchase_line_id'] = return_line.move_id.purchase_line_id.id + return vals + + +class Orderpoint(models.Model): + _inherit = "stock.warehouse.orderpoint" + + show_supplier = fields.Boolean('Show supplier column', compute='_compute_show_suppplier') + supplier_id = fields.Many2one( + 'product.supplierinfo', string='Vendor', check_company=True, + domain="['|', ('product_id', '=', product_id), '&', ('product_id', '=', False), ('product_tmpl_id', '=', product_tmpl_id)]") + + @api.depends('product_id.purchase_order_line_ids', 'product_id.purchase_order_line_ids.state') + def _compute_qty(self): + """ Extend to add more depends values """ + return super()._compute_qty() + + @api.depends('route_id') + def _compute_show_suppplier(self): + buy_route = [] + for res in self.env['stock.rule'].search_read([('action', '=', 'buy')], ['route_id']): + buy_route.append(res['route_id'][0]) + for orderpoint in self: + orderpoint.show_supplier = orderpoint.route_id.id in buy_route + + def action_view_purchase(self): + """ This function returns an action that display existing + purchase orders of given orderpoint. + """ + result = self.env['ir.actions.act_window']._for_xml_id('purchase.purchase_rfq') + + # Remvove the context since the action basically display RFQ and not PO. + result['context'] = {} + order_line_ids = self.env['purchase.order.line'].search([('orderpoint_id', '=', self.id)]) + purchase_ids = order_line_ids.mapped('order_id') + + result['domain'] = "[('id','in',%s)]" % (purchase_ids.ids) + + return result + + def _get_replenishment_order_notification(self): + self.ensure_one() + order = self.env['purchase.order.line'].search([ + ('orderpoint_id', 'in', self.ids) + ], limit=1).order_id + if order: + action = self.env.ref('purchase.action_rfq_form') + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('The following replenishment order has been generated'), + 'message': '%s', + 'links': [{ + 'label': order.display_name, + 'url': f'#action={action.id}&id={order.id}&model=purchase.order', + }], + 'sticky': False, + } + } + return super()._get_replenishment_order_notification() + + def _prepare_procurement_values(self, date=False, group=False): + values = super()._prepare_procurement_values(date=date, group=group) + values['supplierinfo_id'] = self.supplier_id + return values + + def _quantity_in_progress(self): + res = super()._quantity_in_progress() + qty_by_product_location, dummy = self.product_id._get_quantity_in_progress(self.location_id.ids) + for orderpoint in self: + product_qty = qty_by_product_location.get((orderpoint.product_id.id, orderpoint.location_id.id), 0.0) + product_uom_qty = orderpoint.product_id.uom_id._compute_quantity(product_qty, orderpoint.product_uom, round=False) + res[orderpoint.id] += product_uom_qty + return res + + def _set_default_route_id(self): + route_id = self.env['stock.rule'].search([ + ('action', '=', 'buy') + ]).route_id + orderpoint_wh_supplier = self.filtered(lambda o: o.product_id.seller_ids) + if route_id and orderpoint_wh_supplier: + orderpoint_wh_supplier.route_id = route_id[0].id + return super()._set_default_route_id() + + +class ProductionLot(models.Model): + _inherit = 'stock.production.lot' + + purchase_order_ids = fields.Many2many('purchase.order', string="Purchase Orders", compute='_compute_purchase_order_ids', readonly=True, store=False) + purchase_order_count = fields.Integer('Purchase order count', compute='_compute_purchase_order_ids') + + @api.depends('name') + def _compute_purchase_order_ids(self): + for lot in self: + stock_moves = self.env['stock.move.line'].search([ + ('lot_id', '=', lot.id), + ('state', '=', 'done') + ]).mapped('move_id') + stock_moves = stock_moves.search([('id', 'in', stock_moves.ids)]).filtered( + lambda move: move.picking_id.location_id.usage == 'supplier' and move.state == 'done') + lot.purchase_order_ids = stock_moves.mapped('purchase_line_id.order_id') + lot.purchase_order_count = len(lot.purchase_order_ids) + + def action_view_po(self): + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id("purchase.purchase_form_action") + action['domain'] = [('id', 'in', self.mapped('purchase_order_ids.id'))] + action['context'] = dict(self._context, create=False) + return action diff --git a/addons/purchase_stock/models/stock_rule.py b/addons/purchase_stock/models/stock_rule.py new file mode 100644 index 00000000..33144e9e --- /dev/null +++ b/addons/purchase_stock/models/stock_rule.py @@ -0,0 +1,325 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from collections import defaultdict +from datetime import datetime +from dateutil.relativedelta import relativedelta +from itertools import groupby + +from odoo import api, fields, models, SUPERUSER_ID, _ +from odoo.addons.stock.models.stock_rule import ProcurementException + + +class StockRule(models.Model): + _inherit = 'stock.rule' + + action = fields.Selection(selection_add=[ + ('buy', 'Buy') + ], ondelete={'buy': 'cascade'}) + + def _get_message_dict(self): + message_dict = super(StockRule, self)._get_message_dict() + dummy, destination, dummy = self._get_message_values() + message_dict.update({ + 'buy': _('When products are needed in %s,
a request for quotation is created to fulfill the need.') % (destination) + }) + return message_dict + + @api.depends('action') + def _compute_picking_type_code_domain(self): + remaining = self.browse() + for rule in self: + if rule.action == 'buy': + rule.picking_type_code_domain = 'incoming' + else: + remaining |= rule + super(StockRule, remaining)._compute_picking_type_code_domain() + + @api.onchange('action') + def _onchange_action(self): + if self.action == 'buy': + self.location_src_id = False + + @api.model + def _run_buy(self, procurements): + procurements_by_po_domain = defaultdict(list) + errors = [] + for procurement, rule in procurements: + + # Get the schedule date in order to find a valid seller + procurement_date_planned = fields.Datetime.from_string(procurement.values['date_planned']) + schedule_date = (procurement_date_planned - relativedelta(days=procurement.company_id.po_lead)) + + supplier = False + if procurement.values.get('supplierinfo_id'): + supplier = procurement.values['supplierinfo_id'] + else: + supplier = procurement.product_id.with_company(procurement.company_id.id)._select_seller( + partner_id=procurement.values.get("supplierinfo_name"), + quantity=procurement.product_qty, + date=schedule_date.date(), + uom_id=procurement.product_uom) + + # Fall back on a supplier for which no price may be defined. Not ideal, but better than + # blocking the user. + supplier = supplier or procurement.product_id._prepare_sellers(False).filtered( + lambda s: not s.company_id or s.company_id == procurement.company_id + )[:1] + + if not supplier: + msg = _('There is no matching vendor price to generate the purchase order for product %s (no vendor defined, minimum quantity not reached, dates not valid, ...). Go on the product form and complete the list of vendors.') % (procurement.product_id.display_name) + errors.append((procurement, msg)) + + partner = supplier.name + # we put `supplier_info` in values for extensibility purposes + procurement.values['supplier'] = supplier + procurement.values['propagate_cancel'] = rule.propagate_cancel + + domain = rule._make_po_get_domain(procurement.company_id, procurement.values, partner) + procurements_by_po_domain[domain].append((procurement, rule)) + + if errors: + raise ProcurementException(errors) + + for domain, procurements_rules in procurements_by_po_domain.items(): + # Get the procurements for the current domain. + # Get the rules for the current domain. Their only use is to create + # the PO if it does not exist. + procurements, rules = zip(*procurements_rules) + + # Get the set of procurement origin for the current domain. + origins = set([p.origin for p in procurements]) + # Check if a PO exists for the current domain. + po = self.env['purchase.order'].sudo().search([dom for dom in domain], limit=1) + company_id = procurements[0].company_id + if not po: + # We need a rule to generate the PO. However the rule generated + # the same domain for PO and the _prepare_purchase_order method + # should only uses the common rules's fields. + vals = rules[0]._prepare_purchase_order(company_id, origins, [p.values for p in procurements]) + # The company_id is the same for all procurements since + # _make_po_get_domain add the company in the domain. + # We use SUPERUSER_ID since we don't want the current user to be follower of the PO. + # Indeed, the current user may be a user without access to Purchase, or even be a portal user. + po = self.env['purchase.order'].with_company(company_id).with_user(SUPERUSER_ID).create(vals) + else: + # If a purchase order is found, adapt its `origin` field. + if po.origin: + missing_origins = origins - set(po.origin.split(', ')) + if missing_origins: + po.write({'origin': po.origin + ', ' + ', '.join(missing_origins)}) + else: + po.write({'origin': ', '.join(origins)}) + + procurements_to_merge = self._get_procurements_to_merge(procurements) + procurements = self._merge_procurements(procurements_to_merge) + + po_lines_by_product = {} + grouped_po_lines = groupby(po.order_line.filtered(lambda l: not l.display_type and l.product_uom == l.product_id.uom_po_id).sorted(lambda l: l.product_id.id), key=lambda l: l.product_id.id) + for product, po_lines in grouped_po_lines: + po_lines_by_product[product] = self.env['purchase.order.line'].concat(*list(po_lines)) + po_line_values = [] + for procurement in procurements: + po_lines = po_lines_by_product.get(procurement.product_id.id, self.env['purchase.order.line']) + po_line = po_lines._find_candidate(*procurement) + + if po_line: + # If the procurement can be merge in an existing line. Directly + # write the new values on it. + vals = self._update_purchase_order_line(procurement.product_id, + procurement.product_qty, procurement.product_uom, company_id, + procurement.values, po_line) + po_line.write(vals) + else: + # If it does not exist a PO line for current procurement. + # Generate the create values for it and add it to a list in + # order to create it in batch. + partner = procurement.values['supplier'].name + po_line_values.append(self.env['purchase.order.line']._prepare_purchase_order_line_from_procurement( + procurement.product_id, procurement.product_qty, + procurement.product_uom, procurement.company_id, + procurement.values, po)) + self.env['purchase.order.line'].sudo().create(po_line_values) + + def _get_lead_days(self, product): + """Add the company security lead time, days to purchase and the supplier + delay to the cumulative delay and cumulative description. The days to + purchase and company lead time are always displayed for onboarding + purpose in order to indicate that those options are available. + """ + delay, delay_description = super()._get_lead_days(product) + bypass_delay_description = self.env.context.get('bypass_delay_description') + buy_rule = self.filtered(lambda r: r.action == 'buy') + seller = product.with_company(buy_rule.company_id)._select_seller() + if not buy_rule or not seller: + return delay, delay_description + buy_rule.ensure_one() + supplier_delay = seller[0].delay + if supplier_delay and not bypass_delay_description: + delay_description += '%s+ %d %s' % (_('Vendor Lead Time'), supplier_delay, _('day(s)')) + security_delay = buy_rule.picking_type_id.company_id.po_lead + if not bypass_delay_description: + delay_description += '%s+ %d %s' % (_('Purchase Security Lead Time'), security_delay, _('day(s)')) + days_to_purchase = buy_rule.company_id.days_to_purchase + if not bypass_delay_description: + delay_description += '%s+ %d %s' % (_('Days to Purchase'), days_to_purchase, _('day(s)')) + return delay + supplier_delay + security_delay + days_to_purchase, delay_description + + @api.model + def _get_procurements_to_merge_groupby(self, procurement): + # Do not group procument from different orderpoint. 1. _quantity_in_progress + # directly depends from the orderpoint_id on the line. 2. The stock move + # generated from the order line has the orderpoint's location as + # destination location. In case of move_dest_ids those two points are not + # necessary anymore since those values are taken from destination moves. + return procurement.product_id, procurement.product_uom, procurement.values['propagate_cancel'],\ + procurement.values.get('product_description_variants'),\ + (procurement.values.get('orderpoint_id') and not procurement.values.get('move_dest_ids')) and procurement.values['orderpoint_id'] + + @api.model + def _get_procurements_to_merge_sorted(self, procurement): + return procurement.product_id.id, procurement.product_uom.id, procurement.values['propagate_cancel'],\ + procurement.values.get('product_description_variants'),\ + (procurement.values.get('orderpoint_id') and not procurement.values.get('move_dest_ids')) and procurement.values['orderpoint_id'] + + @api.model + def _get_procurements_to_merge(self, procurements): + """ Get a list of procurements values and create groups of procurements + that would use the same purchase order line. + params procurements_list list: procurements requests (not ordered nor + sorted). + return list: procurements requests grouped by their product_id. + """ + procurements_to_merge = [] + + for k, procurements in groupby(sorted(procurements, key=self._get_procurements_to_merge_sorted), key=self._get_procurements_to_merge_groupby): + procurements_to_merge.append(list(procurements)) + return procurements_to_merge + + @api.model + def _merge_procurements(self, procurements_to_merge): + """ Merge the quantity for procurements requests that could use the same + order line. + params similar_procurements list: list of procurements that have been + marked as 'alike' from _get_procurements_to_merge method. + return a list of procurements values where values of similar_procurements + list have been merged. + """ + merged_procurements = [] + for procurements in procurements_to_merge: + quantity = 0 + move_dest_ids = self.env['stock.move'] + orderpoint_id = self.env['stock.warehouse.orderpoint'] + for procurement in procurements: + if procurement.values.get('move_dest_ids'): + move_dest_ids |= procurement.values['move_dest_ids'] + if not orderpoint_id and procurement.values.get('orderpoint_id'): + orderpoint_id = procurement.values['orderpoint_id'] + quantity += procurement.product_qty + # The merged procurement can be build from an arbitrary procurement + # since they were mark as similar before. Only the quantity and + # some keys in values are updated. + values = dict(procurement.values) + values.update({ + 'move_dest_ids': move_dest_ids, + 'orderpoint_id': orderpoint_id, + }) + merged_procurement = self.env['procurement.group'].Procurement( + procurement.product_id, quantity, procurement.product_uom, + procurement.location_id, procurement.name, procurement.origin, + procurement.company_id, values + ) + merged_procurements.append(merged_procurement) + return merged_procurements + + def _update_purchase_order_line(self, product_id, product_qty, product_uom, company_id, values, line): + partner = values['supplier'].name + procurement_uom_po_qty = product_uom._compute_quantity(product_qty, product_id.uom_po_id) + seller = product_id.with_company(company_id)._select_seller( + partner_id=partner, + quantity=line.product_qty + procurement_uom_po_qty, + date=line.order_id.date_order and line.order_id.date_order.date(), + uom_id=product_id.uom_po_id) + + price_unit = self.env['account.tax']._fix_tax_included_price_company(seller.price, line.product_id.supplier_taxes_id, line.taxes_id, company_id) if seller else 0.0 + if price_unit and seller and line.order_id.currency_id and seller.currency_id != line.order_id.currency_id: + price_unit = seller.currency_id._convert( + price_unit, line.order_id.currency_id, line.order_id.company_id, fields.Date.today()) + + res = { + 'product_qty': line.product_qty + procurement_uom_po_qty, + 'price_unit': price_unit, + 'move_dest_ids': [(4, x.id) for x in values.get('move_dest_ids', [])] + } + orderpoint_id = values.get('orderpoint_id') + if orderpoint_id: + res['orderpoint_id'] = orderpoint_id.id + return res + + def _prepare_purchase_order(self, company_id, origins, values): + """ Create a purchase order for procuremets that share the same domain + returned by _make_po_get_domain. + params values: values of procurements + params origins: procuremets origins to write on the PO + """ + dates = [fields.Datetime.from_string(value['date_planned']) for value in values] + + procurement_date_planned = min(dates) + schedule_date = (procurement_date_planned - relativedelta(days=company_id.po_lead)) + supplier_delay = max([int(value['supplier'].delay) for value in values]) + + # Since the procurements are grouped if they share the same domain for + # PO but the PO does not exist. In this case it will create the PO from + # the common procurements values. The common values are taken from an + # arbitrary procurement. In this case the first. + values = values[0] + partner = values['supplier'].name + purchase_date = schedule_date - relativedelta(days=supplier_delay) + + fpos = self.env['account.fiscal.position'].with_company(company_id).get_fiscal_position(partner.id) + + gpo = self.group_propagation_option + group = (gpo == 'fixed' and self.group_id.id) or \ + (gpo == 'propagate' and values.get('group_id') and values['group_id'].id) or False + + return { + 'partner_id': partner.id, + 'user_id': False, + 'picking_type_id': self.picking_type_id.id, + 'company_id': company_id.id, + 'currency_id': partner.with_company(company_id).property_purchase_currency_id.id or company_id.currency_id.id, + 'dest_address_id': values.get('partner_id', False), + 'origin': ', '.join(origins), + 'payment_term_id': partner.with_company(company_id).property_supplier_payment_term_id.id, + 'date_order': purchase_date, + 'fiscal_position_id': fpos.id, + 'group_id': group + } + + def _make_po_get_domain(self, company_id, values, partner): + gpo = self.group_propagation_option + group = (gpo == 'fixed' and self.group_id) or \ + (gpo == 'propagate' and 'group_id' in values and values['group_id']) or False + + domain = ( + ('partner_id', '=', partner.id), + ('state', '=', 'draft'), + ('picking_type_id', '=', self.picking_type_id.id), + ('company_id', '=', company_id.id), + ('user_id', '=', False), + ) + if values.get('orderpoint_id'): + procurement_date = fields.Date.to_date(values['date_planned']) - relativedelta(days=int(values['supplier'].delay) + company_id.po_lead) + delta_days = int(self.env['ir.config_parameter'].sudo().get_param('purchase_stock.delta_days_merge') or 0) + domain += ( + ('date_order', '<=', datetime.combine(procurement_date + relativedelta(days=delta_days), datetime.max.time())), + ('date_order', '>=', datetime.combine(procurement_date - relativedelta(days=delta_days), datetime.min.time())) + ) + if group: + domain += (('group_id', '=', group.id),) + return domain + + def _push_prepare_move_copy_values(self, move_to_copy, new_date): + res = super(StockRule, self)._push_prepare_move_copy_values(move_to_copy, new_date) + res['purchase_line_id'] = None + return res diff --git a/addons/purchase_stock/report/__init__.py b/addons/purchase_stock/report/__init__.py new file mode 100644 index 00000000..68360661 --- /dev/null +++ b/addons/purchase_stock/report/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import purchase_report +from . import report_stock_forecasted +from . import report_stock_rule +from . import vendor_delay_report diff --git a/addons/purchase_stock/report/purchase_report.py b/addons/purchase_stock/report/purchase_report.py new file mode 100644 index 00000000..67e63816 --- /dev/null +++ b/addons/purchase_stock/report/purchase_report.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import re + +from odoo import api, fields, models +from odoo.exceptions import UserError +from odoo.osv.expression import expression + + +class PurchaseReport(models.Model): + _inherit = "purchase.report" + + picking_type_id = fields.Many2one('stock.warehouse', 'Warehouse', readonly=True) + avg_receipt_delay = fields.Float( + 'Average Receipt Delay', digits=(16, 2), readonly=True, store=False, # needs store=False to prevent showing up as a 'measure' option + help="Amount of time between expected and effective receipt date. Due to a hack needed to calculate this, \ + every record will show the same average value, therefore only use this as an aggregated value with group_operator=avg") + effective_date = fields.Datetime(string="Effective Date") + + def _select(self): + return super(PurchaseReport, self)._select() + ", spt.warehouse_id as picking_type_id, po.effective_date as effective_date" + + def _from(self): + return super(PurchaseReport, self)._from() + " left join stock_picking_type spt on (spt.id=po.picking_type_id)" + + def _group_by(self): + return super(PurchaseReport, self)._group_by() + ", spt.warehouse_id, effective_date" + + @api.model + def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True): + """ This is a hack to allow us to correctly calculate the average of PO specific date values since + the normal report query result will duplicate PO values across its PO lines during joins and + lead to incorrect aggregation values. + + Only the AVG operator is supported for avg_receipt_delay. + """ + avg_receipt_delay = next((field for field in fields if re.search(r'\bavg_receipt_delay\b', field)), False) + + if avg_receipt_delay: + fields.remove(avg_receipt_delay) + if any(field.split(':')[1].split('(')[0] != 'avg' for field in [avg_receipt_delay] if field): + raise UserError("Value: 'avg_receipt_delay' should only be used to show an average. If you are seeing this message then it is being accessed incorrectly.") + + res = [] + if fields: + res = super(PurchaseReport, self).read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy) + + if not res and avg_receipt_delay: + res = [{}] + + if avg_receipt_delay: + query = """ SELECT AVG(receipt_delay.po_receipt_delay)::decimal(16,2) AS avg_receipt_delay + FROM ( + SELECT extract(epoch from age(po.effective_date, po.date_planned))/(24*60*60) AS po_receipt_delay + FROM purchase_order po + WHERE po.id IN ( + SELECT "purchase_report"."order_id" FROM %s WHERE %s) + ) AS receipt_delay + """ + + subdomain = domain + [('company_id', '=', self.env.company.id), ('effective_date', '!=', False)] + subtables, subwhere, subparams = expression(subdomain, self).query.get_sql() + + self.env.cr.execute(query % (subtables, subwhere), subparams) + res[0].update({ + '__count': 1, + avg_receipt_delay.split(':')[0]: self.env.cr.fetchall()[0][0], + }) + return res diff --git a/addons/purchase_stock/report/purchase_report_templates.xml b/addons/purchase_stock/report/purchase_report_templates.xml new file mode 100644 index 00000000..89acd21a --- /dev/null +++ b/addons/purchase_stock/report/purchase_report_templates.xml @@ -0,0 +1,46 @@ + + + + + + + + diff --git a/addons/purchase_stock/report/purchase_report_views.xml b/addons/purchase_stock/report/purchase_report_views.xml new file mode 100644 index 00000000..ad272a7b --- /dev/null +++ b/addons/purchase_stock/report/purchase_report_views.xml @@ -0,0 +1,15 @@ + + + + + purchase.report.search.stock + purchase.report + + + + + + + + + diff --git a/addons/purchase_stock/report/report_stock_forecasted.py b/addons/purchase_stock/report/report_stock_forecasted.py new file mode 100644 index 00000000..6ae5459a --- /dev/null +++ b/addons/purchase_stock/report/report_stock_forecasted.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + + +class ReplenishmentReport(models.AbstractModel): + _inherit = 'report.stock.report_product_product_replenishment' + + def _compute_draft_quantity_count(self, product_template_ids, product_variant_ids, wh_location_ids): + res = super()._compute_draft_quantity_count(product_template_ids, product_variant_ids, wh_location_ids) + domain = [('state', 'in', ['draft', 'sent'])] + domain += self._product_purchase_domain(product_template_ids, product_variant_ids) + warehouse_id = self.env.context.get('warehouse', False) + if warehouse_id: + domain += [('order_id.picking_type_id.warehouse_id', '=', warehouse_id)] + po_lines = self.env['purchase.order.line'].read_group(domain, ['product_uom_qty'], 'product_id') + in_sum = sum(line['product_uom_qty'] for line in po_lines) + + res['draft_purchase_qty'] = in_sum + res['qty']['in'] += in_sum + return res + + def _product_purchase_domain(self, product_template_ids, product_variant_ids): + if product_variant_ids: + return [('product_id', 'in', product_variant_ids)] + elif product_template_ids: + products = self.env['product.product'].search_read( + [('product_tmpl_id', 'in', product_template_ids)], ['id'] + ) + product_ids = [product['id'] for product in products] + return [('product_id', 'in', product_ids)] diff --git a/addons/purchase_stock/report/report_stock_forecasted.xml b/addons/purchase_stock/report/report_stock_forecasted.xml new file mode 100644 index 00000000..65538563 --- /dev/null +++ b/addons/purchase_stock/report/report_stock_forecasted.xml @@ -0,0 +1,12 @@ + + + + diff --git a/addons/purchase_stock/report/report_stock_rule.py b/addons/purchase_stock/report/report_stock_rule.py new file mode 100644 index 00000000..cc92df7c --- /dev/null +++ b/addons/purchase_stock/report/report_stock_rule.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models + + +class ReportStockRule(models.AbstractModel): + _inherit = 'report.stock.report_stock_rule' + + @api.model + def _get_rule_loc(self, rule, product_id): + """ We override this method to handle buy rules which do not have a location_src_id. + """ + res = super(ReportStockRule, self)._get_rule_loc(rule, product_id) + if rule.action == 'buy': + res['source'] = self.env.ref('stock.stock_location_suppliers') + return res diff --git a/addons/purchase_stock/report/report_stock_rule.xml b/addons/purchase_stock/report/report_stock_rule.xml new file mode 100644 index 00000000..7d524099 --- /dev/null +++ b/addons/purchase_stock/report/report_stock_rule.xml @@ -0,0 +1,32 @@ + + + + \ No newline at end of file diff --git a/addons/purchase_stock/report/vendor_delay_report.py b/addons/purchase_stock/report/vendor_delay_report.py new file mode 100644 index 00000000..ef55b2ca --- /dev/null +++ b/addons/purchase_stock/report/vendor_delay_report.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, tools +from odoo.exceptions import UserError +from odoo.osv.expression import expression + + +class VendorDelayReport(models.Model): + _name = "vendor.delay.report" + _description = "Vendor Delay Report" + _auto = False + + partner_id = fields.Many2one('res.partner', 'Vendor', readonly=True) + product_id = fields.Many2one('product.product', 'Product', readonly=True) + category_id = fields.Many2one('product.category', 'Product Category', readonly=True) + date = fields.Datetime('Effective Date', readonly=True) + qty_total = fields.Float('Total Quantity', readonly=True) + qty_on_time = fields.Float('On-Time Quantity', readonly=True) + on_time_rate = fields.Float('On-Time Delivery Rate', readonly=True) + + def init(self): + tools.drop_view_if_exists(self.env.cr, 'vendor_delay_report') + self.env.cr.execute(""" +CREATE OR replace VIEW vendor_delay_report AS( +SELECT m.id AS id, + m.date AS date, + m.purchase_line_id AS purchase_line_id, + m.product_id AS product_id, + Min(pc.id) AS category_id, + Min(po.partner_id) AS partner_id, + Sum(pol.product_uom_qty) AS qty_total, + Sum(CASE + WHEN (pol.date_planned::date >= m.date::date) THEN ml.qty_done + ELSE 0 + END) AS qty_on_time +FROM stock_move m + JOIN stock_move_line ml + ON m.id = ml.move_id + JOIN purchase_order_line pol + ON pol.id = m.purchase_line_id + JOIN purchase_order po + ON po.id = pol.order_id + JOIN product_product p + ON p.id = m.product_id + JOIN product_template pt + ON pt.id = p.product_tmpl_id + JOIN product_category pc + ON pc.id = pt.categ_id +WHERE m.state = 'done' +GROUP BY m.id +)""") + + @api.model + def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True): + if all('on_time_rate' not in field for field in fields): + res = super().read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy) + return res + + for field in fields: + if 'on_time_rate' not in field: + continue + + fields.remove(field) + + agg = field.split(':')[1:] + if agg and agg[0] != 'sum': + raise NotImplementedError('Aggregate functions other than \':sum\' are not allowed.') + + qty_total = field.replace('on_time_rate', 'qty_total') + if qty_total not in fields: + fields.append(qty_total) + qty_on_time = field.replace('on_time_rate', 'qty_on_time') + if qty_on_time not in fields: + fields.append(qty_on_time) + break + + res = super().read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy) + + for group in res: + if group['qty_total'] == 0: + on_time_rate = 100 + else: + on_time_rate = group['qty_on_time'] / group['qty_total'] * 100 + group.update({'on_time_rate': on_time_rate}) + + return res diff --git a/addons/purchase_stock/report/vendor_delay_report.xml b/addons/purchase_stock/report/vendor_delay_report.xml new file mode 100644 index 00000000..cd4601cc --- /dev/null +++ b/addons/purchase_stock/report/vendor_delay_report.xml @@ -0,0 +1,35 @@ + + + + vendor.delay.report.search + vendor.delay.report + + + + + + + + + + + vendor.delay.report.view.graph + vendor.delay.report + + + + + + + + + + On-time Delivery + vendor.delay.report + graph + + Vendor On-time Delivery analysis + current + {'search_default_later_than_a_year_ago':1} + + diff --git a/addons/purchase_stock/security/ir.model.access.csv b/addons/purchase_stock/security/ir.model.access.csv new file mode 100644 index 00000000..faf2bb93 --- /dev/null +++ b/addons/purchase_stock/security/ir.model.access.csv @@ -0,0 +1,15 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_purchase_order_stock_worker,purchase.order,model_purchase_order,stock.group_stock_user,1,0,0,0 +access_purchase_order_line_stock_worker,purchase.order.line,model_purchase_order_line,stock.group_stock_user,1,0,0,0 +access_stock_location_purchase_user,stock.location,stock.model_stock_location,purchase.group_purchase_user,1,0,0,0 +access_stock_warehouse_purchase_user,stock.warehouse,stock.model_stock_warehouse,purchase.group_purchase_user,1,0,0,0 +access_stock_picking_purchase_user,stock.picking,stock.model_stock_picking,purchase.group_purchase_user,1,1,1,1 +access_stock_move_purchase_user,stock.move,stock.model_stock_move,purchase.group_purchase_user,1,1,1,0 +access_stock_location_purchase_user_manager,stock.location,stock.model_stock_location,purchase.group_purchase_manager,1,0,0,0 +access_stock_warehouse_purchase_user_manager,stock.warehouse,stock.model_stock_warehouse,purchase.group_purchase_manager,1,0,0,0 +access_stock_picking_purchase_user_manager,stock.picking,stock.model_stock_picking,purchase.group_purchase_manager,1,1,1,1 +access_stock_move_purchase_user_manager,stock.move,stock.model_stock_move,purchase.group_purchase_manager,1,1,1,1 +access_stock_warehouse_orderpoint_manager,stock.warehouse.orderpoint,stock.model_stock_warehouse_orderpoint,purchase.group_purchase_manager,1,0,0,0 +access_stock_warehouse_orderpoint_user,stock.warehouse.orderpoint,stock.model_stock_warehouse_orderpoint,purchase.group_purchase_user,1,0,0,0 +access_report_purchase_order,vendor.delay.report,model_vendor_delay_report,purchase.group_purchase_manager,1,0,0,0 +access_report_purchase_order_user,vendor.delay.report user,model_vendor_delay_report,purchase.group_purchase_user,1,0,0,0 diff --git a/addons/purchase_stock/static/src/js/tours/purchase_stock.js b/addons/purchase_stock/static/src/js/tours/purchase_stock.js new file mode 100644 index 00000000..44e6eabe --- /dev/null +++ b/addons/purchase_stock/static/src/js/tours/purchase_stock.js @@ -0,0 +1,49 @@ +odoo.define('purchase_stock.purchase_steps', function (require) { +"use strict"; + +var core = require('web.core'); + +var _t = core._t; +var PurchaseAdditionalTourSteps = require('purchase.purchase_steps'); + +PurchaseAdditionalTourSteps.include({ + + init: function() { + this._super.apply(this, arguments); + }, + + _get_purchase_stock_steps: function () { + this._super.apply(this, arguments); + return [{ + trigger: ".oe_button_box button[name='action_view_picking']", + extra_trigger: ".oe_button_box button[name='action_view_picking']", + content: _t("Receive the ordered products."), + position: "bottom", + run: 'click', + }, { + trigger: ".o_statusbar_buttons button[name='button_validate']", + content: _t("Validate the receipt of all ordered products."), + position: "bottom", + run: 'click', + }, { + trigger: ".modal-footer .btn-primary", + extra_trigger: ".modal-dialog", + content: _t("Process all the receipt quantities."), + position: "bottom", + }, { + trigger: ".o_back_button a, .breadcrumb-item:not('.active'):last", + content: _t('Go back to the purchase order to generate the vendor bill.'), + position: 'bottom', + }, { + trigger: ".o_statusbar_buttons button[name='action_create_invoice']", + content: _t("Generate the draft vendor bill."), + position: "bottom", + run: 'click', + } + ]; + } +}); + +return PurchaseAdditionalTourSteps; + +}); diff --git a/addons/purchase_stock/tests/__init__.py b/addons/purchase_stock/tests/__init__.py new file mode 100644 index 00000000..31ce9c71 --- /dev/null +++ b/addons/purchase_stock/tests/__init__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_anglo_saxon_valuation_reconciliation +from . import test_average_price +from . import test_create_picking +from . import test_fifo_price +from . import test_fifo_returns +from . import test_onchange_product +from . import test_purchase_delete_order +from . import test_purchase_lead_time +from . import test_purchase_order +from . import test_purchase_order_process +from . import test_purchase_stock_report +from . import test_stockvaluation +from . import test_replenish_wizard +from . import test_reordering_rule +from . import test_move_cancel_propagation +from . import test_product_template +from . import test_routes diff --git a/addons/purchase_stock/tests/common.py b/addons/purchase_stock/tests/common.py new file mode 100644 index 00000000..f3a98e04 --- /dev/null +++ b/addons/purchase_stock/tests/common.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +from datetime import timedelta + +from odoo import fields +from odoo.addons.stock.tests.common2 import TestStockCommon +from odoo import tools +from odoo.modules.module import get_module_resource + + +class PurchaseTestCommon(TestStockCommon): + + def _create_make_procurement(self, product, product_qty, date_planned=False): + ProcurementGroup = self.env['procurement.group'] + order_values = { + 'warehouse_id': self.warehouse_1, + 'action': 'pull_push', + 'date_planned': date_planned or fields.Datetime.to_string(fields.datetime.now() + timedelta(days=10)), # 10 days added to current date of procurement to get future schedule date and order date of purchase order. + 'group_id': self.env['procurement.group'], + } + return ProcurementGroup.run([self.env['procurement.group'].Procurement( + product, product_qty, self.uom_unit, self.warehouse_1.lot_stock_id, + product.name, '/', self.env.company, order_values) + ]) + + @classmethod + def setUpClass(cls): + super(PurchaseTestCommon, cls).setUpClass() + cls.env.ref('stock.route_warehouse0_mto').active = True + + cls.route_buy = cls.warehouse_1.buy_pull_id.route_id.id + cls.route_mto = cls.warehouse_1.mto_pull_id.route_id.id + + # Update product_1 with type, route and Delivery Lead Time + cls.product_1.write({ + 'type': 'product', + 'route_ids': [(6, 0, [cls.route_buy, cls.route_mto])], + 'seller_ids': [(0, 0, {'name': cls.partner_1.id, 'delay': 5})]}) + + cls.t_shirt = cls.env['product.product'].create({ + 'name': 'T-shirt', + 'route_ids': [(6, 0, [cls.route_buy, cls.route_mto])], + 'seller_ids': [(0, 0, {'name': cls.partner_1.id, 'delay': 5})] + }) + + # Update product_2 with type, route and Delivery Lead Time + cls.product_2.write({ + 'type': 'product', + 'route_ids': [(6, 0, [cls.route_buy, cls.route_mto])], + 'seller_ids': [(0, 0, {'name': cls.partner_1.id, 'delay': 2})]}) + + cls.res_users_purchase_user = cls.env['res.users'].create({ + 'company_id': cls.env.ref('base.main_company').id, + 'name': "Purchase User", + 'login': "pu", + 'email': "purchaseuser@yourcompany.com", + 'groups_id': [(6, 0, [cls.env.ref('purchase.group_purchase_user').id])], + }) diff --git a/addons/purchase_stock/tests/test_anglo_saxon_valuation_reconciliation.py b/addons/purchase_stock/tests/test_anglo_saxon_valuation_reconciliation.py new file mode 100644 index 00000000..b54aedd2 --- /dev/null +++ b/addons/purchase_stock/tests/test_anglo_saxon_valuation_reconciliation.py @@ -0,0 +1,212 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.addons.stock_account.tests.test_anglo_saxon_valuation_reconciliation_common import ValuationReconciliationTestCommon +from odoo.tests.common import Form, tagged + + +@tagged('post_install', '-at_install') +class TestValuationReconciliation(ValuationReconciliationTestCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + cls.stock_account_product_categ.property_account_creditor_price_difference_categ = cls.company_data['default_account_stock_price_diff'] + + @classmethod + def setup_company_data(cls, company_name, chart_template=None, **kwargs): + company_data = super().setup_company_data(company_name, chart_template=chart_template, **kwargs) + + # Create stock config. + company_data.update({ + 'default_account_stock_price_diff': cls.env['account.account'].create({ + 'name': 'default_account_stock_price_diff', + 'code': 'STOCKDIFF', + 'reconcile': True, + 'user_type_id': cls.env.ref('account.data_account_type_current_assets').id, + 'company_id': company_data['company'].id, + }), + }) + return company_data + + def _create_purchase(self, product, date, quantity=1.0, set_tax=False, price_unit=66.0): + rslt = self.env['purchase.order'].create({ + 'partner_id': self.partner_a.id, + 'currency_id': self.currency_data['currency'].id, + 'order_line': [ + (0, 0, { + 'name': product.name, + 'product_id': product.id, + 'product_qty': quantity, + 'product_uom': product.uom_po_id.id, + 'price_unit': price_unit, + 'date_planned': date, + 'taxes_id': [(6, 0, product.supplier_taxes_id.ids)] if set_tax else False, + })], + 'date_order': date, + }) + rslt.button_confirm() + return rslt + + def _create_invoice_for_po(self, purchase_order, date): + move_form = Form(self.env['account.move'].with_context(default_move_type='in_invoice', default_date=date)) + move_form.invoice_date = date + move_form.partner_id = self.partner_a + move_form.currency_id = self.currency_data['currency'] + move_form.purchase_id = purchase_order + return move_form.save() + + def test_shipment_invoice(self): + """ Tests the case into which we receive the goods first, and then make the invoice. + """ + test_product = self.test_product_delivery + date_po_and_delivery = '2018-01-01' + + purchase_order = self._create_purchase(test_product, date_po_and_delivery) + self._process_pickings(purchase_order.picking_ids, date=date_po_and_delivery) + + invoice = self._create_invoice_for_po(purchase_order, '2018-02-02') + invoice.action_post() + picking = self.env['stock.picking'].search([('purchase_id','=',purchase_order.id)]) + self.check_reconciliation(invoice, picking) + # cancel the invoice + invoice.button_cancel() + + def test_invoice_shipment(self): + """ Tests the case into which we make the invoice first, and then receive the goods. + """ + # Create a PO and an invoice for it + test_product = self.test_product_order + purchase_order = self._create_purchase(test_product, '2017-12-01') + + invoice = self._create_invoice_for_po(purchase_order, '2017-12-23') + move_form = Form(invoice) + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.quantity = 1 + invoice = move_form.save() + + # Validate the invoice and refund the goods + invoice.action_post() + self._process_pickings(purchase_order.picking_ids, date='2017-12-24') + picking = self.env['stock.picking'].search([('purchase_id', '=', purchase_order.id)]) + self.check_reconciliation(invoice, picking) + + # Return the goods and refund the invoice + stock_return_picking_form = Form(self.env['stock.return.picking'] + .with_context(active_ids=picking.ids, active_id=picking.ids[0], + active_model='stock.picking')) + stock_return_picking = stock_return_picking_form.save() + stock_return_picking.product_return_moves.quantity = 1.0 + stock_return_picking_action = stock_return_picking.create_returns() + return_pick = self.env['stock.picking'].browse(stock_return_picking_action['res_id']) + return_pick.action_assign() + return_pick.move_lines.quantity_done = 1 + return_pick._action_done() + self._change_pickings_date(return_pick, '2018-01-13') + + # Refund the invoice + refund_invoice_wiz = self.env['account.move.reversal'].with_context(active_model="account.move", active_ids=[invoice.id]).create({ + 'reason': 'test_invoice_shipment_refund', + 'refund_method': 'cancel', + 'date': '2018-03-15', + }) + refund_invoice = self.env['account.move'].browse(refund_invoice_wiz.reverse_moves()['res_id']) + + # Check the result + self.assertEqual(invoice.payment_state, 'reversed', "Invoice should be in 'reversed' state") + self.assertEqual(refund_invoice.payment_state, 'paid', "Refund should be in 'paid' state") + self.check_reconciliation(refund_invoice, return_pick) + + def test_multiple_shipments_invoices(self): + """ Tests the case into which we receive part of the goods first, then 2 invoices at different rates, and finally the remaining quantities + """ + test_product = self.test_product_delivery + date_po_and_delivery0 = '2017-01-01' + purchase_order = self._create_purchase(test_product, date_po_and_delivery0, quantity=5.0) + self._process_pickings(purchase_order.picking_ids, quantity=2.0, date=date_po_and_delivery0) + picking = self.env['stock.picking'].search([('purchase_id', '=', purchase_order.id)], order="id asc", limit=1) + + invoice = self._create_invoice_for_po(purchase_order, '2017-01-15') + move_form = Form(invoice) + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.quantity = 3.0 + invoice = move_form.save() + invoice.action_post() + self.check_reconciliation(invoice, picking, full_reconcile=False) + + invoice2 = self._create_invoice_for_po(purchase_order, '2017-02-15') + move_form = Form(invoice2) + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.quantity = 2.0 + invoice2 = move_form.save() + invoice2.action_post() + self.check_reconciliation(invoice2, picking, full_reconcile=False) + + # We don't need to make the date of processing explicit since the very last rate + # will be taken + self._process_pickings(purchase_order.picking_ids.filtered(lambda x: x.state != 'done'), quantity=3.0) + picking = self.env['stock.picking'].search([('purchase_id', '=', purchase_order.id)], order='id desc', limit=1) + self.check_reconciliation(invoice2, picking) + + def test_rounding_discount(self): + self.env.ref("product.decimal_discount").digits = 5 + tax_exclude_id = self.env["account.tax"].create( + { + "name": "Exclude tax", + "amount": "0.00", + "type_tax_use": "purchase", + } + ) + + test_product = self.test_product_delivery + test_product.supplier_taxes_id = [(6, 0, tax_exclude_id.ids)] + date_po_and_delivery = '2018-01-01' + + purchase_order = self._create_purchase(test_product, date_po_and_delivery, quantity=10000, set_tax=True) + self._process_pickings(purchase_order.picking_ids, date=date_po_and_delivery) + + invoice = self._create_invoice_for_po(purchase_order, '2018-01-01') + + # Set a discount + move_form = Form(invoice) + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.discount = 0.92431 + move_form.save() + + invoice.action_post() + + # Check the price difference amount. + price_diff_line = invoice.line_ids.filtered(lambda l: l.account_id == self.stock_account_product_categ.property_account_creditor_price_difference_categ) + self.assertTrue(len(price_diff_line) == 1, "A price difference line should be created") + self.assertAlmostEqual(price_diff_line.price_total, -6100.446) + + picking = self.env['stock.picking'].search([('purchase_id','=',purchase_order.id)]) + self.check_reconciliation(invoice, picking) + + def test_rounding_price_unit(self): + self.env.ref("product.decimal_price").digits = 6 + + test_product = self.test_product_delivery + date_po_and_delivery = '2018-01-01' + + purchase_order = self._create_purchase(test_product, date_po_and_delivery, quantity=1000000, price_unit=0.0005) + self._process_pickings(purchase_order.picking_ids, date=date_po_and_delivery) + + invoice = self._create_invoice_for_po(purchase_order, '2018-01-01') + + # Set a discount + move_form = Form(invoice) + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.price_unit = 0.0006 + move_form.save() + + invoice.action_post() + + # Check the price difference amount. It's expected that price_unit * qty != price_total. + price_diff_line = invoice.line_ids.filtered(lambda l: l.account_id == self.stock_account_product_categ.property_account_creditor_price_difference_categ) + self.assertTrue(len(price_diff_line) == 1, "A price difference line should be created") + self.assertAlmostEqual(price_diff_line.price_unit, 0.0001) + self.assertAlmostEqual(price_diff_line.price_total, 100.0) + + picking = self.env['stock.picking'].search([('purchase_id','=',purchase_order.id)]) + self.check_reconciliation(invoice, picking) diff --git a/addons/purchase_stock/tests/test_average_price.py b/addons/purchase_stock/tests/test_average_price.py new file mode 100644 index 00000000..031fe0d2 --- /dev/null +++ b/addons/purchase_stock/tests/test_average_price.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.addons.stock_account.tests.test_anglo_saxon_valuation_reconciliation_common import ValuationReconciliationTestCommon +from odoo.tests import tagged, Form + +import time + + +@tagged('-at_install', 'post_install') +class TestAveragePrice(ValuationReconciliationTestCommon): + + def test_00_average_price(self): + """ Testcase for average price computation""" + + res_partner_3 = self.env['res.partner'].create({ + 'name': 'Gemini Partner', + }) + + # Set a product as using average price. + product_cable_management_box = self.env['product.product'].create({ + 'default_code': 'AVG', + 'name': 'Average Ice Cream', + 'type': 'product', + 'categ_id': self.stock_account_product_categ.id, + 'list_price': 100.0, + 'standard_price': 60.0, + 'uom_id': self.env.ref('uom.product_uom_kgm').id, + 'uom_po_id': self.env.ref('uom.product_uom_kgm').id, + 'supplier_taxes_id': [], + 'description': 'FIFO Ice Cream', + }) + product_cable_management_box.categ_id.property_cost_method = 'average' + + # I create a draft Purchase Order for first incoming shipment for 10 pieces at 60€ + purchase_order_1 = self.env['purchase.order'].create({ + 'partner_id': res_partner_3.id, + 'order_line': [(0, 0, { + 'name': 'Average Ice Cream', + 'product_id': product_cable_management_box.id, + 'product_qty': 10.0, + 'product_uom': self.env.ref('uom.product_uom_kgm').id, + 'price_unit': 60.0, + 'date_planned': time.strftime('%Y-%m-%d'), + })] + }) + + # Confirm the first purchase order + purchase_order_1.button_confirm() + + # Check the "Approved" status of purchase order 1 + self.assertEqual(purchase_order_1.state, 'purchase', "Wrong state of purchase order!") + + # Process the reception of purchase order 1 + picking = purchase_order_1.picking_ids[0] + res = picking.button_validate() + Form(self.env[res['res_model']].with_context(res['context'])).save().process() + + # Check the average_price of the product (average icecream). + self.assertEqual(product_cable_management_box.qty_available, 10.0, 'Wrong quantity in stock after first reception') + self.assertEqual(product_cable_management_box.standard_price, 60.0, 'Standard price should be the price of the first reception!') + + # I create a draft Purchase Order for second incoming shipment for 30 pieces at 80€ + purchase_order_2 = self.env['purchase.order'].create({ + 'partner_id': res_partner_3.id, + 'order_line': [(0, 0, { + 'name': product_cable_management_box.name, + 'product_id': product_cable_management_box.id, + 'product_qty': 30.0, + 'product_uom': self.env.ref('uom.product_uom_kgm').id, + 'price_unit': 80.0, + 'date_planned': time.strftime('%Y-%m-%d'), + })] + }) + + # Confirm the second purchase order + purchase_order_2.button_confirm() + # Process the reception of purchase order 2 + picking = purchase_order_2.picking_ids[0] + res = picking.button_validate() + Form(self.env['stock.immediate.transfer'].with_context(res['context'])).save().process() + + # Check the standard price + self.assertEqual(product_cable_management_box.standard_price, 75.0, 'After second reception, we should have an average price of 75.0 on the product') + + # Create picking to send some goods + outgoing_shipment = self.env['stock.picking'].create({ + 'picking_type_id': self.company_data['default_warehouse'].out_type_id.id, + 'location_id': self.company_data['default_warehouse'].lot_stock_id.id, + 'location_dest_id': self.env.ref('stock.stock_location_customers').id, + 'move_lines': [(0, 0, { + 'name': 'outgoing_shipment_avg_move', + 'product_id': product_cable_management_box.id, + 'product_uom_qty': 20.0, + 'product_uom': self.env.ref('uom.product_uom_kgm').id, + 'location_id': self.company_data['default_warehouse'].lot_stock_id.id, + 'location_dest_id': self.env.ref('stock.stock_location_customers').id})] + }) + + # Assign this outgoing shipment and process the delivery + outgoing_shipment.action_assign() + res = outgoing_shipment.button_validate() + Form(self.env['stock.immediate.transfer'].with_context(res['context'])).save().process() + + # Check the average price (60 * 10 + 30 * 80) / 40 = 75.0€ did not change + self.assertEqual(product_cable_management_box.standard_price, 75.0, 'Average price should not have changed with outgoing picking!') + self.assertEqual(product_cable_management_box.qty_available, 20.0, 'Pieces were not picked correctly as the quantity on hand is wrong') + + # Make a new purchase order with 500 g Average Ice Cream at a price of 0.2€/g + purchase_order_3 = self.env['purchase.order'].create({ + 'partner_id': res_partner_3.id, + 'order_line': [(0, 0, { + 'name': product_cable_management_box.name, + 'product_id': product_cable_management_box.id, + 'product_qty': 500.0, + 'product_uom': self.ref('uom.product_uom_gram'), + 'price_unit': 0.2, + 'date_planned': time.strftime('%Y-%m-%d'), + })] + }) + + # Confirm the first purchase order + purchase_order_3.button_confirm() + # Process the reception of purchase order 3 in grams + + picking = purchase_order_3.picking_ids[0] + res = picking.button_validate() + Form(self.env[res['res_model']].with_context(res['context'])).save().process() + + # Check price is (75.0 * 20 + 200*0.5) / 20.5 = 78.04878€ + self.assertEqual(product_cable_management_box.qty_available, 20.5, 'Reception of purchase order in grams leads to wrong quantity in stock') + self.assertEqual(round(product_cable_management_box.standard_price, 2), 78.05, + 'Standard price as average price of third reception with other UoM incorrect! Got %s instead of 78.05' % (round(product_cable_management_box.standard_price, 2))) diff --git a/addons/purchase_stock/tests/test_create_picking.py b/addons/purchase_stock/tests/test_create_picking.py new file mode 100644 index 00000000..9ab50671 --- /dev/null +++ b/addons/purchase_stock/tests/test_create_picking.py @@ -0,0 +1,516 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import date, datetime, timedelta + +from odoo.addons.mail.tests.common import mail_new_test_user +from odoo.addons.product.tests import common +from odoo.tests import Form +from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT + + +class TestCreatePicking(common.TestProductCommon): + + def setUp(self): + super(TestCreatePicking, self).setUp() + self.partner_id = self.env['res.partner'].create({'name': 'Wood Corner Partner'}) + self.product_id_1 = self.env['product.product'].create({'name': 'Large Desk'}) + self.product_id_2 = self.env['product.product'].create({'name': 'Conference Chair'}) + + self.user_purchase_user = mail_new_test_user( + self.env, + name='Pauline Poivraisselle', + login='pauline', + email='pur@example.com', + notification_type='inbox', + groups='purchase.group_purchase_user', + ) + + self.po_vals = { + 'partner_id': self.partner_id.id, + 'order_line': [ + (0, 0, { + 'name': self.product_id_1.name, + 'product_id': self.product_id_1.id, + 'product_qty': 5.0, + 'product_uom': self.product_id_1.uom_po_id.id, + 'price_unit': 500.0, + 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT), + })], + } + + def test_00_create_picking(self): + + # Draft purchase order created + self.po = self.env['purchase.order'].create(self.po_vals) + self.assertTrue(self.po, 'Purchase: no purchase order created') + + # Purchase order confirm + self.po.button_confirm() + self.assertEqual(self.po.state, 'purchase', 'Purchase: PO state should be "Purchase') + self.assertEqual(self.po.picking_count, 1, 'Purchase: one picking should be created') + self.assertEqual(len(self.po.order_line.move_ids), 1, 'One move should be created') + # Change purchase order line product quantity + self.po.order_line.write({'product_qty': 7.0}) + self.assertEqual(len(self.po.order_line.move_ids), 1, 'The two moves should be merged in one') + + # Validate first shipment + self.picking = self.po.picking_ids[0] + for ml in self.picking.move_line_ids: + ml.qty_done = ml.product_uom_qty + self.picking._action_done() + self.assertEqual(self.po.order_line.mapped('qty_received'), [7.0], 'Purchase: all products should be received') + + + # create new order line + self.po.write({'order_line': [ + (0, 0, { + 'name': self.product_id_2.name, + 'product_id': self.product_id_2.id, + 'product_qty': 5.0, + 'product_uom': self.product_id_2.uom_po_id.id, + 'price_unit': 250.0, + 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT), + })]}) + self.assertEqual(self.po.picking_count, 2, 'New picking should be created') + moves = self.po.order_line.mapped('move_ids').filtered(lambda x: x.state not in ('done', 'cancel')) + self.assertEqual(len(moves), 1, 'One moves should have been created') + + def test_01_check_double_validation(self): + + # make double validation two step + self.env.company.write({'po_double_validation': 'two_step','po_double_validation_amount':2000.00}) + + # Draft purchase order created + self.po = self.env['purchase.order'].with_user(self.user_purchase_user).create(self.po_vals) + self.assertTrue(self.po, 'Purchase: no purchase order created') + + # Purchase order confirm + self.po.button_confirm() + self.assertEqual(self.po.state, 'to approve', 'Purchase: PO state should be "to approve".') + + # PO approved by manager + self.po.env.user.groups_id += self.env.ref("purchase.group_purchase_manager") + self.po.button_approve() + self.assertEqual(self.po.state, 'purchase', 'PO state should be "Purchase".') + + def test_02_check_mto_chain(self): + """ Simulate a mto chain with a purchase order. Cancel the + purchase order should also change the procure_method of the + following move to MTS in order to be able to link it to a + manually created purchase order. + """ + stock_location = self.env['ir.model.data'].xmlid_to_object('stock.stock_location_stock') + customer_location = self.env['ir.model.data'].xmlid_to_object('stock.stock_location_customers') + # route buy should be there by default + partner = self.env['res.partner'].create({ + 'name': 'Jhon' + }) + + vendor = self.env['res.partner'].create({ + 'name': 'Roger' + }) + + seller = self.env['product.supplierinfo'].create({ + 'name': partner.id, + 'price': 12.0, + }) + + product = self.env['product.product'].create({ + 'name': 'product', + 'type': 'product', + 'route_ids': [(4, self.ref('stock.route_warehouse0_mto')), (4, self.ref('purchase_stock.route_warehouse0_buy'))], + 'seller_ids': [(6, 0, [seller.id])], + 'categ_id': self.env.ref('product.product_category_all').id, + 'supplier_taxes_id': [(6, 0, [])], + }) + + customer_move = self.env['stock.move'].create({ + 'name': 'move out', + 'location_id': stock_location.id, + 'location_dest_id': customer_location.id, + 'product_id': product.id, + 'product_uom': product.uom_id.id, + 'product_uom_qty': 100.0, + 'procure_method': 'make_to_order', + }) + + customer_move._action_confirm() + + purchase_order = self.env['purchase.order'].search([('partner_id', '=', partner.id)]) + self.assertTrue(purchase_order, 'No purchase order created.') + + # Check purchase order line data. + purchase_order_line = purchase_order.order_line + self.assertEqual(purchase_order_line.product_id, product, 'The product on the purchase order line is not correct.') + self.assertEqual(purchase_order_line.price_unit, seller.price, 'The purchase order line price should be the same as the seller.') + self.assertEqual(purchase_order_line.product_qty, customer_move.product_uom_qty, 'The purchase order line qty should be the same as the move.') + self.assertEqual(purchase_order_line.price_subtotal, 1200.0, 'The purchase order line subtotal should be equal to the move qty * seller price.') + + purchase_order.button_cancel() + self.assertEqual(purchase_order.state, 'cancel', 'Purchase order should be cancelled.') + self.assertEqual(customer_move.procure_method, 'make_to_stock', 'Customer move should be passed to mts.') + + purchase = purchase_order.create({ + 'partner_id': vendor.id, + 'order_line': [ + (0, 0, { + 'name': product.name, + 'product_id': product.id, + 'product_qty': 100.0, + 'product_uom': product.uom_po_id.id, + 'price_unit': 11.0, + 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT), + })], + }) + self.assertTrue(purchase, 'RFQ should be created') + purchase.button_confirm() + + picking = purchase.picking_ids + self.assertTrue(picking, 'Picking should be created') + + # Process pickings + picking.action_confirm() + picking.move_lines.quantity_done = 100.0 + picking.button_validate() + + # mts move will be automatically assigned + self.assertEqual(customer_move.state, 'assigned', 'Automatically assigned due to the incoming move makes it available.') + self.assertEqual(self.env['stock.quant']._get_available_quantity(product, stock_location), 0.0, 'Wrong quantity in stock.') + + def test_03_uom(self): + """ Buy a dozen of products stocked in units. Check that the quantities on the purchase order + lines as well as the received quantities are handled in dozen while the moves themselves + are handled in units. Edit the ordered quantities, check that the quantites are correctly + updated on the moves. Edit the ir.config_parameter to propagate the uom of the purchase order + lines to the moves and edit a last time the ordered quantities. Receive, check the quantities. + """ + uom_unit = self.env.ref('uom.product_uom_unit') + uom_dozen = self.env.ref('uom.product_uom_dozen') + + self.assertEqual(self.product_id_1.uom_po_id.id, uom_unit.id) + + # buy a dozen + po = self.env['purchase.order'].create(self.po_vals) + + po.order_line.product_qty = 1 + po.order_line.product_uom = uom_dozen.id + po.button_confirm() + + # the move should be 12 units + # note: move.product_qty = computed field, always in the uom of the quant + # move.product_uom_qty = stored field representing the initial demand in move.product_uom + move1 = po.picking_ids.move_lines.sorted()[0] + self.assertEqual(move1.product_uom_qty, 12) + self.assertEqual(move1.product_uom.id, uom_unit.id) + self.assertEqual(move1.product_qty, 12) + + # edit the so line, sell 2 dozen, the move should now be 24 units + po.order_line.product_qty = 2 + move1 = po.picking_ids.move_lines.sorted()[0] + self.assertEqual(move1.product_uom_qty, 24) + self.assertEqual(move1.product_uom.id, uom_unit.id) + self.assertEqual(move1.product_qty, 24) + + # force the propagation of the uom, sell 3 dozen + self.env['ir.config_parameter'].sudo().set_param('stock.propagate_uom', '1') + po.order_line.product_qty = 3 + move2 = po.picking_ids.move_lines.filtered(lambda m: m.product_uom.id == uom_dozen.id) + self.assertEqual(move2.product_uom_qty, 1) + self.assertEqual(move2.product_uom.id, uom_dozen.id) + self.assertEqual(move2.product_qty, 12) + + # deliver everything + move1.quantity_done = 24 + move2.quantity_done = 1 + po.picking_ids.button_validate() + + # check the delivered quantity + self.assertEqual(po.order_line.qty_received, 3.0) + + def test_04_mto_multiple_po(self): + """ Simulate a mto chain with 2 purchase order. + Create a move with qty 1, confirm the RFQ then create a new + move that will not be merged in the first one(simulate an increase + order quantity on a SO). It should generate a new RFQ, validate + and receipt the picking then try to reserve the delivery + picking. + """ + stock_location = self.env['ir.model.data'].xmlid_to_object('stock.stock_location_stock') + customer_location = self.env['ir.model.data'].xmlid_to_object('stock.stock_location_customers') + picking_type_out = self.env['ir.model.data'].xmlid_to_object('stock.picking_type_out') + # route buy should be there by default + partner = self.env['res.partner'].create({ + 'name': 'Jhon' + }) + + seller = self.env['product.supplierinfo'].create({ + 'name': partner.id, + 'price': 12.0, + }) + + product = self.env['product.product'].create({ + 'name': 'product', + 'type': 'product', + 'route_ids': [(4, self.ref('stock.route_warehouse0_mto')), (4, self.ref('purchase_stock.route_warehouse0_buy'))], + 'seller_ids': [(6, 0, [seller.id])], + 'categ_id': self.env.ref('product.product_category_all').id, + }) + + # A picking is require since only moves inside the same picking are merged. + customer_picking = self.env['stock.picking'].create({ + 'location_id': stock_location.id, + 'location_dest_id': customer_location.id, + 'partner_id': partner.id, + 'picking_type_id': picking_type_out.id, + }) + + customer_move = self.env['stock.move'].create({ + 'name': 'move out', + 'location_id': stock_location.id, + 'location_dest_id': customer_location.id, + 'product_id': product.id, + 'product_uom': product.uom_id.id, + 'product_uom_qty': 80.0, + 'procure_method': 'make_to_order', + 'picking_id': customer_picking.id, + }) + + customer_move._action_confirm() + + purchase_order = self.env['purchase.order'].search([('partner_id', '=', partner.id)]) + self.assertTrue(purchase_order, 'No purchase order created.') + + # Check purchase order line data. + purchase_order_line = purchase_order.order_line + self.assertEqual(purchase_order_line.product_id, product, 'The product on the purchase order line is not correct.') + self.assertEqual(purchase_order_line.price_unit, seller.price, 'The purchase order line price should be the same as the seller.') + self.assertEqual(purchase_order_line.product_qty, customer_move.product_uom_qty, 'The purchase order line qty should be the same as the move.') + + purchase_order.button_confirm() + + customer_move_2 = self.env['stock.move'].create({ + 'name': 'move out', + 'location_id': stock_location.id, + 'location_dest_id': customer_location.id, + 'product_id': product.id, + 'product_uom': product.uom_id.id, + 'product_uom_qty': 20.0, + 'procure_method': 'make_to_order', + 'picking_id': customer_picking.id, + }) + + customer_move_2._action_confirm() + + self.assertTrue(customer_move_2.exists(), 'The second customer move should not be merged in the first.') + self.assertEqual(sum(customer_picking.move_lines.mapped('product_uom_qty')), 100.0) + + purchase_order_2 = self.env['purchase.order'].search([('partner_id', '=', partner.id), ('state', '=', 'draft')]) + self.assertTrue(purchase_order_2, 'No purchase order created.') + + purchase_order_2.button_confirm() + + purchase_order.picking_ids.move_lines.quantity_done = 80.0 + purchase_order.picking_ids.button_validate() + + purchase_order_2.picking_ids.move_lines.quantity_done = 20.0 + purchase_order_2.picking_ids.button_validate() + + self.assertEqual(sum(customer_picking.move_lines.mapped('reserved_availability')), 100.0, 'The total quantity for the customer move should be available and reserved.') + + def test_04_rounding(self): + """ We set the Unit(s) rounding to 1.0 and ensure buying 1.2 units in a PO is rounded to 1.0 + at reception. + """ + uom_unit = self.env.ref('uom.product_uom_unit') + uom_unit.rounding = 1.0 + + # buy a dozen + po = self.env['purchase.order'].create(self.po_vals) + + po.order_line.product_qty = 1.2 + po.button_confirm() + + # the move should be 1.0 units + move1 = po.picking_ids.move_lines[0] + self.assertEqual(move1.product_uom_qty, 1.0) + self.assertEqual(move1.product_uom.id, uom_unit.id) + self.assertEqual(move1.product_qty, 1.0) + + # edit the so line, buy 2.4 units, the move should now be 2.0 units + po.order_line.product_qty = 2.0 + self.assertEqual(move1.product_uom_qty, 2.0) + self.assertEqual(move1.product_uom.id, uom_unit.id) + self.assertEqual(move1.product_qty, 2.0) + + # deliver everything + move1.quantity_done = 2.0 + po.picking_ids.button_validate() + + # check the delivered quantity + self.assertEqual(po.order_line.qty_received, 2.0) + + def test_05_uom_rounding(self): + """ We set the Unit(s) and Dozen(s) rounding to 1.0 and ensure buying 1.3 dozens in a PO is + rounded to 1.0 at reception. + """ + uom_unit = self.env.ref('uom.product_uom_unit') + uom_dozen = self.env.ref('uom.product_uom_dozen') + uom_unit.rounding = 1.0 + uom_dozen.rounding = 1.0 + + # buy 1.3 dozen + po = self.env['purchase.order'].create(self.po_vals) + + po.order_line.product_qty = 1.3 + po.order_line.product_uom = uom_dozen.id + po.button_confirm() + + # the move should be 16.0 units + move1 = po.picking_ids.move_lines[0] + self.assertEqual(move1.product_uom_qty, 16.0) + self.assertEqual(move1.product_uom.id, uom_unit.id) + self.assertEqual(move1.product_qty, 16.0) + + # force the propagation of the uom, buy 2.6 dozens, the move 2 should have 2 dozens + self.env['ir.config_parameter'].sudo().set_param('stock.propagate_uom', '1') + po.order_line.product_qty = 2.6 + move2 = po.picking_ids.move_lines.filtered(lambda m: m.product_uom.id == uom_dozen.id) + self.assertEqual(move2.product_uom_qty, 2) + self.assertEqual(move2.product_uom.id, uom_dozen.id) + self.assertEqual(move2.product_qty, 24) + + def create_delivery_order(self): + stock_location = self.env['ir.model.data'].xmlid_to_object('stock.stock_location_stock') + customer_location = self.env['ir.model.data'].xmlid_to_object('stock.stock_location_customers') + unit = self.ref("uom.product_uom_unit") + picking_type_out = self.env['ir.model.data'].xmlid_to_object('stock.picking_type_out') + partner = self.env['res.partner'].create({'name': 'AAA', 'email': 'from.test@example.com'}) + supplier_info1 = self.env['product.supplierinfo'].create({ + 'name': partner.id, + 'price': 50, + }) + + warehouse1 = self.env.ref('stock.warehouse0') + route_buy = warehouse1.buy_pull_id.route_id + route_mto = warehouse1.mto_pull_id.route_id + + product = self.env['product.product'].create({ + 'name': 'Usb Keyboard', + 'type': 'product', + 'uom_id': unit, + 'uom_po_id': unit, + 'seller_ids': [(6, 0, [supplier_info1.id])], + 'route_ids': [(6, 0, [route_buy.id, route_mto.id])] + }) + + delivery_order = self.env['stock.picking'].create({ + 'location_id': stock_location.id, + 'location_dest_id': customer_location.id, + 'partner_id': partner.id, + 'picking_type_id': picking_type_out.id, + }) + + customer_move = self.env['stock.move'].create({ + 'name': 'move out', + 'location_id': stock_location.id, + 'location_dest_id': customer_location.id, + 'product_id': product.id, + 'product_uom': product.uom_id.id, + 'product_uom_qty': 10.0, + 'procure_method': 'make_to_order', + 'picking_id': delivery_order.id, + }) + + customer_move._action_confirm() + # find created po the product + purchase_order = self.env['purchase.order'].search([('partner_id', '=', partner.id)]) + + return delivery_order, purchase_order + + def test_05_propagate_deadline(self): + """ In order to check deadline date of the delivery order is changed and the planned date not.""" + + # Create Delivery Order and with propagate date and minimum delta + delivery_order, purchase_order = self.create_delivery_order() + + # check po is created or not + self.assertTrue(purchase_order, 'No purchase order created.') + + purchase_order_line = purchase_order.order_line + + # change scheduled date of po line. + purchase_order_line.write({'date_planned': purchase_order_line.date_planned + timedelta(days=5)}) + + # Now check scheduled date and deadline of delivery order. + self.assertNotEqual( + purchase_order_line.date_planned, delivery_order.scheduled_date, + 'Scheduled delivery order date should not changed.') + self.assertEqual( + purchase_order_line.date_planned, delivery_order.date_deadline, + 'Delivery deadline date should be changed.') + + def test_07_differed_schedule_date(self): + warehouse = self.env['stock.warehouse'].search([], limit=1) + + with Form(warehouse) as w: + w.reception_steps = 'three_steps' + po_form = Form(self.env['purchase.order']) + po_form.partner_id = self.partner_id + with po_form.order_line.new() as line: + line.product_id = self.product_id_1 + line.date_planned = datetime.today() + line.product_qty = 1.0 + with po_form.order_line.new() as line: + line.product_id = self.product_id_1 + line.date_planned = datetime.today() + timedelta(days=7) + line.product_qty = 1.0 + po = po_form.save() + + po.button_approve() + + po.picking_ids.move_line_ids.write({ + 'qty_done': 1.0 + }) + po.picking_ids.button_validate() + + pickings = self.env['stock.picking'].search([('group_id', '=', po.group_id.id)]) + for picking in pickings: + self.assertEqual(picking.scheduled_date.date(), date.today()) + + def test_update_quantity_and_return(self): + po = self.env['purchase.order'].create(self.po_vals) + + po.order_line.product_qty = 10 + po.button_confirm() + + first_picking = po.picking_ids + first_picking.move_lines.quantity_done = 5 + # create the backorder + backorder_wizard_dict = first_picking.button_validate() + backorder_wizard = Form(self.env[backorder_wizard_dict['res_model']].with_context(backorder_wizard_dict['context'])).save() + backorder_wizard.process() + + self.assertEqual(len(po.picking_ids), 2) + + # Create a partial return + stock_return_picking_form = Form( + self.env['stock.return.picking'].with_context( + active_ids=first_picking.ids, + active_id=first_picking.ids[0], + active_model='stock.picking' + ) + ) + stock_return_picking = stock_return_picking_form.save() + stock_return_picking.product_return_moves.quantity = 2.0 + stock_return_picking_action = stock_return_picking.create_returns() + return_pick = self.env['stock.picking'].browse(stock_return_picking_action['res_id']) + return_pick.action_assign() + return_pick.move_lines.quantity_done = 2 + return_pick._action_done() + + self.assertEqual(po.order_line.qty_received, 3) + + po.order_line.product_qty += 2 + backorder = po.picking_ids.filtered(lambda picking: picking.state == 'assigned') + self.assertEqual(backorder.move_lines.product_uom_qty, 9) diff --git a/addons/purchase_stock/tests/test_fifo_price.py b/addons/purchase_stock/tests/test_fifo_price.py new file mode 100644 index 00000000..848d967c --- /dev/null +++ b/addons/purchase_stock/tests/test_fifo_price.py @@ -0,0 +1,366 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.addons.stock_account.tests.test_anglo_saxon_valuation_reconciliation_common import ValuationReconciliationTestCommon +from odoo.tests import tagged, Form + +import time + + +@tagged('-at_install', 'post_install') +class TestFifoPrice(ValuationReconciliationTestCommon): + + def test_00_test_fifo(self): + """ Test product cost price with fifo removal strategy.""" + + res_partner_3 = self.env['res.partner'].create({ + 'name': 'Gemini Partner', + }) + + # Set a product as using fifo price + product_cable_management_box = self.env['product.product'].create({ + 'default_code': 'FIFO', + 'name': 'FIFO Ice Cream', + 'type': 'product', + 'categ_id': self.stock_account_product_categ.id, + 'list_price': 100.0, + 'standard_price': 70.0, + 'uom_id': self.env.ref('uom.product_uom_kgm').id, + 'uom_po_id': self.env.ref('uom.product_uom_kgm').id, + 'supplier_taxes_id': [], + 'description': 'FIFO Ice Cream', + }) + + # I create a draft Purchase Order for first in move for 10 kg at 50 euro + purchase_order_1 = self.env['purchase.order'].create({ + 'partner_id': res_partner_3.id, + 'order_line': [(0, 0, { + 'name': 'FIFO Ice Cream', + 'product_id': product_cable_management_box.id, + 'product_qty': 10.0, + 'product_uom': self.env.ref('uom.product_uom_kgm').id, + 'price_unit': 50.0, + 'date_planned': time.strftime('%Y-%m-%d')})], + }) + + # Confirm the first purchase order + purchase_order_1.button_confirm() + + # Check the "Purchase" status of purchase order 1 + self.assertEqual(purchase_order_1.state, 'purchase') + + # Process the reception of purchase order 1 and set date + picking = purchase_order_1.picking_ids[0] + res = picking.button_validate() + Form(self.env[res['res_model']].with_context(res['context'])).save().process() + + # Check the standard price of the product (fifo icecream), that should have changed + # because the unit cost of the purchase order is 50 + self.assertAlmostEqual(product_cable_management_box.standard_price, 50.0) + self.assertEqual(product_cable_management_box.value_svl, 500.0, 'Wrong stock value') + + # I create a draft Purchase Order for second shipment for 30 kg at 80 euro + purchase_order_2 = self.env['purchase.order'].create({ + 'partner_id': res_partner_3.id, + 'order_line': [(0, 0, { + 'name': 'FIFO Ice Cream', + 'product_id': product_cable_management_box.id, + 'product_qty': 30.0, + 'product_uom': self.env.ref('uom.product_uom_kgm').id, + 'price_unit': 80.0, + 'date_planned': time.strftime('%Y-%m-%d')})], + }) + + # Confirm the second purchase order + purchase_order_2.button_confirm() + + # Process the reception of purchase order 2 + picking = purchase_order_2.picking_ids[0] + res = picking.button_validate() + Form(self.env[res['res_model']].with_context(res['context'])).save().process() + + # Check the standard price of the product, that should have not changed because we + # still have icecream in stock + self.assertEqual(product_cable_management_box.standard_price, 50.0, 'Standard price as fifo price of second reception incorrect!') + self.assertEqual(product_cable_management_box.value_svl, 2900.0, 'Stock valuation should be 2900') + + # Let us send some goods + outgoing_shipment = self.env['stock.picking'].create({ + 'picking_type_id': self.company_data['default_warehouse'].out_type_id.id, + 'location_id': self.company_data['default_warehouse'].lot_stock_id.id, + 'location_dest_id': self.env.ref('stock.stock_location_customers').id, + 'move_lines': [(0, 0, { + 'name': product_cable_management_box.name, + 'product_id': product_cable_management_box.id, + 'product_uom_qty': 20.0, + 'product_uom': self.env.ref('uom.product_uom_kgm').id, + 'location_id': self.company_data['default_warehouse'].lot_stock_id.id, + 'location_dest_id': self.env.ref('stock.stock_location_customers').id, + 'picking_type_id': self.company_data['default_warehouse'].out_type_id.id})] + }) + + # I assign this outgoing shipment + outgoing_shipment.action_assign() + + # Process the delivery of the outgoing shipment + res = outgoing_shipment.button_validate() + Form(self.env[res['res_model']].with_context(res['context'])).save().process() + + # Check stock value became 1600 . + self.assertEqual(product_cable_management_box.value_svl, 1600.0, 'Stock valuation should be 1600') + + # Do a delivery of an extra 500 g (delivery order) + outgoing_shipment_uom = self.env['stock.picking'].create({ + 'picking_type_id': self.company_data['default_warehouse'].out_type_id.id, + 'location_id': self.company_data['default_warehouse'].lot_stock_id.id, + 'location_dest_id': self.env.ref('stock.stock_location_customers').id, + 'move_lines': [(0, 0, { + 'name': product_cable_management_box.name, + 'product_id': product_cable_management_box.id, + 'product_uom_qty': 500.0, + 'product_uom': self.env.ref('uom.product_uom_gram').id, + 'location_id': self.company_data['default_warehouse'].lot_stock_id.id, + 'location_dest_id': self.env.ref('stock.stock_location_customers').id, + 'picking_type_id': self.company_data['default_warehouse'].out_type_id.id})] + }) + + # I assign this outgoing shipment + outgoing_shipment_uom.action_assign() + + # Process the delivery of the outgoing shipment + res = outgoing_shipment_uom.button_validate() + Form(self.env[res['res_model']].with_context(res['context'])).save().process() + + # Check stock valuation and qty in stock + self.assertEqual(product_cable_management_box.value_svl, 1560.0, 'Stock valuation should be 1560') + self.assertEqual(product_cable_management_box.qty_available, 19.5, 'Should still have 19.5 in stock') + + # We will temporarily change the currency rate on the sixth of June to have the same results all year + NewUSD = self.env['res.currency'].create({ + 'name': 'new_usd', + 'symbol': '$²', + 'rate_ids': [(0, 0, {'rate': 1.2834, 'name': time.strftime('%Y-%m-%d')})], + }) + + # Create PO for 30000 g at 0.150$/g and 10 kg at 150$/kg + purchase_order_usd = self.env['purchase.order'].create({ + 'partner_id': res_partner_3.id, + 'currency_id': NewUSD.id, + 'order_line': [(0, 0, { + 'name': 'FIFO Ice Cream', + 'product_id': product_cable_management_box.id, + 'product_qty': 30, + 'product_uom': self.env.ref('uom.product_uom_kgm').id, + 'price_unit': 0.150, + 'date_planned': time.strftime('%Y-%m-%d')}), + (0, 0, { + 'name': product_cable_management_box.name, + 'product_id': product_cable_management_box.id, + 'product_qty': 10.0, + 'product_uom': self.env.ref('uom.product_uom_kgm').id, + 'price_unit': 150.0, + 'date_planned': time.strftime('%Y-%m-%d')})] + }) + + # Confirm the purchase order in USD + purchase_order_usd.button_confirm() + # Process the reception of purchase order with USD + picking = purchase_order_usd.picking_ids[0] + res = picking.button_validate() + Form(self.env[res['res_model']].with_context(res['context'])).save().process() + + # Create delivery order of 49.5 kg + outgoing_shipment_cur = self.env['stock.picking'].create({ + 'picking_type_id': self.company_data['default_warehouse'].out_type_id.id, + 'location_id': self.company_data['default_warehouse'].lot_stock_id.id, + 'location_dest_id': self.env.ref('stock.stock_location_customers').id, + 'move_lines': [(0, 0, { + 'name': product_cable_management_box.name, + 'product_id': product_cable_management_box.id, + 'product_uom_qty': 49.5, + 'product_uom': self.env.ref('uom.product_uom_kgm').id, + 'location_id': self.company_data['default_warehouse'].lot_stock_id.id, + 'location_dest_id': self.env.ref('stock.stock_location_customers').id, + 'picking_type_id': self.company_data['default_warehouse'].out_type_id.id})] + }) + + # I assign this outgoing shipment + outgoing_shipment_cur.action_assign() + + # Process the delivery of the outgoing shipment + res = outgoing_shipment_cur.button_validate() + Form(self.env[res['res_model']].with_context(res['context'])).save().process() + + # Do a delivery of an extra 10 kg + outgoing_shipment_ret = self.env['stock.picking'].create({ + 'picking_type_id': self.company_data['default_warehouse'].out_type_id.id, + 'location_id': self.company_data['default_warehouse'].lot_stock_id.id, + 'location_dest_id': self.env.ref('stock.stock_location_customers').id, + 'move_lines': [(0, 0, { + 'name': product_cable_management_box.name, + 'product_id': product_cable_management_box.id, + 'product_uom_qty': 10, + 'product_uom': self.env.ref('uom.product_uom_kgm').id, + 'location_id': self.company_data['default_warehouse'].lot_stock_id.id, + 'location_dest_id': self.env.ref('stock.stock_location_customers').id, + 'picking_type_id': self.company_data['default_warehouse'].out_type_id.id})] + }) + + # I assign this outgoing shipment + outgoing_shipment_ret.action_assign() + res = outgoing_shipment_ret.button_validate() + Form(self.env[res['res_model']].with_context(res['context'])).save().process() + + # Check rounded price is 150.0 / 1.2834 + self.assertEqual(round(product_cable_management_box.qty_available), 0.0, 'Wrong quantity in stock after first reception.') + + # Let us create some outs to get negative stock for a new product using the same config + product_fifo_negative = self.env['product.product'].create({ + 'default_code': 'NEG', + 'name': 'FIFO Negative', + 'type': 'product', + 'categ_id': self.stock_account_product_categ.id, + 'list_price': 100.0, + 'standard_price': 70.0, + 'uom_id': self.env.ref('uom.product_uom_kgm').id, + 'uom_po_id': self.env.ref('uom.product_uom_kgm').id, + 'supplier_taxes_id': [], + 'description': 'FIFO Ice Cream', + }) + + # Create outpicking.create delivery order of 100 kg. + outgoing_shipment_neg = self.env['stock.picking'].create({ + 'picking_type_id': self.company_data['default_warehouse'].out_type_id.id, + 'location_id': self.company_data['default_warehouse'].lot_stock_id.id, + 'location_dest_id': self.env.ref('stock.stock_location_customers').id, + 'move_lines': [(0, 0, { + 'name': product_fifo_negative.name, + 'product_id': product_fifo_negative.id, + 'product_uom_qty': 100, + 'product_uom': self.env.ref('uom.product_uom_kgm').id, + 'location_id': self.company_data['default_warehouse'].lot_stock_id.id, + 'location_dest_id': self.env.ref('stock.stock_location_customers').id, + 'picking_type_id': self.company_data['default_warehouse'].out_type_id.id})] + }) + + # Process the delivery of the first outgoing shipment + outgoing_shipment_neg.action_confirm() + outgoing_shipment_neg.move_lines[0].quantity_done = 100.0 + outgoing_shipment_neg._action_done() + + # Check qty available = -100 + self.assertEqual(product_fifo_negative.qty_available, -100, 'Stock qty should be -100') + + # The behavior of fifo/lifo is not garantee if the quants are created at the same second, so just wait one second + time.sleep(1) + + # Let create another out shipment of 400 kg + outgoing_shipment_neg2 = self.env['stock.picking'].create({ + 'picking_type_id': self.company_data['default_warehouse'].out_type_id.id, + 'location_id': self.company_data['default_warehouse'].lot_stock_id.id, + 'location_dest_id': self.env.ref('stock.stock_location_customers').id, + 'move_lines': [(0, 0, { + 'name': product_fifo_negative.name, + 'product_id': product_fifo_negative.id, + 'product_uom_qty': 400, + 'product_uom': self.env.ref('uom.product_uom_kgm').id, + 'location_id': self.company_data['default_warehouse'].lot_stock_id.id, + 'location_dest_id': self.env.ref('stock.stock_location_customers').id, + 'picking_type_id': self.company_data['default_warehouse'].out_type_id.id})] + }) + + # Process the delivery of the outgoing shipments + outgoing_shipment_neg2.action_confirm() + outgoing_shipment_neg2.move_lines[0].quantity_done = 400.0 + outgoing_shipment_neg2._action_done() + + # Check qty available = -500 + self.assertEqual(product_fifo_negative.qty_available, -500, 'Stock qty should be -500') + + # Receive purchase order with 50 kg Ice Cream at 50€/kg + purchase_order_neg = self.env['purchase.order'].create({ + 'partner_id': res_partner_3.id, + 'order_line': [(0, 0, { + 'name': 'FIFO Ice Cream', + 'product_id': product_fifo_negative.id, + 'product_qty': 50.0, + 'product_uom': self.env.ref('uom.product_uom_kgm').id, + 'price_unit': 50.0, + 'date_planned': time.strftime('%Y-%m-%d')})], + }) + + # I confirm the first purchase order + purchase_order_neg.button_confirm() + + # Process the reception of purchase order neg + picking = purchase_order_neg.picking_ids[0] + res = picking.button_validate() + Form(self.env[res['res_model']].with_context(res['context'])).save().process() + + # Receive purchase order with 600 kg FIFO Ice Cream at 80 euro/kg + purchase_order_neg2 = self.env['purchase.order'].create({ + 'partner_id': res_partner_3.id, + 'order_line': [(0, 0, { + 'name': product_cable_management_box.name, + 'product_id': product_fifo_negative.id, + 'product_qty': 600.0, + 'product_uom': self.env.ref('uom.product_uom_kgm').id, + 'price_unit': 80.0, + 'date_planned': time.strftime('%Y-%m-%d')})], + }) + + # I confirm the second negative purchase order + purchase_order_neg2.button_confirm() + + # Process the reception of purchase order neg2 + picking = purchase_order_neg2.picking_ids[0] + res = picking.button_validate() + Form(self.env[res['res_model']].with_context(res['context'])).save().process() + + original_out_move = outgoing_shipment_neg.move_lines[0] + self.assertEqual(original_out_move.product_id.value_svl, 12000.0, 'Value of the move should be 12000') + self.assertEqual(original_out_move.product_id.qty_available, 150.0, 'Qty available should be 150') + + def test_01_test_fifo(self): + """" This test ensures that unit price keeps its decimal precision """ + + unit_price_precision = self.env['ir.model.data'].xmlid_to_object('product.decimal_price') + unit_price_precision.digits = 3 + + tax = self.env["account.tax"].create({ + "name": "Dummy Tax", + "amount": "0.00", + "type_tax_use": "purchase", + }) + + super_product = self.env['product.product'].create({ + 'name': 'Super Product', + 'type': 'product', + 'categ_id': self.stock_account_product_categ.id, + 'standard_price': 0.035, + }) + self.assertEqual(super_product.cost_method, 'fifo') + self.assertEqual(super_product.valuation, 'real_time') + + purchase_order = self.env['purchase.order'].create({ + 'partner_id': self.env.ref('base.res_partner_3').id, + 'order_line': [(0, 0, { + 'name': super_product.name, + 'product_id': super_product.id, + 'product_qty': 1000, + 'product_uom': super_product.uom_id.id, + 'price_unit': super_product.standard_price, + 'date_planned': time.strftime('%Y-%m-%d'), + 'taxes_id': [(4, tax.id)], + })], + }) + + purchase_order.button_confirm() + self.assertEqual(purchase_order.state, 'purchase') + + picking = purchase_order.picking_ids[0] + res = picking.button_validate() + Form(self.env[res['res_model']].with_context(res['context'])).save().process() + + self.assertEqual(super_product.standard_price, 0.035) + self.assertEqual(super_product.value_svl, 35.0) + self.assertEqual(picking.move_lines.price_unit, 0.035) diff --git a/addons/purchase_stock/tests/test_fifo_returns.py b/addons/purchase_stock/tests/test_fifo_returns.py new file mode 100644 index 00000000..b53fcf76 --- /dev/null +++ b/addons/purchase_stock/tests/test_fifo_returns.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- + +import time + +from odoo.tests import tagged, Form +from odoo.addons.stock_account.tests.test_anglo_saxon_valuation_reconciliation_common import ValuationReconciliationTestCommon + + +@tagged('-at_install', 'post_install') +class TestFifoReturns(ValuationReconciliationTestCommon): + + def test_fifo_returns(self): + """Test to create product and purchase order to test the FIFO returns of the product""" + res_partner_3 = self.env['res.partner'].create({ + 'name': 'Gemini Partner', + }) + + # Set a product as using fifo price + product_fiforet_icecream = self.env['product.product'].create({ + 'default_code': 'FIFORET', + 'name': 'FIFO Ice Cream', + 'type': 'product', + 'categ_id': self.stock_account_product_categ.id, + 'standard_price': 0.0, + 'uom_id': self.env.ref('uom.product_uom_kgm').id, + 'uom_po_id': self.env.ref('uom.product_uom_kgm').id, + 'description': 'FIFO Ice Cream', + }) + + # I create a draft Purchase Order for first in move for 10 kg at 50 euro + purchase_order_1 = self.env['purchase.order'].create({ + 'partner_id': res_partner_3.id, + 'order_line': [(0, 0, { + 'name': 'FIFO Ice Cream', + 'product_id': product_fiforet_icecream.id, + 'product_qty': 10.0, + 'product_uom': self.env.ref('uom.product_uom_kgm').id, + 'price_unit': 50.0, + 'date_planned': time.strftime('%Y-%m-%d'), + })], + }) + + # Create a draft Purchase Order for second shipment for 30kg at 80€/kg + purchase_order_2 = self.env['purchase.order'].create({ + 'partner_id': res_partner_3.id, + 'order_line': [(0, 0, { + 'name': 'FIFO Ice Cream', + 'product_id': product_fiforet_icecream.id, + 'product_qty': 30.0, + 'product_uom': self.env.ref('uom.product_uom_kgm').id, + 'price_unit': 80.0, + 'date_planned': time.strftime('%Y-%m-%d'), + })], + }) + + # Confirm the first purchase order + purchase_order_1.button_confirm() + + # Process the reception of purchase order 1 + picking = purchase_order_1.picking_ids[0] + res = picking.button_validate() + Form(self.env[res['res_model']].with_context(res['context'])).save().process() + + # Check the standard price of the product (fifo icecream) + self.assertAlmostEqual(product_fiforet_icecream.standard_price, 50) + + # Confirm the second purchase order + purchase_order_2.button_confirm() + picking = purchase_order_2.picking_ids[0] + res = picking.button_validate() + Form(self.env[res['res_model']].with_context(res['context'])).save().process() + + # Return the goods of purchase order 2 + picking = purchase_order_2.picking_ids[0] + stock_return_picking_form = Form(self.env['stock.return.picking'] + .with_context(active_ids=picking.ids, active_id=picking.ids[0], + active_model='stock.picking')) + return_pick_wiz = stock_return_picking_form.save() + return_picking_id, dummy = return_pick_wiz.with_context(active_id=picking.id)._create_returns() + + # Important to pass through confirmation and assignation + return_picking = self.env['stock.picking'].browse(return_picking_id) + return_picking.action_confirm() + return_picking.move_lines[0].quantity_done = return_picking.move_lines[0].product_uom_qty + return_picking._action_done() + + # After the return only 10 of the second purchase order should still be in stock as it applies fifo on the return too + self.assertEqual(product_fiforet_icecream.qty_available, 10.0, 'Qty available should be 10.0') + self.assertEqual(product_fiforet_icecream.value_svl, 800.0, 'Stock value should be 800') diff --git a/addons/purchase_stock/tests/test_move_cancel_propagation.py b/addons/purchase_stock/tests/test_move_cancel_propagation.py new file mode 100644 index 00000000..111ba1e0 --- /dev/null +++ b/addons/purchase_stock/tests/test_move_cancel_propagation.py @@ -0,0 +1,293 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.tests import tagged +from .common import PurchaseTestCommon + + +class TestMoveCancelPropagation(PurchaseTestCommon): + + def setUp(self): + super(TestMoveCancelPropagation, self).setUp() + self.customer = self.env['res.partner'].create({'name': 'abc'}) + self.group = self.env['procurement.group'].create({'partner_id': self.customer.id, 'name': 'New Group'}) + self.warehouse = self.env.ref('stock.warehouse0') + cust_location = self.env.ref('stock.stock_location_customers') + seller = self.env['product.supplierinfo'].create({ + 'name': self.customer.id, + 'price': 100.0, + }) + product = self.env['product.product'].create({ + 'name': 'Geyser', + 'type': 'product', + 'route_ids': [(4, self.route_mto), (4, self.route_buy)], + 'seller_ids': [(6, 0, [seller.id])], + }) + self.picking_out = self.env['stock.picking'].create({ + 'location_id': self.warehouse.out_type_id.default_location_src_id.id, + 'location_dest_id': cust_location.id, + 'partner_id': self.customer.id, + 'group_id': self.group.id, + 'picking_type_id': self.ref('stock.picking_type_out'), + }) + self.move = self.env['stock.move'].create({ + 'name': product.name, + 'product_id': product.id, + 'product_uom_qty': 10, + 'product_uom': product.uom_id.id, + 'picking_id': self.picking_out.id, + 'group_id': self.group.id, + 'location_id': self.warehouse.out_type_id.default_location_src_id.id, + 'location_dest_id': cust_location.id, + 'procure_method': 'make_to_order', + }) + + def test_01_cancel_draft_purchase_order_one_steps(self): + """ Check the picking and moves status related PO, When canceling purchase order + Ex. + 1) Set one steps of receiption and delivery on the warehouse. + 2) Create Delivery order with mto move and confirm the order, related RFQ should be generated. + 3) Cancel 'draft' purchase order should not cancel < Delivery > + """ + self.warehouse.write({'delivery_steps': 'ship_only', 'reception_steps': 'one_step'}) + self.picking_out.write({'location_id': self.warehouse.out_type_id.default_location_src_id.id}) + self.move.write({'location_id': self.warehouse.out_type_id.default_location_src_id.id}) + self.picking_out.action_confirm() + + # Find PO related to picking. + purchase_order = self.env['purchase.order'].search([('partner_id', '=', self.customer.id)]) + + # Po should be create related picking. + self.assertTrue(purchase_order, 'No purchase order created.') + + # Check status of Purchase Order + self.assertEqual(purchase_order.state, 'draft', "Purchase order should be in 'draft' state.") + + # Cancel Purchase order. + purchase_order.button_cancel() + + # Check the status of picking after canceling po. + self.assertNotEqual(self.picking_out.state, 'cancel') + + def test_02_cancel_confirm_purchase_order_one_steps(self): + """ Check the picking and moves status related purchase order, When canceling purchase order + after confirming. + Ex. + 1) Set one steps of receiption and delivery on the warehouse. + 2) Create Delivery order with mto move and confirm the order, related RFQ should be generated. + 3) Cancel 'confirmed' purchase order, should cancel releted < Receiption > + but it should not cancel < Delivery > order. + """ + self.warehouse.write({'delivery_steps': 'ship_only', 'reception_steps': 'one_step'}) + self.picking_out.write({'location_id': self.warehouse.out_type_id.default_location_src_id.id}) + self.move.write({'location_id': self.warehouse.out_type_id.default_location_src_id.id}) + self.picking_out.action_confirm() + + # Find PO related to picking. + purchase_order = self.env['purchase.order'].search([('partner_id', '=', self.customer.id)]) + # Po should be create related picking. + self.assertTrue(purchase_order, 'No purchase order created.') + + # Check status of Purchase Order + self.assertEqual(purchase_order.state, 'draft', "Purchase order should be in 'draft' state.") + purchase_order .button_confirm() + picking_in = purchase_order.picking_ids.filtered(lambda r: r.picking_type_id == self.warehouse.in_type_id) + # Cancel Purchase order. + purchase_order .button_cancel() + + # Check the status of picking after canceling po. + self.assertEqual(picking_in.state, 'cancel') + self.assertNotEqual(self.picking_out.state, 'cancel') + + def test_03_cancel_draft_purchase_order_two_steps(self): + """ Check the picking and moves status related PO, When canceling purchase order + in 'draft' state. + Ex. + 1) Set two steps of receiption and delivery on the warehouse. + 2) Create Delivery order with mto move and confirm the order, related RFQ should be generated. + 3) Cancel 'draft' purchase order should cancel < Input to Stock> + but it should not cancel < PICK, Delivery > + """ + self.warehouse.write({'delivery_steps': 'pick_ship', 'reception_steps': 'two_steps'}) + self.picking_out.write({'location_id': self.warehouse.out_type_id.default_location_src_id.id}) + self.move.write({'location_id': self.warehouse.out_type_id.default_location_src_id.id}) + self.picking_out.action_confirm() + + # Find purchase order related to picking. + purchase_order = self.env['purchase.order'].search([('partner_id', '=', self.customer.id)]) + # Purchase order should be created for picking. + self.assertTrue(purchase_order, 'No purchase order created.') + + picking_ids = self.env['stock.picking'].search([('group_id', '=', self.group.id)]) + + internal = picking_ids.filtered(lambda r: r.picking_type_id == self.warehouse.int_type_id and r.group_id.id == self.group.id) + pick = picking_ids.filtered(lambda r: r.picking_type_id == self.warehouse.pick_type_id and r.group_id.id == self.group.id) + + # Check status of Purchase Order + self.assertEqual(purchase_order.state, 'draft', "Purchase order should be in 'draft' state.") + # Cancel Purchase order. + purchase_order.button_cancel() + + # Check the status of picking after canceling po. + for res in internal: + self.assertEqual(res.state, 'cancel') + self.assertNotEqual(pick.state, 'cancel') + self.assertNotEqual(self.picking_out.state, 'cancel') + + def test_04_cancel_confirm_purchase_order_two_steps(self): + """ Check the picking and moves status related PO, When canceling purchase order + Ex. + 1) Set 2 steps of receiption and delivery on the warehouse. + 2) Create Delivery order with mto move and confirm the order, related RFQ should be generated. + 3) Cancel 'comfirm' purchase order should cancel releted < Receiption Picking IN, INT> + not < PICK, SHIP > + """ + self.warehouse.write({'delivery_steps': 'pick_ship', 'reception_steps': 'two_steps'}) + self.picking_out.write({'location_id': self.warehouse.out_type_id.default_location_src_id.id}) + self.move.write({'location_id': self.warehouse.out_type_id.default_location_src_id.id}) + self.picking_out.action_confirm() + + # Find PO related to picking. + purchase_order = self.env['purchase.order'].search([('partner_id', '=', self.customer.id)]) + # Po should be create related picking. + self.assertTrue(purchase_order, 'purchase order is created.') + + picking_ids = self.env['stock.picking'].search([('group_id', '=', self.group.id)]) + + internal = picking_ids.filtered(lambda r: r.picking_type_id == self.warehouse.int_type_id) + pick = picking_ids.filtered(lambda r: r.picking_type_id == self.warehouse.pick_type_id) + + # Check status of Purchase Order + self.assertEqual(purchase_order.state, 'draft', "Purchase order should be in 'draft' state.") + + purchase_order.button_confirm() + picking_in = purchase_order.picking_ids.filtered(lambda r: r.picking_type_id == self.warehouse.in_type_id) + # Cancel Purchase order. + purchase_order.button_cancel() + + # Check the status of picking after canceling po. + self.assertEqual(picking_in.state, 'cancel') + for res in internal: + self.assertEqual(res.state, 'cancel') + self.assertNotEqual(pick.state, 'cancel') + self.assertNotEqual(self.picking_out.state, 'cancel') + + def test_05_cancel_draft_purchase_order_three_steps(self): + """ Check the picking and moves status related PO, When canceling purchase order + Ex. + 1) Set 3 steps of receiption and delivery on the warehouse. + 2) Create Delivery order with mto move and confirm the order, related RFQ should be generated. + 3) Cancel 'draft' purchase order should cancel releted < Receiption Picking IN> + not < PICK, PACK, SHIP > + """ + self.warehouse.write({'delivery_steps': 'pick_pack_ship', 'reception_steps': 'three_steps'}) + self.picking_out.write({'location_id': self.warehouse.out_type_id.default_location_src_id.id}) + self.move.write({'location_id': self.warehouse.out_type_id.default_location_src_id.id}) + self.picking_out.action_confirm() + + # Find PO related to picking. + purchase_order = self.env['purchase.order'].search([('partner_id', '=', self.customer.id)]) + # Po should be create related picking. + self.assertTrue(purchase_order, 'No purchase order created.') + + picking_ids = self.env['stock.picking'].search([('group_id', '=', self.group.id)]) + + internal = picking_ids.filtered(lambda r: r.picking_type_id == self.warehouse.int_type_id) + pick = picking_ids.filtered(lambda r: r.picking_type_id == self.warehouse.pick_type_id) + pack = picking_ids.filtered(lambda r: r.picking_type_id == self.warehouse.pack_type_id) + + # Check status of Purchase Order + self.assertEqual(purchase_order.state, 'draft', "Purchase order should be in 'draft' state.") + # Cancel Purchase order. + purchase_order.button_cancel() + + # Check the status of picking after canceling po. + for res in internal: + self.assertEqual(res.state, 'cancel') + self.assertNotEqual(pick.state, 'cancel') + self.assertNotEqual(pack.state, 'cancel') + self.assertNotEqual(self.picking_out.state, 'cancel') + + def test_06_cancel_confirm_purchase_order_three_steps(self): + """ Check the picking and moves status related PO, When canceling purchase order + Ex. + 1) Set 3 steps of receiption and delivery on the warehouse. + 2) Create Delivery order with mto move and confirm the order, related RFQ should be generated. + 3) Cancel 'comfirm' purchase order should cancel releted < Receiption Picking IN, INT> + not < PICK, PACK, SHIP > + """ + self.warehouse.write({'delivery_steps': 'pick_pack_ship', 'reception_steps': 'three_steps'}) + self.picking_out.write({'location_id': self.warehouse.out_type_id.default_location_src_id.id}) + self.move.write({'location_id': self.warehouse.out_type_id.default_location_src_id.id}) + self.picking_out.action_confirm() + + # Find PO related to picking. + purchase_order = self.env['purchase.order'].search([('partner_id', '=', self.customer.id)]) + # Po should be create related picking. + self.assertTrue(purchase_order, 'No purchase order created.') + + picking_ids = self.env['stock.picking'].search([('group_id', '=', self.group.id)]) + + internal = picking_ids.filtered(lambda r: r.picking_type_id == self.warehouse.int_type_id) + pick = picking_ids.filtered(lambda r: r.picking_type_id == self.warehouse.pick_type_id) + pack = picking_ids.filtered(lambda r: r.picking_type_id == self.warehouse.pack_type_id) + + # Check status of Purchase Order + self.assertEqual(purchase_order.state, 'draft', "Purchase order should be in 'draft' state.") + + purchase_order.button_confirm() + picking_in = purchase_order.picking_ids.filtered(lambda r: r.picking_type_id == self.warehouse.in_type_id) + # Cancel Purchase order. + purchase_order.button_cancel() + + # Check the status of picking after canceling po. + self.assertEqual(picking_in.state, 'cancel') + for res in internal: + self.assertEqual(res.state, 'cancel') + self.assertNotEqual(pick.state, 'cancel') + self.assertNotEqual(pack.state, 'cancel') + self.assertNotEqual(self.picking_out.state, 'cancel') + + def test_cancel_move_lines_operation(self): + """Check for done and cancelled moves. Ensure that the RFQ cancellation + will not impact the delivery state if it's already cancelled. + """ + stock_location = self.env['ir.model.data'].xmlid_to_object('stock.stock_location_stock') + customer_location = self.env['ir.model.data'].xmlid_to_object('stock.stock_location_customers') + picking_type_out = self.env['ir.model.data'].xmlid_to_object('stock.picking_type_out') + + partner = self.env['res.partner'].create({ + 'name': 'Steve' + }) + seller = self.env['product.supplierinfo'].create({ + 'name': partner.id, + 'price': 10.0, + }) + product_car = self.env['product.product'].create({ + 'name': 'Car', + 'type': 'product', + 'route_ids': [(4, self.ref('stock.route_warehouse0_mto')), (4, self.ref('purchase_stock.route_warehouse0_buy'))], + 'seller_ids': [(6, 0, [seller.id])], + 'categ_id': self.env.ref('product.product_category_all').id, + }) + customer_picking = self.env['stock.picking'].create({ + 'location_id': stock_location.id, + 'location_dest_id': customer_location.id, + 'partner_id': partner.id, + 'picking_type_id': picking_type_out.id, + }) + customer_move = self.env['stock.move'].create({ + 'name': 'move out', + 'location_id': stock_location.id, + 'location_dest_id': customer_location.id, + 'product_id': product_car.id, + 'product_uom': product_car.uom_id.id, + 'product_uom_qty': 10.0, + 'procure_method': 'make_to_order', + 'picking_id': customer_picking.id, + }) + customer_move._action_confirm() + purchase_order = self.env['purchase.order'].search([('partner_id', '=', partner.id)]) + customer_move._action_cancel() + self.assertEqual(customer_move.state, 'cancel', 'Move should be cancelled') + purchase_order.button_cancel() + self.assertEqual(customer_move.state, 'cancel', 'State of cancelled and done moves should not change.') diff --git a/addons/purchase_stock/tests/test_onchange_product.py b/addons/purchase_stock/tests/test_onchange_product.py new file mode 100644 index 00000000..b029839e --- /dev/null +++ b/addons/purchase_stock/tests/test_onchange_product.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime + +from odoo import fields +from odoo.tests.common import TransactionCase, Form +from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT + +class TestOnchangeProductId(TransactionCase): + """Test that when an included tax is mapped by a fiscal position, the included tax must be + subtracted to the price of the product. + """ + + def setUp(self): + super(TestOnchangeProductId, self).setUp() + self.fiscal_position_model = self.env['account.fiscal.position'] + self.fiscal_position_tax_model = self.env['account.fiscal.position.tax'] + self.tax_model = self.env['account.tax'] + self.po_model = self.env['purchase.order'] + self.po_line_model = self.env['purchase.order.line'] + self.res_partner_model = self.env['res.partner'] + self.product_tmpl_model = self.env['product.template'] + self.product_model = self.env['product.product'] + self.product_uom_model = self.env['uom.uom'] + self.supplierinfo_model = self.env["product.supplierinfo"] + + def test_onchange_product_id(self): + + uom_id = self.product_uom_model.search([('name', '=', 'Units')])[0] + + partner_id = self.res_partner_model.create(dict(name="George")) + tax_include_id = self.tax_model.create(dict(name="Include tax", + amount='21.00', + price_include=True, + type_tax_use='purchase')) + tax_exclude_id = self.tax_model.create(dict(name="Exclude tax", + amount='0.00', + type_tax_use='purchase')) + supplierinfo_vals = { + 'name': partner_id.id, + 'price': 121.0, + } + + supplierinfo = self.supplierinfo_model.create(supplierinfo_vals) + + product_tmpl_id = self.product_tmpl_model.create(dict(name="Voiture", + list_price=121, + seller_ids=[(6, 0, [supplierinfo.id])], + supplier_taxes_id=[(6, 0, [tax_include_id.id])])) + product_id = product_tmpl_id.product_variant_id + + fp_id = self.fiscal_position_model.create(dict(name="fiscal position", sequence=1)) + + fp_tax_id = self.fiscal_position_tax_model.create(dict(position_id=fp_id.id, + tax_src_id=tax_include_id.id, + tax_dest_id=tax_exclude_id.id)) + po_vals = { + 'partner_id': partner_id.id, + 'fiscal_position_id': fp_id.id, + 'order_line': [ + (0, 0, { + 'name': product_id.name, + 'product_id': product_id.id, + 'product_qty': 1.0, + 'product_uom': uom_id.id, + 'price_unit': 121.0, + 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT), + })], + } + po = self.po_model.create(po_vals) + + po_line = po.order_line[0] + po_line.onchange_product_id() + self.assertEqual(100, po_line.price_unit, "The included tax must be subtracted to the price") + + supplierinfo.write({'min_qty': 24}) + po_line.write({'product_qty': 20}) + po_line._onchange_quantity() + self.assertEqual(0, po_line.price_unit, "Unit price should be reset to 0 since the supplier supplies minimum of 24 quantities") + + po_line.write({'product_qty': 3, 'product_uom': self.ref("uom.product_uom_dozen")}) + po_line._onchange_quantity() + self.assertEqual(1200, po_line.price_unit, "Unit price should be 1200 for one Dozen") + ipad_uom = self.env['uom.category'].create({'name': 'Ipad Unit'}) + ipad_lot = self.env['uom.uom'].create({ + 'name': 'Ipad', + 'category_id': ipad_uom.id, + 'uom_type': 'reference', + 'rounding': 0.001 + }) + ipad_lot_10 = self.env['uom.uom'].create({ + 'name': '10 Ipad', + 'category_id': ipad_uom.id, + 'uom_type': 'bigger', + 'rounding': 0.001, + "factor_inv": 10 + }) + product_ipad = self.env['product.product'].create({ + 'name': 'Conference Chair', + 'standard_price': 100, + 'uom_id': ipad_lot.id, + 'uom_po_id': ipad_lot.id, + }) + po_line2 = self.po_line_model.create({ + 'name': product_ipad.name, + 'product_id': product_ipad.id, + 'order_id': po.id, + 'product_qty': 5, + 'product_uom': ipad_uom.id, + 'date_planned': fields.Date().today() + }) + + po_line2.onchange_product_id() + self.assertEqual(100, po_line2.price_unit, "No vendor supplies this product, hence unit price should be set to 100") + + po_form = Form(po) + with po_form.order_line.edit(1) as order_line: + order_line.product_uom = ipad_lot_10 + po_form.save() + self.assertEqual(1000, po_line2.price_unit, "The product_uom is multiplied by 10, hence unit price should be set to 1000") diff --git a/addons/purchase_stock/tests/test_product_template.py b/addons/purchase_stock/tests/test_product_template.py new file mode 100644 index 00000000..0692ac02 --- /dev/null +++ b/addons/purchase_stock/tests/test_product_template.py @@ -0,0 +1,26 @@ +from odoo.tests.common import TransactionCase + + +class TestProductTemplate(TransactionCase): + def test_name_search(self): + partner = self.env['res.partner'].create({ + 'name': 'Azure Interior', + }) + + seller = self.env['product.supplierinfo'].create({ + 'name': partner.id, + 'price': 12.0, + 'delay': 1, + 'product_code': 'VOB2a', + }) + + product_tmpl = self.env['product.template'].create({ + 'name': 'Rubber Duck', + 'type': 'product', + 'default_code': 'VOB2A', + 'seller_ids': [seller.id], + 'purchase_ok': True, + }) + ns = self.env['product.template'].with_context(partner_id=partner.id).name_search('VOB2', [['purchase_ok', '=', True]]) + self.assertEqual(len(ns), 1, "name_search should have 1 item") + self.assertEqual(ns[0][1], '[VOB2A] Rubber Duck', "name_search should return the expected result") diff --git a/addons/purchase_stock/tests/test_purchase_delete_order.py b/addons/purchase_stock/tests/test_purchase_delete_order.py new file mode 100644 index 00000000..0687e76d --- /dev/null +++ b/addons/purchase_stock/tests/test_purchase_delete_order.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.exceptions import UserError +from .common import PurchaseTestCommon + + +class TestDeleteOrder(PurchaseTestCommon): + + def test_00_delete_order(self): + ''' Testcase for deleting purchase order with purchase user group''' + + # In order to test delete process on purchase order,tried to delete a confirmed order and check Error Message. + partner = self.env['res.partner'].create({'name': 'My Partner'}) + + purchase_order = self.env['purchase.order'].create({ + 'partner_id': partner.id, + 'state': 'purchase', + }) + purchase_order_1 = purchase_order.with_user(self.res_users_purchase_user) + with self.assertRaises(UserError): + purchase_order_1.unlink() + + # Delete 'cancelled' purchase order with user group + purchase_order = self.env['purchase.order'].create({ + 'partner_id': partner.id, + 'state': 'purchase', + }) + purchase_order_2 = purchase_order.with_user(self.res_users_purchase_user) + purchase_order_2.button_cancel() + self.assertEqual(purchase_order_2.state, 'cancel', 'PO is cancelled!') + purchase_order_2.unlink() + + # Delete 'draft' purchase order with user group + purchase_order = self.env['purchase.order'].create({ + 'partner_id': partner.id, + 'state': 'draft', + }) + purchase_order_3 = purchase_order.with_user(self.res_users_purchase_user) + purchase_order_3.button_cancel() + self.assertEqual(purchase_order_3.state, 'cancel', 'PO is cancelled!') + purchase_order_3.unlink() diff --git a/addons/purchase_stock/tests/test_purchase_lead_time.py b/addons/purchase_stock/tests/test_purchase_lead_time.py new file mode 100644 index 00000000..6fbae5c5 --- /dev/null +++ b/addons/purchase_stock/tests/test_purchase_lead_time.py @@ -0,0 +1,341 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime, timedelta, time +from unittest.mock import patch + +from odoo import fields +from .common import PurchaseTestCommon +from odoo.tests.common import Form + + +class TestPurchaseLeadTime(PurchaseTestCommon): + + def test_00_product_company_level_delays(self): + """ To check dates, set product's Delivery Lead Time + and company's Purchase Lead Time.""" + + company = self.env.ref('base.main_company') + + # Update company with Purchase Lead Time + company.write({'po_lead': 3.00}) + + # Make procurement request from product_1's form view, create procurement and check it's state + date_planned = fields.Datetime.to_string(fields.datetime.now() + timedelta(days=10)) + self._create_make_procurement(self.product_1, 15.00, date_planned=date_planned) + purchase = self.env['purchase.order.line'].search([('product_id', '=', self.product_1.id)], limit=1).order_id + + # Confirm purchase order + purchase.button_confirm() + + # Check order date of purchase order + order_date = fields.Datetime.from_string(date_planned) - timedelta(days=company.po_lead) - timedelta(days=self.product_1.seller_ids.delay) + self.assertEqual(purchase.date_order, order_date, 'Order date should be equal to: Date of the procurement order - Purchase Lead Time - Delivery Lead Time.') + + # Check scheduled date of purchase order + schedule_date = datetime.combine(order_date + timedelta(days=self.product_1.seller_ids.delay), time.min).replace(tzinfo=None, hour=12) + self.assertEqual(purchase.order_line.date_planned, schedule_date, 'Schedule date should be equal to: Order date of Purchase order + Delivery Lead Time.') + + # check the picking created or not + self.assertTrue(purchase.picking_ids, "Picking should be created.") + + # Check scheduled and deadline date of In Type shipment + self.assertEqual(purchase.picking_ids.scheduled_date, schedule_date, 'Schedule date of In type shipment should be equal to: schedule date of purchase order.') + self.assertEqual(purchase.picking_ids.date_deadline, schedule_date + timedelta(days=company.po_lead), 'Deadline date of should be equal to: schedule date of purchase order + lead_po.') + + def test_01_product_level_delay(self): + """ To check schedule dates of multiple purchase order line of the same purchase order, + we create two procurements for the two different product with same vendor + and different Delivery Lead Time.""" + + company = self.env.ref('base.main_company') + company.write({'po_lead': 0.00}) + + # Make procurement request from product_1's form view, create procurement and check it's state + date_planned1 = fields.Datetime.to_string(fields.datetime.now() + timedelta(days=10)) + self._create_make_procurement(self.product_1, 10.00, date_planned=date_planned1) + purchase1 = self.env['purchase.order.line'].search([('product_id', '=', self.product_1.id)], limit=1).order_id + + # Make procurement request from product_2's form view, create procurement and check it's state + date_planned2 = fields.Datetime.to_string(fields.datetime.now() + timedelta(days=10)) + self._create_make_procurement(self.product_2, 5.00, date_planned=date_planned2) + purchase2 = self.env['purchase.order.line'].search([('product_id', '=', self.product_2.id)], limit=1).order_id + + # Check purchase order is same or not + self.assertEqual(purchase1, purchase2, 'Purchase orders should be same for the two different product with same vendor.') + + # Confirm purchase order + purchase1.button_confirm() + + # Check order date of purchase order + order_line_pro_1 = purchase2.order_line.filtered(lambda r: r.product_id == self.product_1) + order_line_pro_2 = purchase2.order_line.filtered(lambda r: r.product_id == self.product_2) + order_date = fields.Datetime.from_string(date_planned1) - timedelta(days=self.product_1.seller_ids.delay) + self.assertEqual(purchase2.date_order, order_date, 'Order date should be equal to: Date of the procurement order - Delivery Lead Time.') + + # Check scheduled date of purchase order line for product_1 + schedule_date_1 = datetime.combine(order_date + timedelta(days=self.product_1.seller_ids.delay), time.min).replace(tzinfo=None, hour=12) + self.assertEqual(order_line_pro_1.date_planned, schedule_date_1, 'Schedule date of purchase order line for product_1 should be equal to: Order date of purchase order + Delivery Lead Time of product_1.') + + # Check scheduled date of purchase order line for product_2 + schedule_date_2 = datetime.combine(order_date + timedelta(days=self.product_2.seller_ids.delay), time.min).replace(tzinfo=None, hour=12) + self.assertEqual(order_line_pro_2.date_planned, schedule_date_2, 'Schedule date of purchase order line for product_2 should be equal to: Order date of purchase order + Delivery Lead Time of product_2.') + + # Check scheduled date of purchase order + po_schedule_date = min(schedule_date_1, schedule_date_2) + self.assertEqual(purchase2.order_line[1].date_planned, po_schedule_date, 'Schedule date of purchase order should be minimum of schedule dates of purchase order lines.') + + # Check the picking created or not + self.assertTrue(purchase2.picking_ids, "Picking should be created.") + + # Check scheduled date of In Type shipment + self.assertEqual(purchase2.picking_ids.scheduled_date, po_schedule_date, 'Schedule date of In type shipment should be same as schedule date of purchase order.') + + # Check deadline of pickings + self.assertEqual(purchase2.picking_ids.date_deadline, purchase2.date_planned, "Deadline of pickings should be equals to the receipt date of purchase") + purchase_form = Form(purchase2) + purchase_form.date_planned = purchase2.date_planned + timedelta(days=2) + purchase_form.save() + self.assertEqual(purchase2.picking_ids.date_deadline, purchase2.date_planned, "Deadline of pickings should be propagate") + + def test_02_product_route_level_delays(self): + """ In order to check dates, set product's Delivery Lead Time + and warehouse route's delay.""" + + company = self.env.ref('base.main_company') + company.write({'po_lead': 1.00}) + + # Update warehouse_1 with Incoming Shipments 3 steps + self.warehouse_1.write({'reception_steps': 'three_steps'}) + + # Set delay on push rule + for push_rule in self.warehouse_1.reception_route_id.rule_ids: + push_rule.write({'delay': 2}) + + rule_delay = sum(self.warehouse_1.reception_route_id.rule_ids.mapped('delay')) + + date_planned = fields.Datetime.to_string(fields.datetime.now() + timedelta(days=10)) + # Create procurement order of product_1 + self.env['procurement.group'].run([self.env['procurement.group'].Procurement( + self.product_1, 5.000, self.uom_unit, self.warehouse_1.lot_stock_id, 'Test scheduler for RFQ', '/', self.env.company, + { + 'warehouse_id': self.warehouse_1, + 'date_planned': date_planned, # 10 days added to current date of procurement to get future schedule date and order date of purchase order. + 'date_deadline': date_planned, # 10 days added to current date of procurement to get future schedule date and order date of purchase order. + 'rule_id': self.warehouse_1.buy_pull_id, + 'group_id': False, + 'route_ids': [], + } + )]) + + # Confirm purchase order + purchase = self.env['purchase.order.line'].search([('product_id', '=', self.product_1.id)], limit=1).order_id + purchase.button_confirm() + + # Check order date of purchase order + order_date = fields.Datetime.from_string(date_planned) - timedelta(days=self.product_1.seller_ids.delay + rule_delay + company.po_lead) + self.assertEqual(purchase.date_order, order_date, 'Order date should be equal to: Date of the procurement order - Delivery Lead Time(supplier and pull rules).') + + # Check scheduled date of purchase order + schedule_date = order_date + timedelta(days=self.product_1.seller_ids.delay + rule_delay + company.po_lead) + self.assertEqual(date_planned, str(schedule_date), 'Schedule date should be equal to: Order date of Purchase order + Delivery Lead Time(supplier and pull rules).') + + # Check the picking crated or not + self.assertTrue(purchase.picking_ids, "Picking should be created.") + + # Check scheduled date of Internal Type shipment + incoming_shipment1 = self.env['stock.picking'].search([('move_lines.product_id', 'in', (self.product_1.id, self.product_2.id)), ('picking_type_id', '=', self.warehouse_1.int_type_id.id), ('location_id', '=', self.warehouse_1.wh_input_stock_loc_id.id), ('location_dest_id', '=', self.warehouse_1.wh_qc_stock_loc_id.id)]) + incoming_shipment1_date = order_date + timedelta(days=self.product_1.seller_ids.delay + company.po_lead) + self.assertEqual(incoming_shipment1.scheduled_date, incoming_shipment1_date, 'Schedule date of Internal Type shipment for input stock location should be equal to: schedule date of purchase order + push rule delay.') + self.assertEqual(incoming_shipment1.date_deadline, incoming_shipment1_date) + old_deadline1 = incoming_shipment1.date_deadline + + incoming_shipment2 = self.env['stock.picking'].search([('picking_type_id', '=', self.warehouse_1.int_type_id.id), ('location_id', '=', self.warehouse_1.wh_qc_stock_loc_id.id), ('location_dest_id', '=', self.warehouse_1.lot_stock_id.id)]) + incoming_shipment2_date = schedule_date - timedelta(days=incoming_shipment2.move_lines[0].rule_id.delay) + self.assertEqual(incoming_shipment2.scheduled_date, incoming_shipment2_date, 'Schedule date of Internal Type shipment for quality control stock location should be equal to: schedule date of Internal type shipment for input stock location + push rule delay..') + self.assertEqual(incoming_shipment2.date_deadline, incoming_shipment2_date) + old_deadline2 = incoming_shipment2.date_deadline + + # Modify the date_planned of the purchase -> propagate the deadline + purchase_form = Form(purchase) + purchase_form.date_planned = purchase.date_planned + timedelta(days=1) + purchase_form.save() + self.assertEqual(incoming_shipment2.date_deadline, old_deadline2 + timedelta(days=1), 'Deadline should be propagate') + self.assertEqual(incoming_shipment1.date_deadline, old_deadline1 + timedelta(days=1), 'Deadline should be propagate') + + def test_merge_po_line(self): + """Change that merging po line for same procurement is done.""" + + # create a product with manufacture route + product_1 = self.env['product.product'].create({ + 'name': 'AAA', + 'route_ids': [(4, self.route_buy)], + 'seller_ids': [(0, 0, {'name': self.partner_1.id, 'delay': 5})] + }) + + # create a move for product_1 from stock to output and reserve to trigger the + # rule + move_1 = self.env['stock.move'].create({ + 'name': 'move_1', + 'product_id': product_1.id, + 'product_uom': self.ref('uom.product_uom_unit'), + 'location_id': self.ref('stock.stock_location_stock'), + 'location_dest_id': self.ref('stock.stock_location_output'), + 'product_uom_qty': 10, + 'procure_method': 'make_to_order' + }) + + move_1._action_confirm() + po_line = self.env['purchase.order.line'].search([ + ('product_id', '=', product_1.id), + ]) + self.assertEqual(len(po_line), 1, 'the purchase order line is not created') + self.assertEqual(po_line.product_qty, 10, 'the purchase order line has a wrong quantity') + + move_2 = self.env['stock.move'].create({ + 'name': 'move_2', + 'product_id': product_1.id, + 'product_uom': self.ref('uom.product_uom_unit'), + 'location_id': self.ref('stock.stock_location_stock'), + 'location_dest_id': self.ref('stock.stock_location_output'), + 'product_uom_qty': 5, + 'procure_method': 'make_to_order' + }) + + move_2._action_confirm() + po_line = self.env['purchase.order.line'].search([ + ('product_id', '=', product_1.id), + ]) + self.assertEqual(len(po_line), 1, 'the purchase order lines should be merged') + self.assertEqual(po_line.product_qty, 15, 'the purchase order line has a wrong quantity') + + def test_merge_po_line_3(self): + """Change merging po line if same procurement is done depending on custom values.""" + company = self.env.ref('base.main_company') + company.write({'po_lead': 0.00}) + + # The seller has a specific product name and code which must be kept in the PO line + self.t_shirt.seller_ids.write({ + 'product_name': 'Vendor Name', + 'product_code': 'Vendor Code', + }) + partner = self.t_shirt.seller_ids[:1].name + t_shirt = self.t_shirt.with_context( + lang=partner.lang, + partner_id=partner.id, + ) + + # Create procurement order of product_1 + ProcurementGroup = self.env['procurement.group'] + procurement_values = { + 'warehouse_id': self.warehouse_1, + 'rule_id': self.warehouse_1.buy_pull_id, + 'date_planned': fields.Datetime.to_string(fields.datetime.now() + timedelta(days=10)), + 'group_id': False, + 'route_ids': [], + } + + procurement_values['product_description_variants'] = 'Color (Red)' + order_1_values = procurement_values + ProcurementGroup.run([self.env['procurement.group'].Procurement( + self.t_shirt, 5, self.uom_unit, self.warehouse_1.lot_stock_id, + self.t_shirt.name, '/', self.env.company, order_1_values) + ]) + purchase_order = self.env['purchase.order.line'].search([('product_id', '=', self.t_shirt.id)], limit=1).order_id + order_line_description = purchase_order.order_line.product_id._get_description(purchase_order.picking_type_id) + self.assertEqual(len(purchase_order.order_line), 1, 'wrong number of order line is created') + self.assertEqual(purchase_order.order_line.name, t_shirt.display_name + "\n" + "Color (Red)", 'wrong description in po lines') + + procurement_values['product_description_variants'] = 'Color (Red)' + order_2_values = procurement_values + ProcurementGroup.run([self.env['procurement.group'].Procurement( + self.t_shirt, 10, self.uom_unit, self.warehouse_1.lot_stock_id, + self.t_shirt.name, '/', self.env.company, order_2_values) + ]) + self.env['procurement.group'].run_scheduler() + self.assertEqual(len(purchase_order.order_line), 1, 'line with same custom value should be merged') + self.assertEqual(purchase_order.order_line[0].product_qty, 15, 'line with same custom value should be merged and qty should be update') + + procurement_values['product_description_variants'] = 'Color (Green)' + + order_3_values = procurement_values + ProcurementGroup.run([self.env['procurement.group'].Procurement( + self.t_shirt, 10, self.uom_unit, self.warehouse_1.lot_stock_id, + self.t_shirt.name, '/', self.env.company, order_3_values) + ]) + self.assertEqual(len(purchase_order.order_line), 2, 'line with different custom value should not be merged') + self.assertEqual(purchase_order.order_line.filtered(lambda x: x.product_qty == 15).name, t_shirt.display_name + "\n" + "Color (Red)", 'wrong description in po lines') + self.assertEqual(purchase_order.order_line.filtered(lambda x: x.product_qty == 10).name, t_shirt.display_name + "\n" + "Color (Green)", 'wrong description in po lines') + + purchase_order.button_confirm() + self.assertEqual(purchase_order.picking_ids[0].move_ids_without_package.filtered(lambda x: x.product_uom_qty == 15).description_picking, order_line_description + "\nColor (Red)", 'wrong description in picking') + self.assertEqual(purchase_order.picking_ids[0].move_ids_without_package.filtered(lambda x: x.product_uom_qty == 10).description_picking, order_line_description + "\nColor (Green)", 'wrong description in picking') + + def test_reordering_days_to_purchase(self): + company = self.env.ref('base.main_company') + company2 = self.env['res.company'].create({ + 'name': 'Second Company', + }) + company.write({'po_lead': 0.00}) + self.patcher = patch('odoo.addons.stock.models.stock_orderpoint.fields.Date', wraps=fields.Date) + self.mock_date = self.patcher.start() + + vendor = self.env['res.partner'].create({ + 'name': 'Colruyt' + }) + vendor2 = self.env['res.partner'].create({ + 'name': 'Delhaize' + }) + + self.env.company.days_to_purchase = 2.0 + + product = self.env['product.product'].create({ + 'name': 'Chicory', + 'type': 'product', + 'seller_ids': [ + (0, 0, {'name': vendor2.id, 'delay': 15.0, 'company_id': company2.id}), + (0, 0, {'name': vendor.id, 'delay': 1.0, 'company_id': company.id}) + ] + }) + orderpoint_form = Form(self.env['stock.warehouse.orderpoint']) + orderpoint_form.product_id = product + orderpoint_form.product_min_qty = 0.0 + orderpoint = orderpoint_form.save() + + orderpoint_form = Form(self.env['stock.warehouse.orderpoint'].with_company(company2)) + orderpoint_form.product_id = product + orderpoint_form.product_min_qty = 0.0 + orderpoint = orderpoint_form.save() + + warehouse = self.env['stock.warehouse'].search([], limit=1) + delivery_moves = self.env['stock.move'] + for i in range(0, 6): + delivery_moves |= self.env['stock.move'].create({ + 'name': 'Delivery', + 'date': datetime.today() + timedelta(days=i), + 'product_id': product.id, + 'product_uom': product.uom_id.id, + 'product_uom_qty': 5.0, + 'location_id': warehouse.lot_stock_id.id, + 'location_dest_id': self.ref('stock.stock_location_customers'), + }) + delivery_moves._action_confirm() + self.env['procurement.group'].run_scheduler() + po_line = self.env['purchase.order.line'].search([('product_id', '=', product.id)]) + self.assertEqual(fields.Date.to_date(po_line.order_id.date_order), fields.Date.today() + timedelta(days=2)) + self.assertEqual(len(po_line), 1) + self.assertEqual(po_line.product_uom_qty, 20.0) + self.assertEqual(len(po_line.order_id), 1) + orderpoint_form = Form(orderpoint) + orderpoint_form.save() + + self.mock_date.today.return_value = fields.Date.today() + timedelta(days=1) + orderpoint._compute_qty() + self.env['procurement.group'].run_scheduler() + po_line = self.env['purchase.order.line'].search([('product_id', '=', product.id)]) + self.assertEqual(len(po_line), 2) + self.assertEqual(len(po_line.order_id), 2) + new_order = po_line.order_id.sorted('date_order')[-1] + self.assertEqual(fields.Date.to_date(new_order.date_order), fields.Date.today() + timedelta(days=2)) + self.assertEqual(new_order.order_line.product_uom_qty, 5.0) + self.patcher.stop() diff --git a/addons/purchase_stock/tests/test_purchase_order.py b/addons/purchase_stock/tests/test_purchase_order.py new file mode 100644 index 00000000..7d98d247 --- /dev/null +++ b/addons/purchase_stock/tests/test_purchase_order.py @@ -0,0 +1,332 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime, timedelta + +from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT +from odoo.addons.stock_account.tests.test_anglo_saxon_valuation_reconciliation_common import ValuationReconciliationTestCommon +from odoo.tests import Form, tagged + + +@tagged('post_install', '-at_install') +class TestPurchaseOrder(ValuationReconciliationTestCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + cls.product_id_1 = cls.env['product.product'].create({'name': 'Large Desk', 'purchase_method': 'purchase'}) + cls.product_id_2 = cls.env['product.product'].create({'name': 'Conference Chair', 'purchase_method': 'purchase'}) + + cls.po_vals = { + 'partner_id': cls.partner_a.id, + 'order_line': [ + (0, 0, { + 'name': cls.product_id_1.name, + 'product_id': cls.product_id_1.id, + 'product_qty': 5.0, + 'product_uom': cls.product_id_1.uom_po_id.id, + 'price_unit': 500.0, + 'date_planned': datetime.today().replace(hour=9).strftime(DEFAULT_SERVER_DATETIME_FORMAT), + }), + (0, 0, { + 'name': cls.product_id_2.name, + 'product_id': cls.product_id_2.id, + 'product_qty': 5.0, + 'product_uom': cls.product_id_2.uom_po_id.id, + 'price_unit': 250.0, + 'date_planned': datetime.today().replace(hour=9).strftime(DEFAULT_SERVER_DATETIME_FORMAT), + })], + } + + def test_00_purchase_order_flow(self): + # Ensure product_id_2 doesn't have res_partner_1 as supplier + if self.partner_a in self.product_id_2.seller_ids.mapped('name'): + id_to_remove = self.product_id_2.seller_ids.filtered(lambda r: r.name == self.partner_a).ids[0] if self.product_id_2.seller_ids.filtered(lambda r: r.name == self.partner_a) else False + if id_to_remove: + self.product_id_2.write({ + 'seller_ids': [(2, id_to_remove, False)], + }) + self.assertFalse(self.product_id_2.seller_ids.filtered(lambda r: r.name == self.partner_a), 'Purchase: the partner should not be in the list of the product suppliers') + + self.po = self.env['purchase.order'].create(self.po_vals) + self.assertTrue(self.po, 'Purchase: no purchase order created') + self.assertEqual(self.po.invoice_status, 'no', 'Purchase: PO invoice_status should be "Not purchased"') + self.assertEqual(self.po.order_line.mapped('qty_received'), [0.0, 0.0], 'Purchase: no product should be received"') + self.assertEqual(self.po.order_line.mapped('qty_invoiced'), [0.0, 0.0], 'Purchase: no product should be invoiced"') + + self.po.button_confirm() + self.assertEqual(self.po.state, 'purchase', 'Purchase: PO state should be "Purchase"') + self.assertEqual(self.po.invoice_status, 'to invoice', 'Purchase: PO invoice_status should be "Waiting Invoices"') + + self.assertTrue(self.product_id_2.seller_ids.filtered(lambda r: r.name == self.partner_a), 'Purchase: the partner should be in the list of the product suppliers') + + seller = self.product_id_2._select_seller(partner_id=self.partner_a, quantity=2.0, date=self.po.date_planned, uom_id=self.product_id_2.uom_po_id) + price_unit = seller.price if seller else 0.0 + if price_unit and seller and self.po.currency_id and seller.currency_id != self.po.currency_id: + price_unit = seller.currency_id._convert(price_unit, self.po.currency_id, self.po.company_id, self.po.date_order) + self.assertEqual(price_unit, 250.0, 'Purchase: the price of the product for the supplier should be 250.0.') + + self.assertEqual(self.po.picking_count, 1, 'Purchase: one picking should be created"') + self.picking = self.po.picking_ids[0] + self.picking.move_line_ids.write({'qty_done': 5.0}) + self.picking.button_validate() + self.assertEqual(self.po.order_line.mapped('qty_received'), [5.0, 5.0], 'Purchase: all products should be received"') + + move_form = Form(self.env['account.move'].with_context(default_move_type='in_invoice')) + move_form.partner_id = self.partner_a + move_form.purchase_id = self.po + self.invoice = move_form.save() + + self.assertEqual(self.po.order_line.mapped('qty_invoiced'), [5.0, 5.0], 'Purchase: all products should be invoiced"') + + def test_02_po_return(self): + """ + Test a PO with a product on Incoming shipment. Validate the PO, then do a return + of the picking with Refund. + """ + # Draft purchase order created + self.po = self.env['purchase.order'].create(self.po_vals) + self.assertTrue(self.po, 'Purchase: no purchase order created') + self.assertEqual(self.po.order_line.mapped('qty_received'), [0.0, 0.0], 'Purchase: no product should be received"') + self.assertEqual(self.po.order_line.mapped('qty_invoiced'), [0.0, 0.0], 'Purchase: no product should be invoiced"') + + self.po.button_confirm() + self.assertEqual(self.po.state, 'purchase', 'Purchase: PO state should be "Purchase"') + self.assertEqual(self.po.invoice_status, 'to invoice', 'Purchase: PO invoice_status should be "Waiting Invoices"') + + # Confirm the purchase order + self.po.button_confirm() + self.assertEqual(self.po.state, 'purchase', 'Purchase: PO state should be "Purchase') + self.assertEqual(self.po.picking_count, 1, 'Purchase: one picking should be created"') + self.picking = self.po.picking_ids[0] + self.picking.move_line_ids.write({'qty_done': 5.0}) + self.picking.button_validate() + self.assertEqual(self.po.order_line.mapped('qty_received'), [5.0, 5.0], 'Purchase: all products should be received"') + + #After Receiving all products create vendor bill. + move_form = Form(self.env['account.move'].with_context(default_move_type='in_invoice')) + move_form.invoice_date = move_form.date + move_form.partner_id = self.partner_a + move_form.purchase_id = self.po + self.invoice = move_form.save() + self.invoice.action_post() + + self.assertEqual(self.po.order_line.mapped('qty_invoiced'), [5.0, 5.0], 'Purchase: all products should be invoiced"') + + # Check quantity received + received_qty = sum(pol.qty_received for pol in self.po.order_line) + self.assertEqual(received_qty, 10.0, 'Purchase: Received quantity should be 10.0 instead of %s after validating incoming shipment' % received_qty) + + # Create return picking + pick = self.po.picking_ids + stock_return_picking_form = Form(self.env['stock.return.picking'] + .with_context(active_ids=pick.ids, active_id=pick.ids[0], + active_model='stock.picking')) + return_wiz = stock_return_picking_form.save() + return_wiz.product_return_moves.write({'quantity': 2.0, 'to_refund': True}) # Return only 2 + res = return_wiz.create_returns() + return_pick = self.env['stock.picking'].browse(res['res_id']) + + # Validate picking + return_pick.move_line_ids.write({'qty_done': 2}) + + return_pick.button_validate() + + # Check Received quantity + self.assertEqual(self.po.order_line[0].qty_received, 3.0, 'Purchase: delivered quantity should be 3.0 instead of "%s" after picking return' % self.po.order_line[0].qty_received) + #Create vendor bill for refund qty + move_form = Form(self.env['account.move'].with_context(default_move_type='in_refund')) + move_form.invoice_date = move_form.date + move_form.partner_id = self.partner_a + move_form.purchase_id = self.po + self.invoice = move_form.save() + move_form = Form(self.invoice) + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.quantity = 2.0 + with move_form.invoice_line_ids.edit(1) as line_form: + line_form.quantity = 2.0 + self.invoice = move_form.save() + self.invoice.action_post() + + self.assertEqual(self.po.order_line.mapped('qty_invoiced'), [3.0, 3.0], 'Purchase: Billed quantity should be 3.0') + + def test_03_po_return_and_modify(self): + """Change the picking code of the delivery to internal. Make a PO for 10 units, go to the + picking and return 5, edit the PO line to 15 units. + The purpose of the test is to check the consistencies across the received quantities and the + procurement quantities. + """ + # Change the code of the picking type delivery + self.env['stock.picking.type'].search([('code', '=', 'outgoing')]).write({'code': 'internal'}) + + # Sell and deliver 10 units + item1 = self.product_id_1 + uom_unit = self.env.ref('uom.product_uom_unit') + po1 = self.env['purchase.order'].create({ + 'partner_id': self.partner_a.id, + 'order_line': [ + (0, 0, { + 'name': item1.name, + 'product_id': item1.id, + 'product_qty': 10, + 'product_uom': uom_unit.id, + 'price_unit': 123.0, + 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT), + }), + ], + }) + po1.button_confirm() + + picking = po1.picking_ids + wiz_act = picking.button_validate() + wiz = Form(self.env[wiz_act['res_model']].with_context(wiz_act['context'])).save() + wiz.process() + + # Return 5 units + stock_return_picking_form = Form(self.env['stock.return.picking'].with_context( + active_ids=picking.ids, + active_id=picking.ids[0], + active_model='stock.picking' + )) + return_wiz = stock_return_picking_form.save() + for return_move in return_wiz.product_return_moves: + return_move.write({ + 'quantity': 5, + 'to_refund': True + }) + res = return_wiz.create_returns() + return_pick = self.env['stock.picking'].browse(res['res_id']) + wiz_act = return_pick.button_validate() + wiz = Form(self.env[wiz_act['res_model']].with_context(wiz_act['context'])).save() + wiz.process() + + self.assertEqual(po1.order_line.qty_received, 5) + + # Deliver 15 instead of 10. + po1.write({ + 'order_line': [ + (1, po1.order_line[0].id, {'product_qty': 15}), + ] + }) + + # A new move of 10 unit (15 - 5 units) + self.assertEqual(po1.order_line.qty_received, 5) + self.assertEqual(po1.picking_ids[-1].move_lines.product_qty, 10) + + def test_04_update_date_planned(self): + today = datetime.today().replace(hour=9, microsecond=0) + tomorrow = datetime.today().replace(hour=9, microsecond=0) + timedelta(days=1) + po = self.env['purchase.order'].create(self.po_vals) + po.button_confirm() + + # update first line + po._update_date_planned_for_lines([(po.order_line[0], tomorrow)]) + self.assertEqual(po.order_line[0].date_planned, tomorrow) + activity = self.env['mail.activity'].search([ + ('summary', '=', 'Date Updated'), + ('res_model_id', '=', 'purchase.order'), + ('res_id', '=', po.id), + ]) + self.assertTrue(activity) + self.assertIn( + '

partner_a modified receipt dates for the following products:

\xa0 - Large Desk from %s to %s

Those dates have been updated accordingly on the receipt %s.

' % (today.date(), tomorrow.date(), po.picking_ids.name), + activity.note, + ) + + # receive products + wiz_act = po.picking_ids.button_validate() + wiz = Form(self.env[wiz_act['res_model']].with_context(wiz_act['context'])).save() + wiz.process() + + # update second line + old_date = po.order_line[1].date_planned + po._update_date_planned_for_lines([(po.order_line[1], tomorrow)]) + self.assertEqual(po.order_line[1].date_planned, old_date) + self.assertIn( + '

partner_a modified receipt dates for the following products:

\xa0 - Large Desk from %s to %s

\xa0 - Conference Chair from %s to %s

Those dates couldn’t be modified accordingly on the receipt %s which had already been validated.

' % (today.date(), tomorrow.date(), today.date(), tomorrow.date(), po.picking_ids.name), + activity.note, + ) + + def test_05_multi_company(self): + company_a = self.env.user.company_id + company_b = self.env['res.company'].create({ + "name": "Test Company", + "currency_id": self.env['res.currency'].with_context(active_test=False).search([ + ('id', '!=', company_a.currency_id.id), + ], limit=1).id + }) + self.env.user.write({ + 'company_id': company_b.id, + 'company_ids': [(4, company_b.id), (4, company_a.id)], + }) + po = self.env['purchase.order'].create(dict(company_id=company_a.id, partner_id=self.partner_a.id)) + + self.assertEqual(po.company_id, company_a) + self.assertEqual(po.picking_type_id.warehouse_id.company_id, company_a) + self.assertEqual(po.currency_id, po.company_id.currency_id) + + def test_06_on_time_rate(self): + company_a = self.env.user.company_id + company_b = self.env['res.company'].create({ + "name": "Test Company", + "currency_id": self.env['res.currency'].with_context(active_test=False).search([ + ('id', '!=', company_a.currency_id.id), + ], limit=1).id + }) + + # Create a purchase order with 90% qty received for company A + self.env.user.write({ + 'company_id': company_a.id, + 'company_ids': [(6, 0, [company_a.id])], + }) + po = self.env['purchase.order'].create(self.po_vals) + po.order_line.write({'product_qty': 10}) + po.button_confirm() + picking = po.picking_ids[0] + # Process 9.0 out of the 10.0 ordered qty + picking.move_line_ids.write({'qty_done': 9.0}) + res_dict = picking.button_validate() + # No backorder + self.env['stock.backorder.confirmation'].with_context(res_dict['context']).process_cancel_backorder() + # `on_time_rate` should be equals to the ratio of quantity received against quantity ordered + expected_rate = sum(picking.move_line_ids.mapped("qty_done")) / sum(po.order_line.mapped("product_qty")) * 100 + self.assertEqual(expected_rate, po.on_time_rate) + + # Create a purchase order with 80% qty received for company B + # The On-Time Delivery Rate shouldn't be shared accross multiple companies + self.env.user.write({ + 'company_id': company_b.id, + 'company_ids': [(6, 0, [company_b.id])], + }) + po = self.env['purchase.order'].create(self.po_vals) + po.order_line.write({'product_qty': 10}) + po.button_confirm() + picking = po.picking_ids[0] + # Process 8.0 out of the 10.0 ordered qty + picking.move_line_ids.write({'qty_done': 8.0}) + res_dict = picking.button_validate() + # No backorder + self.env['stock.backorder.confirmation'].with_context(res_dict['context']).process_cancel_backorder() + # `on_time_rate` should be equal to the ratio of quantity received against quantity ordered + expected_rate = sum(picking.move_line_ids.mapped("qty_done")) / sum(po.order_line.mapped("product_qty")) * 100 + self.assertEqual(expected_rate, po.on_time_rate) + + # Tricky corner case + # As `purchase.order.on_time_rate` is a related to `partner_id.on_time_rate` + # `on_time_rate` on the PO should equals `on_time_rate` on the partner. + # Related fields are by default computed as sudo + # while non-stored computed fields are not computed as sudo by default + # If the computation of the related field (`purchase.order.on_time_rate`) was asked + # and `res.partner.on_time_rate` was not yet in the cache + # the `sudo` requested for the computation of the related `purchase.order.on_time_rate` + # was propagated to the computation of `res.partner.on_time_rate` + # and therefore the multi-company record rules were ignored. + # 1. Compute `res.partner.on_time_rate` regular non-stored comptued field + partner_on_time_rate = po.partner_id.on_time_rate + # 2. Invalidate the cache for that record and field, so it's not reused in the next step. + po.partner_id.invalidate_cache(fnames=["on_time_rate"], ids=po.partner_id.ids) + # 3. Compute the related field `purchase.order.on_time_rate` + po_on_time_rate = po.on_time_rate + # 4. Check both are equals. + self.assertEqual(partner_on_time_rate, po_on_time_rate) diff --git a/addons/purchase_stock/tests/test_purchase_order_process.py b/addons/purchase_stock/tests/test_purchase_order_process.py new file mode 100644 index 00000000..56f09106 --- /dev/null +++ b/addons/purchase_stock/tests/test_purchase_order_process.py @@ -0,0 +1,29 @@ +from .common import PurchaseTestCommon + + +class TestPurchaseOrderProcess(PurchaseTestCommon): + + def test_00_cancel_purchase_order_flow(self): + """ Test cancel purchase order with group user.""" + + # In order to test the cancel flow,start it from canceling confirmed purchase order. + purchase_order = self.env['purchase.order'].create({ + 'partner_id': self.env['res.partner'].create({'name': 'My Partner'}).id, + 'state': 'draft', + }) + po_edit_with_user = purchase_order.with_user(self.res_users_purchase_user) + + # Confirm the purchase order. + po_edit_with_user.button_confirm() + + # Check the "Approved" status after confirmed RFQ. + self.assertEqual(po_edit_with_user.state, 'purchase', 'Purchase: PO state should be "Purchase') + + # First cancel receptions related to this order if order shipped. + po_edit_with_user.picking_ids.action_cancel() + + # Able to cancel purchase order. + po_edit_with_user.button_cancel() + + # Check that order is cancelled. + self.assertEqual(po_edit_with_user.state, 'cancel', 'Purchase: PO state should be "Cancel') diff --git a/addons/purchase_stock/tests/test_purchase_stock_report.py b/addons/purchase_stock/tests/test_purchase_stock_report.py new file mode 100644 index 00000000..824a11e7 --- /dev/null +++ b/addons/purchase_stock/tests/test_purchase_stock_report.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests.common import Form +from odoo.addons.stock.tests.test_report import TestReportsCommon + + +class TestPurchaseStockReports(TestReportsCommon): + def test_report_forecast_1_purchase_order_multi_receipt(self): + """ Create a PO for 5 product, receive them then increase the quantity to 10. + """ + po_form = Form(self.env['purchase.order']) + po_form.partner_id = self.partner + with po_form.order_line.new() as line: + line.product_id = self.product + line.product_qty = 5 + po = po_form.save() + + # Checks the report. + report_values, docs, lines = self.get_report_forecast(product_template_ids=self.product_template.ids) + draft_picking_qty_in = docs['draft_picking_qty']['in'] + draft_purchase_qty = docs['draft_purchase_qty'] + pending_qty_in = docs['qty']['in'] + self.assertEqual(len(lines), 0, "Must have 0 line for now.") + self.assertEqual(draft_picking_qty_in, 0) + self.assertEqual(draft_purchase_qty, 5) + self.assertEqual(pending_qty_in, 5) + + # Confirms the PO and checks the report again. + po.button_confirm() + report_values, docs, lines = self.get_report_forecast(product_template_ids=self.product_template.ids) + draft_picking_qty_in = docs['draft_picking_qty']['in'] + draft_purchase_qty = docs['draft_purchase_qty'] + pending_qty_in = docs['qty']['in'] + self.assertEqual(len(lines), 1) + self.assertEqual(lines[0]['document_in'].id, po.id) + self.assertEqual(lines[0]['quantity'], 5) + self.assertEqual(lines[0]['document_out'], False) + self.assertEqual(draft_picking_qty_in, 0) + self.assertEqual(draft_purchase_qty, 0) + self.assertEqual(pending_qty_in, 0) + + # Receives 5 products. + receipt = po.picking_ids + res_dict = receipt.button_validate() + wizard = Form(self.env[res_dict['res_model']].with_context(res_dict['context'])).save() + wizard.process() + report_values, docs, lines = self.get_report_forecast(product_template_ids=self.product_template.ids) + draft_picking_qty_in = docs['draft_picking_qty']['in'] + draft_purchase_qty = docs['draft_purchase_qty'] + pending_qty_in = docs['qty']['in'] + self.assertEqual(len(lines), 0) + self.assertEqual(draft_picking_qty_in, 0) + self.assertEqual(draft_purchase_qty, 0) + self.assertEqual(pending_qty_in, 0) + + # Increase the PO quantity to 10, so must create a second receipt. + po_form = Form(po) + with po_form.order_line.edit(0) as line: + line.product_qty = 10 + po = po_form.save() + # Checks the report. + report_values, docs, lines = self.get_report_forecast(product_template_ids=self.product_template.ids) + draft_picking_qty_in = docs['draft_picking_qty']['in'] + draft_purchase_qty = docs['draft_purchase_qty'] + pending_qty_in = docs['qty']['in'] + self.assertEqual(len(lines), 1, "Must have 1 line for now.") + self.assertEqual(lines[0]['document_in'].id, po.id) + self.assertEqual(lines[0]['quantity'], 5) + self.assertEqual(draft_picking_qty_in, 0) + self.assertEqual(draft_purchase_qty, 0) + self.assertEqual(pending_qty_in, 0) + + def test_report_forecast_2_purchase_order_three_step_receipt(self): + """ Create a PO for 4 product, receive them then increase the quantity + to 10, but use three steps receipt. + """ + grp_multi_loc = self.env.ref('stock.group_stock_multi_locations') + grp_multi_routes = self.env.ref('stock.group_adv_location') + self.env.user.write({'groups_id': [(4, grp_multi_loc.id)]}) + self.env.user.write({'groups_id': [(4, grp_multi_routes.id)]}) + # Configure warehouse. + warehouse = self.env.ref('stock.warehouse0') + warehouse.reception_steps = 'three_steps' + + po_form = Form(self.env['purchase.order']) + po_form.partner_id = self.partner + with po_form.order_line.new() as line: + line.product_id = self.product + line.product_qty = 4 + po = po_form.save() + + # Checks the report -> Must be empty for now, just display some pending qty. + report_values, docs, lines = self.get_report_forecast(product_template_ids=self.product_template.ids) + draft_picking_qty_in = docs['draft_picking_qty']['in'] + draft_purchase_qty = docs['draft_purchase_qty'] + pending_qty_in = docs['qty']['in'] + self.assertEqual(len(lines), 0, "Must have 0 line for now.") + self.assertEqual(draft_picking_qty_in, 0) + self.assertEqual(draft_purchase_qty, 4) + self.assertEqual(pending_qty_in, 4) + + # Confirms the PO and checks the report again. + po.button_confirm() + report_values, docs, lines = self.get_report_forecast(product_template_ids=self.product_template.ids) + draft_picking_qty_in = docs['draft_picking_qty']['in'] + draft_purchase_qty = docs['draft_purchase_qty'] + pending_qty_in = docs['qty']['in'] + self.assertEqual(len(lines), 1) + self.assertEqual(lines[0]['document_in'].id, po.id) + self.assertEqual(lines[0]['quantity'], 4) + self.assertEqual(lines[0]['document_out'], False) + self.assertEqual(draft_picking_qty_in, 0) + self.assertEqual(draft_purchase_qty, 0) + self.assertEqual(pending_qty_in, 0) + # Get back the different transfers. + receipt = po.picking_ids + + # Receives 4 products. + res_dict = receipt.button_validate() + wizard = Form(self.env[res_dict['res_model']].with_context(res_dict['context'])).save() + wizard.process() + report_values, docs, lines = self.get_report_forecast(product_template_ids=self.product_template.ids) + draft_picking_qty_in = docs['draft_picking_qty']['in'] + draft_purchase_qty = docs['draft_purchase_qty'] + pending_qty_in = docs['qty']['in'] + self.assertEqual(len(lines), 0) + self.assertEqual(draft_picking_qty_in, 0) + self.assertEqual(draft_purchase_qty, 0) + self.assertEqual(pending_qty_in, 0) + + # Increase the PO quantity to 10, so must create a second receipt. + po_form = Form(po) + with po_form.order_line.edit(0) as line: + line.product_qty = 10 + po = po_form.save() + # Checks the report. + report_values, docs, lines = self.get_report_forecast(product_template_ids=self.product_template.ids) + draft_picking_qty_in = docs['draft_picking_qty']['in'] + draft_purchase_qty = docs['draft_purchase_qty'] + pending_qty_in = docs['qty']['in'] + self.assertEqual(len(lines), 1) + self.assertEqual(lines[0]['document_in'].id, po.id) + self.assertEqual(lines[0]['quantity'], 6) + self.assertEqual(draft_picking_qty_in, 0) + self.assertEqual(draft_purchase_qty, 0) + self.assertEqual(pending_qty_in, 0) diff --git a/addons/purchase_stock/tests/test_reordering_rule.py b/addons/purchase_stock/tests/test_reordering_rule.py new file mode 100644 index 00000000..bcb2f48a --- /dev/null +++ b/addons/purchase_stock/tests/test_reordering_rule.py @@ -0,0 +1,520 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime as dt +from datetime import timedelta as td + +from odoo import SUPERUSER_ID +from odoo.tests import Form +from odoo.tests.common import SavepointCase + + +class TestReorderingRule(SavepointCase): + @classmethod + def setUpClass(cls): + super(TestReorderingRule, cls).setUpClass() + cls.partner = cls.env['res.partner'].create({ + 'name': 'Smith' + }) + + # create product and set the vendor + product_form = Form(cls.env['product.product']) + product_form.name = 'Product A' + product_form.type = 'product' + product_form.description = 'Internal Notes' + with product_form.seller_ids.new() as seller: + seller.name = cls.partner + product_form.route_ids.add(cls.env.ref('purchase_stock.route_warehouse0_buy')) + cls.product_01 = product_form.save() + + def test_reordering_rule_1(self): + """ + - Receive products in 2 steps + - The product has a reordering rule + - On the po generated, the source document should be the name of the reordering rule + - Increase the quantity on the RFQ, the extra quantity should follow the push rules + - Increase the quantity on the PO, the extra quantity should follow the push rules + - There should be one move supplier -> input and two moves input -> stock + """ + warehouse_1 = self.env['stock.warehouse'].search([('company_id', '=', self.env.user.id)], limit=1) + warehouse_1.write({'reception_steps': 'two_steps'}) + + # create reordering rule + orderpoint_form = Form(self.env['stock.warehouse.orderpoint']) + orderpoint_form.warehouse_id = warehouse_1 + orderpoint_form.location_id = warehouse_1.lot_stock_id + orderpoint_form.product_id = self.product_01 + orderpoint_form.product_min_qty = 0.000 + orderpoint_form.product_max_qty = 0.000 + order_point = orderpoint_form.save() + # Create Delivery Order of 10 product + picking_form = Form(self.env['stock.picking']) + picking_form.partner_id = self.partner + picking_form.picking_type_id = self.env.ref('stock.picking_type_out') + with picking_form.move_ids_without_package.new() as move: + move.product_id = self.product_01 + move.product_uom_qty = 10.0 + customer_picking = picking_form.save() + # picking confirm + customer_picking.action_confirm() + # Run scheduler + self.env['procurement.group'].run_scheduler() + + # Check purchase order created or not + purchase_order = self.env['purchase.order'].search([('partner_id', '=', self.partner.id)]) + self.assertTrue(purchase_order, 'No purchase order created.') + + # On the po generated, the source document should be the name of the reordering rule + self.assertEqual(order_point.name, purchase_order.origin, 'Source document on purchase order should be the name of the reordering rule.') + self.assertEqual(purchase_order.order_line.product_qty, 10) + self.assertEqual(purchase_order.order_line.name, 'Product A') + + # Increase the quantity on the RFQ before confirming it + purchase_order.order_line.product_qty = 12 + purchase_order.button_confirm() + + self.assertEqual(purchase_order.picking_ids.move_lines.filtered(lambda m: m.product_id == self.product_01).product_qty, 12) + next_picking = purchase_order.picking_ids.move_lines.move_dest_ids.picking_id + self.assertEqual(len(next_picking), 2) + self.assertEqual(next_picking[0].move_lines.filtered(lambda m: m.product_id == self.product_01).product_qty, 10) + self.assertEqual(next_picking[1].move_lines.filtered(lambda m: m.product_id == self.product_01).product_qty, 2) + + # Increase the quantity on the PO + purchase_order.order_line.product_qty = 15 + self.assertEqual(purchase_order.picking_ids.move_lines.product_qty, 15) + self.assertEqual(next_picking[0].move_lines.filtered(lambda m: m.product_id == self.product_01).product_qty, 10) + self.assertEqual(next_picking[1].move_lines.filtered(lambda m: m.product_id == self.product_01).product_qty, 5) + + def test_reordering_rule_2(self): + """ + - Receive products in 1 steps + - The product has two reordering rules, each one applying in a sublocation + - Processing the purchase order should fulfill the two sublocations + - Increase the quantity on the RFQ for one of the POL, the extra quantity will go to + the original subloc since we don't know where to push it (no move dest) + - Increase the quantity on the PO, the extra quantity should follow the push rules and + thus go to stock + """ + warehouse_1 = self.env['stock.warehouse'].search([('company_id', '=', self.env.user.id)], limit=1) + subloc_1 = self.env['stock.location'].create({'name': 'subloc_1', 'location_id': warehouse_1.lot_stock_id.id}) + subloc_2 = self.env['stock.location'].create({'name': 'subloc_2', 'location_id': warehouse_1.lot_stock_id.id}) + + # create reordering rules + orderpoint_form = Form(self.env['stock.warehouse.orderpoint']) + orderpoint_form.warehouse_id = warehouse_1 + orderpoint_form.location_id = subloc_1 + orderpoint_form.product_id = self.product_01 + orderpoint_form.product_min_qty = 0.000 + orderpoint_form.product_max_qty = 0.000 + order_point_1 = orderpoint_form.save() + orderpoint_form = Form(self.env['stock.warehouse.orderpoint']) + orderpoint_form.warehouse_id = warehouse_1 + orderpoint_form.location_id = subloc_2 + orderpoint_form.product_id = self.product_01 + orderpoint_form.product_min_qty = 0.000 + orderpoint_form.product_max_qty = 0.000 + order_point_2 = orderpoint_form.save() + + # Create Delivery Order of 10 product + picking_form = Form(self.env['stock.picking']) + picking_form.partner_id = self.partner + picking_form.picking_type_id = self.env.ref('stock.picking_type_out') + with picking_form.move_ids_without_package.new() as move: + move.product_id = self.product_01 + move.product_uom_qty = 10.0 + with picking_form.move_ids_without_package.new() as move: + move.product_id = self.product_01 + move.product_uom_qty = 10.0 + customer_picking = picking_form.save() + customer_picking.move_lines[0].location_id = subloc_1.id + customer_picking.move_lines[1].location_id = subloc_2.id + + # picking confirm + customer_picking.action_confirm() + self.assertEqual(self.product_01.with_context(location=subloc_1.id).virtual_available, -10) + self.assertEqual(self.product_01.with_context(location=subloc_2.id).virtual_available, -10) + + # Run scheduler + self.env['procurement.group'].run_scheduler() + + # Check purchase order created or not + purchase_order = self.env['purchase.order'].search([('partner_id', '=', self.partner.id)]) + self.assertTrue(purchase_order, 'No purchase order created.') + self.assertEqual(len(purchase_order.order_line), 2, 'Not enough purchase order lines created.') + + # increment the qty of the first po line + purchase_order.order_line.filtered(lambda pol: pol.orderpoint_id == order_point_1).product_qty = 15 + purchase_order.button_confirm() + self.assertEqual(self.product_01.with_context(location=subloc_1.id).virtual_available, 5) + self.assertEqual(self.product_01.with_context(location=subloc_2.id).virtual_available, 0) + + # increment the qty of the second po line + purchase_order.order_line.filtered(lambda pol: pol.orderpoint_id == order_point_2).with_context(debug=True).product_qty = 15 + self.assertEqual(self.product_01.with_context(location=subloc_1.id).virtual_available, 5) + self.assertEqual(self.product_01.with_context(location=subloc_2.id).virtual_available, 0) + self.assertEqual(self.product_01.with_context(location=warehouse_1.lot_stock_id.id).virtual_available, 10) # 5 on the main loc, 5 on subloc_1 + + self.assertEqual(purchase_order.picking_ids.move_lines[-1].product_qty, 5) + self.assertEqual(purchase_order.picking_ids.move_lines[-1].location_dest_id, warehouse_1.lot_stock_id) + + def test_replenish_report_1(self): + """Tests the auto generation of manual orderpoints. + + Opening multiple times the report should not duplicate the generated orderpoints. + MTO products should not trigger the creation of generated orderpoints + """ + partner = self.env['res.partner'].create({ + 'name': 'Tintin' + }) + route_buy = self.env.ref('purchase_stock.route_warehouse0_buy') + route_mto = self.env.ref('stock.route_warehouse0_mto') + + product_form = Form(self.env['product.product']) + product_form.name = 'Simple Product' + product_form.type = 'product' + with product_form.seller_ids.new() as s: + s.name = partner + product = product_form.save() + + product_form = Form(self.env['product.product']) + product_form.name = 'Product BUY + MTO' + product_form.type = 'product' + product_form.route_ids.add(route_buy) + product_form.route_ids.add(route_mto) + with product_form.seller_ids.new() as s: + s.name = partner + product_buy_mto = product_form.save() + + # Create Delivery Order of 20 product and 10 buy + MTO + picking_form = Form(self.env['stock.picking']) + picking_form.partner_id = partner + picking_form.picking_type_id = self.env.ref('stock.picking_type_out') + with picking_form.move_ids_without_package.new() as move: + move.product_id = product + move.product_uom_qty = 10.0 + with picking_form.move_ids_without_package.new() as move: + move.product_id = product + move.product_uom_qty = 10.0 + with picking_form.move_ids_without_package.new() as move: + move.product_id = product_buy_mto + move.product_uom_qty = 10.0 + customer_picking = picking_form.save() + customer_picking.move_lines.filtered(lambda m: m.product_id == product_buy_mto).procure_method = 'make_to_order' + customer_picking.action_confirm() + self.env['stock.warehouse.orderpoint']._get_orderpoint_action() + self.env['stock.warehouse.orderpoint']._get_orderpoint_action() + + orderpoint_product = self.env['stock.warehouse.orderpoint'].search( + [('product_id', '=', product.id)]) + orderpoint_product_mto_buy = self.env['stock.warehouse.orderpoint'].search( + [('product_id', '=', product_buy_mto.id)]) + self.assertFalse(orderpoint_product_mto_buy) + self.assertEqual(len(orderpoint_product), 1.0) + self.assertEqual(orderpoint_product.qty_to_order, 20.0) + self.assertEqual(orderpoint_product.trigger, 'manual') + self.assertEqual(orderpoint_product.create_uid.id, SUPERUSER_ID) + + orderpoint_product.action_replenish() + po = self.env['purchase.order'].search([('partner_id', '=', partner.id)]) + self.assertTrue(po) + self.assertEqual(len(po.order_line), 2.0) + po_line_product_mto = po.order_line.filtered(lambda l: l.product_id == product_buy_mto) + po_line_product = po.order_line.filtered(lambda l: l.product_id == product) + self.assertEqual(po_line_product_mto.product_uom_qty, 10.0) + self.assertEqual(po_line_product.product_uom_qty, 20.0) + + self.env['stock.warehouse.orderpoint']._get_orderpoint_action() + orderpoint_product = self.env['stock.warehouse.orderpoint'].search( + [('product_id', '=', product.id)]) + orderpoint_product_mto_buy = self.env['stock.warehouse.orderpoint'].search( + [('product_id', '=', product_buy_mto.id)]) + self.assertFalse(orderpoint_product) + self.assertFalse(orderpoint_product_mto_buy) + + # Create Delivery Order of 10 product and 10 buy + MTO + picking_form = Form(self.env['stock.picking']) + picking_form.partner_id = partner + picking_form.picking_type_id = self.env.ref('stock.picking_type_out') + with picking_form.move_ids_without_package.new() as move: + move.product_id = product + move.product_uom_qty = 10.0 + with picking_form.move_ids_without_package.new() as move: + move.product_id = product_buy_mto + move.product_uom_qty = 10.0 + customer_picking = picking_form.save() + customer_picking.move_lines.filtered(lambda m: m.product_id == product_buy_mto).procure_method = 'make_to_order' + customer_picking.action_confirm() + self.env['stock.warehouse.orderpoint'].flush() + + self.env['stock.warehouse.orderpoint']._get_orderpoint_action() + orderpoint_product = self.env['stock.warehouse.orderpoint'].search( + [('product_id', '=', product.id)]) + orderpoint_product_mto_buy = self.env['stock.warehouse.orderpoint'].search( + [('product_id', '=', product_buy_mto.id)]) + self.assertFalse(orderpoint_product_mto_buy) + self.assertEqual(len(orderpoint_product), 1.0) + self.assertEqual(orderpoint_product.qty_to_order, 10.0) + self.assertEqual(orderpoint_product.trigger, 'manual') + self.assertEqual(orderpoint_product.create_uid.id, SUPERUSER_ID) + + def test_replenish_report_2(self): + """Same then `test_replenish_report_1` but with two steps receipt enabled""" + partner = self.env['res.partner'].create({ + 'name': 'Tintin' + }) + for wh in self.env['stock.warehouse'].search([]): + wh.write({'reception_steps': 'two_steps'}) + route_buy = self.env.ref('purchase_stock.route_warehouse0_buy') + route_mto = self.env.ref('stock.route_warehouse0_mto') + + product_form = Form(self.env['product.product']) + product_form.name = 'Simple Product' + product_form.type = 'product' + with product_form.seller_ids.new() as s: + s.name = partner + product = product_form.save() + + product_form = Form(self.env['product.product']) + product_form.name = 'Product BUY + MTO' + product_form.type = 'product' + product_form.route_ids.add(route_buy) + product_form.route_ids.add(route_mto) + with product_form.seller_ids.new() as s: + s.name = partner + product_buy_mto = product_form.save() + + # Create Delivery Order of 20 product and 10 buy + MTO + picking_form = Form(self.env['stock.picking']) + picking_form.partner_id = partner + picking_form.picking_type_id = self.env.ref('stock.picking_type_out') + with picking_form.move_ids_without_package.new() as move: + move.product_id = product + move.product_uom_qty = 10.0 + with picking_form.move_ids_without_package.new() as move: + move.product_id = product + move.product_uom_qty = 10.0 + with picking_form.move_ids_without_package.new() as move: + move.product_id = product_buy_mto + move.product_uom_qty = 10.0 + customer_picking = picking_form.save() + customer_picking.move_lines.filtered(lambda m: m.product_id == product_buy_mto).procure_method = 'make_to_order' + customer_picking.action_confirm() + self.env['stock.warehouse.orderpoint']._get_orderpoint_action() + orderpoint_product = self.env['stock.warehouse.orderpoint'].search( + [('product_id', '=', product.id)]) + orderpoint_product_mto_buy = self.env['stock.warehouse.orderpoint'].search( + [('product_id', '=', product_buy_mto.id)]) + self.assertFalse(orderpoint_product_mto_buy) + self.assertEqual(len(orderpoint_product), 1.0) + self.assertEqual(orderpoint_product.qty_to_order, 20.0) + self.assertEqual(orderpoint_product.trigger, 'manual') + self.assertEqual(orderpoint_product.create_uid.id, SUPERUSER_ID) + + orderpoint_product.action_replenish() + po = self.env['purchase.order'].search([('partner_id', '=', partner.id)]) + self.assertTrue(po) + self.assertEqual(len(po.order_line), 2.0) + po_line_product_mto = po.order_line.filtered(lambda l: l.product_id == product_buy_mto) + po_line_product = po.order_line.filtered(lambda l: l.product_id == product) + self.assertEqual(po_line_product_mto.product_uom_qty, 10.0) + self.assertEqual(po_line_product.product_uom_qty, 20.0) + + self.env['stock.warehouse.orderpoint'].flush() + self.env['stock.warehouse.orderpoint']._get_orderpoint_action() + orderpoint_product = self.env['stock.warehouse.orderpoint'].search( + [('product_id', '=', product.id)]) + orderpoint_product_mto_buy = self.env['stock.warehouse.orderpoint'].search( + [('product_id', '=', product_buy_mto.id)]) + self.assertFalse(orderpoint_product) + self.assertFalse(orderpoint_product_mto_buy) + + # Create Delivery Order of 10 product and 10 buy + MTO + picking_form = Form(self.env['stock.picking']) + picking_form.partner_id = partner + picking_form.picking_type_id = self.env.ref('stock.picking_type_out') + with picking_form.move_ids_without_package.new() as move: + move.product_id = product + move.product_uom_qty = 10.0 + with picking_form.move_ids_without_package.new() as move: + move.product_id = product_buy_mto + move.product_uom_qty = 10.0 + customer_picking = picking_form.save() + customer_picking.move_lines.filtered(lambda m: m.product_id == product_buy_mto).procure_method = 'make_to_order' + customer_picking.action_confirm() + self.env['stock.warehouse.orderpoint'].flush() + + self.env['stock.warehouse.orderpoint']._get_orderpoint_action() + orderpoint_product = self.env['stock.warehouse.orderpoint'].search( + [('product_id', '=', product.id)]) + orderpoint_product_mto_buy = self.env['stock.warehouse.orderpoint'].search( + [('product_id', '=', product_buy_mto.id)]) + self.assertFalse(orderpoint_product_mto_buy) + self.assertEqual(len(orderpoint_product), 1.0) + self.assertEqual(orderpoint_product.qty_to_order, 10.0) + self.assertEqual(orderpoint_product.trigger, 'manual') + self.assertEqual(orderpoint_product.create_uid.id, SUPERUSER_ID) + + def test_procure_not_default_partner(self): + """Define a product with 2 vendors. First run a "standard" procurement, + default vendor should be used. Then, call a procurement with + `partner_id` specified in values, the specified vendor should be + used.""" + purchase_route = self.env.ref("purchase_stock.route_warehouse0_buy") + uom_unit = self.env.ref("uom.product_uom_unit") + warehouse = self.env['stock.warehouse'].search( + [('company_id', '=', self.env.company.id)], limit=1) + product = self.env["product.product"].create({ + "name": "product TEST", + "standard_price": 100.0, + "type": "product", + "uom_id": uom_unit.id, + "default_code": "A", + "route_ids": [(6, 0, purchase_route.ids)], + }) + default_vendor = self.env["res.partner"].create({ + "name": "Supplier A", + }) + secondary_vendor = self.env["res.partner"].create({ + "name": "Supplier B", + }) + self.env["product.supplierinfo"].create({ + "name": default_vendor.id, + "product_tmpl_id": product.product_tmpl_id.id, + "delay": 7, + }) + self.env["product.supplierinfo"].create({ + "name": secondary_vendor.id, + "product_tmpl_id": product.product_tmpl_id.id, + "delay": 10, + }) + + # Test standard procurement. + po_line = self.env["purchase.order.line"].search( + [("product_id", "=", product.id)]) + self.assertFalse(po_line) + self.env["procurement.group"].run( + [self.env["procurement.group"].Procurement( + product, 100, uom_unit, + warehouse.lot_stock_id, "Test default vendor", "/", + self.env.company, + { + "warehouse_id": warehouse, + "date_planned": dt.today() + td(days=15), + "rule_id": warehouse.buy_pull_id, + "group_id": False, + "route_ids": [], + } + )]) + po_line = self.env["purchase.order.line"].search( + [("product_id", "=", product.id)]) + self.assertTrue(po_line) + self.assertEqual(po_line.partner_id, default_vendor) + po_line.order_id.button_cancel() + po_line.order_id.unlink() + + # now force the vendor: + po_line = self.env["purchase.order.line"].search( + [("product_id", "=", product.id)]) + self.assertFalse(po_line) + self.env["procurement.group"].run( + [self.env["procurement.group"].Procurement( + product, 100, uom_unit, + warehouse.lot_stock_id, "Test default vendor", "/", + self.env.company, + { + "warehouse_id": warehouse, + "date_planned": dt.today() + td(days=15), + "rule_id": warehouse.buy_pull_id, + "group_id": False, + "route_ids": [], + "supplierinfo_name": secondary_vendor, + } + )]) + po_line = self.env["purchase.order.line"].search( + [("product_id", "=", product.id)]) + self.assertTrue(po_line) + self.assertEqual(po_line.partner_id, secondary_vendor) + + def test_procure_multi_lingual(self): + """ + Define a product with description in English and French. + Run a procurement specifying a group_id with a partner (customer) + set up with French as language. Verify that the PO is generated + using the default (English) language. + """ + purchase_route = self.env.ref("purchase_stock.route_warehouse0_buy") + # create a new warehouse to make sure it gets the mts/mto rule + warehouse = self.env['stock.warehouse'].create({ + "name": "test warehouse", + "active": True, + 'reception_steps': 'one_step', + 'delivery_steps': 'ship_only', + 'code': 'TEST' + }) + customer_loc, _ = warehouse._get_partner_locations() + mto_rule = self.env['stock.rule'].search( + [('warehouse_id', '=', warehouse.id), + ('procure_method', '=', 'mts_else_mto'), + ('location_id', '=', customer_loc.id) + ] + ) + route_mto = self.env["stock.location.route"].create({ + "name": "MTO", + "active": True, + "sequence": 3, + "product_selectable": True, + "rule_ids": [(6, 0, [ + mto_rule.id + ])] + }) + uom_unit = self.env.ref("uom.product_uom_unit") + product = self.env["product.product"].create({ + "name": "product TEST", + "standard_price": 100.0, + "type": "product", + "uom_id": uom_unit.id, + "default_code": "A", + "route_ids": [(6, 0, [ + route_mto.id, + purchase_route.id, + ])], + }) + self.env['res.lang']._activate_lang('fr_FR') + self.env['ir.translation']._set_ids('product.template,name', 'model', 'fr_FR', product.product_tmpl_id.ids, 'produit en français') + self.env['ir.translation']._set_ids('product.product,name', 'model', 'fr_FR', product.ids, 'produit en français') + default_vendor = self.env["res.partner"].create({ + "name": "Supplier A", + }) + self.env["product.supplierinfo"].create({ + "name": default_vendor.id, + "product_tmpl_id": product.product_tmpl_id.id, + "delay": 7, + }) + customer = self.env["res.partner"].create({ + "name": "Customer", + "lang": "fr_FR" + }) + proc_group = self.env["procurement.group"].create({ + "partner_id": customer.id + }) + procurement = self.env["procurement.group"].Procurement( + product, 100, uom_unit, + customer.property_stock_customer, + "Test default vendor", + "/", + self.env.company, + { + "warehouse_id": warehouse, + "date_planned": dt.today() + td(days=15), + "group_id": proc_group, + "route_ids": [], + } + ) + self.env.cache.invalidate() + + self.env["procurement.group"].run([procurement]) + + po_line = self.env["purchase.order.line"].search( + [("product_id", "=", product.id)]) + self.assertTrue(po_line) + self.assertEqual("[A] product TEST", po_line.name) diff --git a/addons/purchase_stock/tests/test_replenish_wizard.py b/addons/purchase_stock/tests/test_replenish_wizard.py new file mode 100644 index 00000000..bdb126dd --- /dev/null +++ b/addons/purchase_stock/tests/test_replenish_wizard.py @@ -0,0 +1,249 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.addons.stock.tests.common import TestStockCommon + + +class TestReplenishWizard(TestStockCommon): + def setUp(self): + super(TestReplenishWizard, self).setUp() + self.vendor = self.env['res.partner'].create(dict(name='The Replenisher')) + self.product1_price = 500 + + # Create a supplier info witch the previous vendor + self.supplierinfo = self.env['product.supplierinfo'].create({ + 'name': self.vendor.id, + 'price': self.product1_price, + }) + + # Create a product with the 'buy' route and + # the 'supplierinfo' prevously created + self.product1 = self.env['product.product'].create({ + 'name': 'product a', + 'type': 'product', + 'categ_id': self.env.ref('product.product_category_all').id, + 'seller_ids': [(4, self.supplierinfo.id, 0)], + 'route_ids': [(4, self.env.ref('purchase_stock.route_warehouse0_buy').id, 0)], + }) + + # Additional Values required by the replenish wizard + self.uom_unit = self.env.ref('uom.product_uom_unit') + self.wh = self.env['stock.warehouse'].search([('company_id', '=', self.env.user.id)], limit=1) + + def test_replenish_buy_1(self): + """ Set a quantity to replenish via the "Buy" route and check if + a purchase order is created with the correct values + """ + self.product_uom_qty = 42 + + replenish_wizard = self.env['product.replenish'].create({ + 'product_id': self.product1.id, + 'product_tmpl_id': self.product1.product_tmpl_id.id, + 'product_uom_id': self.uom_unit.id, + 'quantity': self.product_uom_qty, + 'warehouse_id': self.wh.id, + }) + replenish_wizard.launch_replenishment() + last_po_id = self.env['purchase.order'].search([ + ('origin', 'ilike', '%Manual Replenishment%'), + ('partner_id', '=', self.vendor.id) + ])[-1] + self.assertTrue(last_po_id, 'Purchase Order not found') + order_line = last_po_id.order_line.search([('product_id', '=', self.product1.id)]) + self.assertTrue(order_line, 'The product is not in the Purchase Order') + self.assertEqual(order_line.product_qty, self.product_uom_qty, 'Quantities does not match') + self.assertEqual(order_line.price_unit, self.product1_price, 'Prices does not match') + + def test_chose_supplier_1(self): + """ Choose supplier based on the ordered quantity and minimum price + + replenish 10 + + 1)seq1 vendor1 140 min qty 1 + 2)seq2 vendor1 100 min qty 10 + -> 2) should be chosen + """ + product_to_buy = self.env['product.product'].create({ + 'name': "Furniture Service", + 'type': 'product', + 'categ_id': self.env.ref('product.product_category_all').id, + 'route_ids': [(4, self.env.ref('purchase_stock.route_warehouse0_buy').id, 0)], + }) + vendor1 = self.env['res.partner'].create({'name': 'vendor1', 'email': 'from.test@example.com'}) + + supplierinfo1 = self.env['product.supplierinfo'].create({ + 'product_tmpl_id': product_to_buy.product_tmpl_id.id, + 'name': vendor1.id, + 'min_qty': 1, + 'price': 140, + 'sequence': 1, + }) + supplierinfo2 = self.env['product.supplierinfo'].create({ + 'product_tmpl_id': product_to_buy.product_tmpl_id.id, + 'name': vendor1.id, + 'min_qty': 10, + 'price': 100, + 'sequence': 2, + }) + + replenish_wizard = self.env['product.replenish'].create({ + 'product_id': product_to_buy.id, + 'product_tmpl_id': product_to_buy.product_tmpl_id.id, + 'product_uom_id': self.uom_unit.id, + 'quantity': 10, + 'warehouse_id': self.wh.id, + }) + replenish_wizard.launch_replenishment() + last_po_id = self.env['purchase.order'].search([ + ('origin', 'ilike', '%Manual Replenishment%'), + ])[-1] + self.assertEqual(last_po_id.partner_id, vendor1) + self.assertEqual(last_po_id.order_line.price_unit, 100) + + def test_chose_supplier_2(self): + """ Choose supplier based on the ordered quantity and minimum price + + replenish 10 + + 1)seq1 vendor1 140 min qty 1 + 2)seq2 vendor2 90 min qty 10 + 3)seq3 vendor1 100 min qty 10 + -> 3) should be chosen + """ + product_to_buy = self.env['product.product'].create({ + 'name': "Furniture Service", + 'type': 'product', + 'categ_id': self.env.ref('product.product_category_all').id, + 'route_ids': [(4, self.env.ref('purchase_stock.route_warehouse0_buy').id, 0)], + }) + vendor1 = self.env['res.partner'].create({'name': 'vendor1', 'email': 'from.test@example.com'}) + vendor2 = self.env['res.partner'].create({'name': 'vendor2', 'email': 'from.test2@example.com'}) + + supplierinfo1 = self.env['product.supplierinfo'].create({ + 'product_tmpl_id': product_to_buy.product_tmpl_id.id, + 'name': vendor1.id, + 'min_qty': 1, + 'price': 140, + 'sequence': 1, + }) + supplierinfo2 = self.env['product.supplierinfo'].create({ + 'product_tmpl_id': product_to_buy.product_tmpl_id.id, + 'name': vendor2.id, + 'min_qty': 10, + 'price': 90, + 'sequence': 2, + }) + supplierinfo3 = self.env['product.supplierinfo'].create({ + 'product_tmpl_id': product_to_buy.product_tmpl_id.id, + 'name': vendor1.id, + 'min_qty': 10, + 'price': 100, + 'sequence': 3, + }) + + replenish_wizard = self.env['product.replenish'].create({ + 'product_id': product_to_buy.id, + 'product_tmpl_id': product_to_buy.product_tmpl_id.id, + 'product_uom_id': self.uom_unit.id, + 'quantity': 10, + 'warehouse_id': self.wh.id, + }) + replenish_wizard.launch_replenishment() + last_po_id = self.env['purchase.order'].search([ + ('origin', 'ilike', '%Manual Replenishment%'), + ])[-1] + self.assertEqual(last_po_id.partner_id, vendor1) + self.assertEqual(last_po_id.order_line.price_unit, 100) + + def test_chose_supplier_3(self): + """ Choose supplier based on the ordered quantity and minimum price + + replenish 10 + + 1)seq2 vendor1 50 + 2)seq1 vendor2 50 + -> 2) should be chosen + """ + product_to_buy = self.env['product.product'].create({ + 'name': "Furniture Service", + 'type': 'product', + 'categ_id': self.env.ref('product.product_category_all').id, + 'route_ids': [(4, self.env.ref('purchase_stock.route_warehouse0_buy').id, 0)], + }) + vendor1 = self.env['res.partner'].create({'name': 'vendor1', 'email': 'from.test@example.com'}) + vendor2 = self.env['res.partner'].create({'name': 'vendor2', 'email': 'from.test2@example.com'}) + + supplierinfo1 = self.env['product.supplierinfo'].create({ + 'product_tmpl_id': product_to_buy.product_tmpl_id.id, + 'name': vendor1.id, + 'price': 50, + 'sequence': 2, + }) + supplierinfo2 = self.env['product.supplierinfo'].create({ + 'product_tmpl_id': product_to_buy.product_tmpl_id.id, + 'name': vendor2.id, + 'price': 50, + 'sequence': 1, + }) + + replenish_wizard = self.env['product.replenish'].create({ + 'product_id': product_to_buy.id, + 'product_tmpl_id': product_to_buy.product_tmpl_id.id, + 'product_uom_id': self.uom_unit.id, + 'quantity': 10, + 'warehouse_id': self.wh.id, + }) + replenish_wizard.launch_replenishment() + last_po_id = self.env['purchase.order'].search([ + ('origin', 'ilike', '%Manual Replenishment%'), + ])[-1] + self.assertEqual(last_po_id.partner_id, vendor2) + + def test_chose_supplier_4(self): + """ Choose supplier based on the ordered quantity and minimum price + + replenish 10 + + 1)seq1 vendor1 100 min qty 2 + 2)seq2 vendor1 60 min qty 10 + 2)seq3 vendor1 80 min qty 5 + -> 2) should be chosen + """ + product_to_buy = self.env['product.product'].create({ + 'name': "Furniture Service", + 'type': 'product', + 'categ_id': self.env.ref('product.product_category_all').id, + 'route_ids': [(4, self.env.ref('purchase_stock.route_warehouse0_buy').id, 0)], + }) + vendor1 = self.env['res.partner'].create({'name': 'vendor1', 'email': 'from.test@example.com'}) + supplierinfo1 = self.env['product.supplierinfo'].create({ + 'name': vendor1.id, + 'price': 100, + 'product_tmpl_id': product_to_buy.product_tmpl_id.id, + 'min_qty': 2 + }) + supplierinfo2 = self.env['product.supplierinfo'].create({ + 'name': vendor1.id, + 'price': 60, + 'product_tmpl_id': product_to_buy.product_tmpl_id.id, + 'min_qty': 10 + }) + supplierinfo3 = self.env['product.supplierinfo'].create({ + 'name': vendor1.id, + 'price': 80, + 'product_tmpl_id': product_to_buy.product_tmpl_id.id, + 'min_qty': 5 + }) + replenish_wizard = self.env['product.replenish'].create({ + 'product_id': product_to_buy.id, + 'product_tmpl_id': product_to_buy.product_tmpl_id.id, + 'product_uom_id': self.uom_unit.id, + 'quantity': 10, + 'warehouse_id': self.wh.id, + }) + replenish_wizard.launch_replenishment() + last_po_id = self.env['purchase.order'].search([ + ('origin', 'ilike', '%Manual Replenishment%'), + ])[-1] + + self.assertEqual(last_po_id.partner_id, vendor1) + self.assertEqual(last_po_id.order_line.price_unit, 60) diff --git a/addons/purchase_stock/tests/test_routes.py b/addons/purchase_stock/tests/test_routes.py new file mode 100644 index 00000000..7510011b --- /dev/null +++ b/addons/purchase_stock/tests/test_routes.py @@ -0,0 +1,53 @@ +from odoo.tests.common import TransactionCase, Form + + +class TestRoutes(TransactionCase): + + def test_allow_rule_creation_for_route_without_company(self): + self.env['res.config.settings'].write({ + 'group_stock_adv_location': True, + 'group_stock_multi_locations': True, + }) + + warehouse = self.env['stock.warehouse'].search([('company_id', '=', self.env.company.id)], limit=1) + + location_1 = self.env['stock.location'].create({ + 'name': 'loc1', + 'location_id': warehouse.id + }) + + location_2 = self.env['stock.location'].create({ + 'name': 'loc2', + 'location_id': warehouse.id + }) + + receipt_1 = self.env['stock.picking.type'].create({ + 'name': 'Receipts from loc1', + 'sequence_code': 'IN1', + 'code': 'incoming', + 'warehouse_id': warehouse.id, + 'default_location_dest_id': location_1.id, + }) + + receipt_2 = self.env['stock.picking.type'].create({ + 'name': 'Receipts from loc2', + 'sequence_code': 'IN2', + 'code': 'incoming', + 'warehouse_id': warehouse.id, + 'default_location_dest_id': location_2.id, + }) + + route = self.env['stock.location.route'].create({ + 'name': 'Buy', + 'company_id': False + }) + + with Form(route) as r: + with r.rule_ids.new() as line: + line.name = 'first rule' + line.action = 'buy' + line.picking_type_id = receipt_1 + with r.rule_ids.new() as line: + line.name = 'second rule' + line.action = 'buy' + line.picking_type_id = receipt_2 diff --git a/addons/purchase_stock/tests/test_stockvaluation.py b/addons/purchase_stock/tests/test_stockvaluation.py new file mode 100644 index 00000000..06cfabd6 --- /dev/null +++ b/addons/purchase_stock/tests/test_stockvaluation.py @@ -0,0 +1,1328 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import time +from datetime import datetime +from unittest.mock import patch + +from odoo import fields +from odoo.tests import Form +from odoo.tests.common import TransactionCase, tagged +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT + + +class TestStockValuation(TransactionCase): + def setUp(self): + super(TestStockValuation, self).setUp() + self.supplier_location = self.env.ref('stock.stock_location_suppliers') + self.stock_location = self.env.ref('stock.stock_location_stock') + self.partner_id = self.env['res.partner'].create({ + 'name': 'Wood Corner Partner', + 'company_id': self.env.user.company_id.id, + }) + self.product1 = self.env['product.product'].create({ + 'name': 'Large Desk', + 'standard_price': 1299.0, + 'list_price': 1799.0, + 'type': 'product', + }) + Account = self.env['account.account'] + self.stock_input_account = Account.create({ + 'name': 'Stock Input', + 'code': 'StockIn', + 'user_type_id': self.env.ref('account.data_account_type_current_assets').id, + 'reconcile': True, + }) + self.stock_output_account = Account.create({ + 'name': 'Stock Output', + 'code': 'StockOut', + 'user_type_id': self.env.ref('account.data_account_type_current_assets').id, + 'reconcile': True, + }) + self.stock_valuation_account = Account.create({ + 'name': 'Stock Valuation', + 'code': 'Stock Valuation', + 'user_type_id': self.env.ref('account.data_account_type_current_assets').id, + }) + self.stock_journal = self.env['account.journal'].create({ + 'name': 'Stock Journal', + 'code': 'STJTEST', + 'type': 'general', + }) + self.product1.categ_id.write({ + 'property_stock_account_input_categ_id': self.stock_input_account.id, + 'property_stock_account_output_categ_id': self.stock_output_account.id, + 'property_stock_valuation_account_id': self.stock_valuation_account.id, + 'property_stock_journal': self.stock_journal.id, + }) + + def test_change_unit_cost_average_1(self): + """ Confirm a purchase order and create the associated receipt, change the unit cost of the + purchase order before validating the receipt, the value of the received goods should be set + according to the last unit cost. + """ + self.product1.product_tmpl_id.categ_id.property_cost_method = 'average' + po1 = self.env['purchase.order'].create({ + 'partner_id': self.partner_id.id, + 'order_line': [ + (0, 0, { + 'name': self.product1.name, + 'product_id': self.product1.id, + 'product_qty': 10.0, + 'product_uom': self.product1.uom_po_id.id, + 'price_unit': 100.0, + 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT), + }), + ], + }) + po1.button_confirm() + + picking1 = po1.picking_ids[0] + move1 = picking1.move_lines[0] + + # the unit price of the purchase order line is copied to the in move + self.assertEqual(move1.price_unit, 100) + + # update the unit price on the purchase order line + po1.order_line.price_unit = 200 + + # the unit price on the stock move is not directly updated + self.assertEqual(move1.price_unit, 100) + + # validate the receipt + res_dict = picking1.button_validate() + wizard = Form(self.env[(res_dict.get('res_model'))].with_context(res_dict['context'])).save() + wizard.process() + + # the unit price of the valuationlayer used the latest value + self.assertEqual(move1.stock_valuation_layer_ids.unit_cost, 200) + + self.assertEqual(self.product1.value_svl, 2000) + + def test_standard_price_change_1(self): + """ Confirm a purchase order and create the associated receipt, change the unit cost of the + purchase order and the standard price of the product before validating the receipt, the + value of the received goods should be set according to the last standard price. + """ + self.product1.product_tmpl_id.categ_id.property_cost_method = 'standard' + + # set a standard price + self.product1.product_tmpl_id.standard_price = 10 + + po1 = self.env['purchase.order'].create({ + 'partner_id': self.partner_id.id, + 'order_line': [ + (0, 0, { + 'name': self.product1.name, + 'product_id': self.product1.id, + 'product_qty': 10.0, + 'product_uom': self.product1.uom_po_id.id, + 'price_unit': 11.0, + 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT), + }), + ], + }) + po1.button_confirm() + + picking1 = po1.picking_ids[0] + move1 = picking1.move_lines[0] + + # the move's unit price reflects the purchase order line's cost even if it's useless when + # the product's cost method is standard + self.assertEqual(move1.price_unit, 11) + + # set a new standard price + self.product1.product_tmpl_id.standard_price = 12 + + # the unit price on the stock move is not directly updated + self.assertEqual(move1.price_unit, 11) + + # validate the receipt + res_dict = picking1.button_validate() + wizard = Form(self.env[(res_dict.get('res_model'))].with_context(res_dict['context'])).save() + wizard.process() + + # the unit price of the valuation layer used the latest value + self.assertEqual(move1.stock_valuation_layer_ids.unit_cost, 12) + + self.assertEqual(self.product1.value_svl, 120) + + def test_change_currency_rate_average_1(self): + """ Confirm a purchase order in another currency and create the associated receipt, change + the currency rate, validate the receipt and then check that the value of the received goods + is set according to the last currency rate. + """ + self.env['res.currency.rate'].search([]).unlink() + usd_currency = self.env.ref('base.USD') + self.env.company.currency_id = usd_currency.id + + eur_currency = self.env.ref('base.EUR') + + self.product1.product_tmpl_id.categ_id.property_cost_method = 'average' + + # default currency is USD, create a purchase order in EUR + po1 = self.env['purchase.order'].create({ + 'partner_id': self.partner_id.id, + 'currency_id': eur_currency.id, + 'order_line': [ + (0, 0, { + 'name': self.product1.name, + 'product_id': self.product1.id, + 'product_qty': 10.0, + 'product_uom': self.product1.uom_po_id.id, + 'price_unit': 100.0, + 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT), + }), + ], + }) + po1.button_confirm() + + picking1 = po1.picking_ids[0] + move1 = picking1.move_lines[0] + + # convert the price unit in the company currency + price_unit_usd = po1.currency_id._convert( + po1.order_line.price_unit, po1.company_id.currency_id, + self.env.company, fields.Date.today(), round=False) + + # the unit price of the move is the unit price of the purchase order line converted in + # the company's currency + self.assertAlmostEqual(move1.price_unit, price_unit_usd, places=2) + + # change the rate of the currency + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y-%m-%d'), + 'rate': 2.0, + 'currency_id': eur_currency.id, + 'company_id': po1.company_id.id, + }) + eur_currency._compute_current_rate() + price_unit_usd_new_rate = po1.currency_id._convert( + po1.order_line.price_unit, po1.company_id.currency_id, + self.env.company, fields.Date.today(), round=False) + + # the new price_unit is lower than th initial because of the rate's change + self.assertLess(price_unit_usd_new_rate, price_unit_usd) + + # the unit price on the stock move is not directly updated + self.assertAlmostEqual(move1.price_unit, price_unit_usd, places=2) + + # validate the receipt + res_dict = picking1.button_validate() + wizard = Form(self.env[(res_dict.get('res_model'))].with_context(res_dict['context'])).save() + wizard.process() + + # the unit price of the valuation layer used the latest value + self.assertAlmostEqual(move1.stock_valuation_layer_ids.unit_cost, price_unit_usd_new_rate) + + self.assertAlmostEqual(self.product1.value_svl, price_unit_usd_new_rate * 10, delta=0.1) + + def test_extra_move_fifo_1(self): + """ Check that the extra move when over processing a receipt is correctly merged back in + the original move. + """ + self.product1.product_tmpl_id.categ_id.property_cost_method = 'fifo' + po1 = self.env['purchase.order'].create({ + 'partner_id': self.partner_id.id, + 'order_line': [ + (0, 0, { + 'name': self.product1.name, + 'product_id': self.product1.id, + 'product_qty': 10.0, + 'product_uom': self.product1.uom_po_id.id, + 'price_unit': 100.0, + 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT), + }), + ], + }) + po1.button_confirm() + + picking1 = po1.picking_ids[0] + move1 = picking1.move_lines[0] + move1.quantity_done = 15 + picking1.button_validate() + + # there should be only one move + self.assertEqual(len(picking1.move_lines), 1) + self.assertEqual(move1.price_unit, 100) + self.assertEqual(move1.stock_valuation_layer_ids.unit_cost, 100) + self.assertEqual(move1.product_qty, 15) + self.assertEqual(self.product1.value_svl, 1500) + + def test_backorder_fifo_1(self): + """ Check that the backordered move when under processing a receipt correctly keep the + price unit of the original move. + """ + self.product1.product_tmpl_id.categ_id.property_cost_method = 'fifo' + po1 = self.env['purchase.order'].create({ + 'partner_id': self.partner_id.id, + 'order_line': [ + (0, 0, { + 'name': self.product1.name, + 'product_id': self.product1.id, + 'product_qty': 10.0, + 'product_uom': self.product1.uom_po_id.id, + 'price_unit': 100.0, + 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT), + }), + ], + }) + po1.button_confirm() + + picking1 = po1.picking_ids[0] + move1 = picking1.move_lines[0] + move1.quantity_done = 5 + res_dict = picking1.button_validate() + self.assertEqual(res_dict['res_model'], 'stock.backorder.confirmation') + wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id')).with_context(res_dict['context']) + wizard.process() + + self.assertEqual(len(picking1.move_lines), 1) + self.assertEqual(move1.price_unit, 100) + self.assertEqual(move1.product_qty, 5) + + picking2 = po1.picking_ids.filtered(lambda p: p.backorder_id) + move2 = picking2.move_lines[0] + self.assertEqual(len(picking2.move_lines), 1) + self.assertEqual(move2.price_unit, 100) + self.assertEqual(move2.product_qty, 5) + + +@tagged('post_install', '-at_install') +class TestStockValuationWithCOA(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + cls.supplier_location = cls.env.ref('stock.stock_location_suppliers') + cls.stock_location = cls.env.ref('stock.stock_location_stock') + cls.partner_id = cls.env['res.partner'].create({'name': 'Wood Corner Partner'}) + cls.product1 = cls.env['product.product'].create({'name': 'Large Desk'}) + + cls.cat = cls.env['product.category'].create({ + 'name': 'cat', + }) + cls.product1 = cls.env['product.product'].create({ + 'name': 'product1', + 'type': 'product', + 'categ_id': cls.cat.id, + }) + cls.product1_copy = cls.env['product.product'].create({ + 'name': 'product1', + 'type': 'product', + 'categ_id': cls.cat.id, + }) + + Account = cls.env['account.account'] + cls.usd_currency = cls.env.ref('base.USD') + cls.eur_currency = cls.env.ref('base.EUR') + + cls.stock_input_account = Account.create({ + 'name': 'Stock Input', + 'code': 'StockIn', + 'user_type_id': cls.env.ref('account.data_account_type_current_assets').id, + 'reconcile': True, + }) + cls.stock_output_account = Account.create({ + 'name': 'Stock Output', + 'code': 'StockOut', + 'user_type_id': cls.env.ref('account.data_account_type_current_assets').id, + 'reconcile': True, + }) + cls.stock_valuation_account = Account.create({ + 'name': 'Stock Valuation', + 'code': 'Stock Valuation', + 'user_type_id': cls.env.ref('account.data_account_type_current_assets').id, + }) + cls.price_diff_account = Account.create({ + 'name': 'price diff account', + 'code': 'price diff account', + 'user_type_id': cls.env.ref('account.data_account_type_current_assets').id, + }) + cls.stock_journal = cls.env['account.journal'].create({ + 'name': 'Stock Journal', + 'code': 'STJTEST', + 'type': 'general', + }) + cls.product1.categ_id.write({ + 'property_stock_account_input_categ_id': cls.stock_input_account.id, + 'property_stock_account_output_categ_id': cls.stock_output_account.id, + 'property_stock_valuation_account_id': cls.stock_valuation_account.id, + 'property_stock_journal': cls.stock_journal.id, + }) + + def test_fifo_anglosaxon_return(self): + self.env.company.anglo_saxon_accounting = True + self.product1.product_tmpl_id.categ_id.property_cost_method = 'fifo' + self.product1.product_tmpl_id.categ_id.property_valuation = 'real_time' + self.product1.property_account_creditor_price_difference = self.price_diff_account + + # Receive 10@10 ; create the vendor bill + po1 = self.env['purchase.order'].create({ + 'partner_id': self.partner_id.id, + 'order_line': [ + (0, 0, { + 'name': self.product1.name, + 'product_id': self.product1.id, + 'product_qty': 10.0, + 'product_uom': self.product1.uom_po_id.id, + 'price_unit': 10.0, + 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT), + }), + ], + }) + po1.button_confirm() + receipt_po1 = po1.picking_ids[0] + receipt_po1.move_lines.quantity_done = 10 + receipt_po1.button_validate() + + move_form = Form(self.env['account.move'].with_context(default_move_type='in_invoice')) + move_form.invoice_date = move_form.date + move_form.partner_id = self.partner_id + move_form.purchase_id = po1 + invoice_po1 = move_form.save() + invoice_po1.action_post() + + # Receive 10@20 ; create the vendor bill + po2 = self.env['purchase.order'].create({ + 'partner_id': self.partner_id.id, + 'order_line': [ + (0, 0, { + 'name': self.product1.name, + 'product_id': self.product1.id, + 'product_qty': 10.0, + 'product_uom': self.product1.uom_po_id.id, + 'price_unit': 20.0, + 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT), + }), + ], + }) + po2.button_confirm() + receipt_po2 = po2.picking_ids[0] + receipt_po2.move_lines.quantity_done = 10 + receipt_po2.button_validate() + + move_form = Form(self.env['account.move'].with_context(default_move_type='in_invoice')) + move_form.invoice_date = move_form.date + move_form.partner_id = self.partner_id + move_form.purchase_id = po2 + invoice_po2 = move_form.save() + invoice_po2.action_post() + + # valuation of product1 should be 300 + self.assertEqual(self.product1.value_svl, 300) + + # return the second po + stock_return_picking_form = Form(self.env['stock.return.picking'] + .with_context(active_ids=receipt_po2.ids, active_id=receipt_po2.ids[0], + active_model='stock.picking')) + stock_return_picking = stock_return_picking_form.save() + stock_return_picking.product_return_moves.quantity = 10 + stock_return_picking_action = stock_return_picking.create_returns() + return_pick = self.env['stock.picking'].browse(stock_return_picking_action['res_id']) + return_pick.move_lines[0].move_line_ids[0].qty_done = 10 + return_pick.button_validate() + + # valuation of product1 should be 200 as the first items will be sent out + self.assertEqual(self.product1.value_svl, 200) + + # create a credit note for po2 + move_form = Form(self.env['account.move'].with_context(default_move_type='in_refund')) + move_form.invoice_date = move_form.date + move_form.partner_id = self.partner_id + move_form.purchase_id = po2 + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.quantity = 10 + creditnote_po2 = move_form.save() + creditnote_po2.action_post() + + # check the anglo saxon entries + price_diff_entry = self.env['account.move.line'].search([('account_id', '=', self.price_diff_account.id)]) + self.assertEqual(price_diff_entry.credit, 100) + + def test_anglosaxon_valuation(self): + self.env.company.anglo_saxon_accounting = True + self.product1.product_tmpl_id.categ_id.property_cost_method = 'fifo' + self.product1.product_tmpl_id.categ_id.property_valuation = 'real_time' + self.product1.property_account_creditor_price_difference = self.price_diff_account + + # Create PO + po_form = Form(self.env['purchase.order']) + po_form.partner_id = self.partner_id + with po_form.order_line.new() as po_line: + po_line.product_id = self.product1 + po_line.product_qty = 1 + po_line.price_unit = 10.0 + order = po_form.save() + order.button_confirm() + + # Receive the goods + receipt = order.picking_ids[0] + receipt.move_lines.quantity_done = 1 + receipt.button_validate() + + # Create an invoice with a different price + move_form = Form(self.env['account.move'].with_context(default_move_type='in_invoice')) + move_form.invoice_date = move_form.date + move_form.partner_id = order.partner_id + move_form.purchase_id = order + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.price_unit = 15.0 + invoice = move_form.save() + invoice.action_post() + + # Check what was posted in the price difference account + price_diff_aml = self.env['account.move.line'].search([('account_id','=', self.price_diff_account.id)]) + self.assertEqual(len(price_diff_aml), 1, "Only one line should have been generated in the price difference account.") + self.assertAlmostEqual(price_diff_aml.debit, 5, "Price difference should be equal to 5 (15-10)") + + # Check what was posted in stock input account + input_aml = self.env['account.move.line'].search([('account_id','=',self.stock_input_account.id)]) + self.assertEqual(len(input_aml), 3, "Only three lines should have been generated in stock input account: one when receiving the product, one when making the invoice.") + invoice_amls = input_aml.filtered(lambda l: l.move_id == invoice) + picking_aml = input_aml - invoice_amls + self.assertAlmostEqual(sum(invoice_amls.mapped('debit')), 15, "Total debit value on stock input account should be equal to the original PO price of the product.") + self.assertAlmostEqual(sum(invoice_amls.mapped('credit')), 5, "Total debit value on stock input account should be equal to the original PO price of the product.") + self.assertAlmostEqual(sum(picking_aml.mapped('credit')), 10, "Total credit value on stock input account should be equal to the original PO price of the product.") + + def test_valuation_from_increasing_tax(self): + """ Check that a tax without account will increment the stock value. + """ + + tax_with_no_account = self.env['account.tax'].create({ + 'name': "Tax with no account", + 'amount_type': 'fixed', + 'amount': 5, + 'sequence': 8, + }) + + self.product1.product_tmpl_id.categ_id.property_cost_method = 'fifo' + self.product1.product_tmpl_id.categ_id.property_valuation = 'real_time' + + # Receive 10@10 ; create the vendor bill + po1 = self.env['purchase.order'].create({ + 'partner_id': self.partner_id.id, + 'order_line': [ + (0, 0, { + 'name': self.product1.name, + 'product_id': self.product1.id, + 'taxes_id': [(4, tax_with_no_account.id)], + 'product_qty': 10.0, + 'product_uom': self.product1.uom_po_id.id, + 'price_unit': 10.0, + 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT), + }), + ], + }) + po1.button_confirm() + receipt_po1 = po1.picking_ids[0] + receipt_po1.move_lines.quantity_done = 10 + receipt_po1.button_validate() + + # valuation of product1 should be 15 as the tax with no account set + # has gone to the stock account, and must be reflected in inventory valuation + self.assertEqual(self.product1.value_svl, 150) + + def test_average_realtime_anglo_saxon_valuation_multicurrency_same_date(self): + """ + The PO and invoice are in the same foreign currency. + The PO is invoiced on the same date as its creation. + This shouldn't create a price difference entry. + """ + company = self.env.user.company_id + company.anglo_saxon_accounting = True + company.currency_id = self.usd_currency + + date_po = '2019-01-01' + + # SetUp product + self.product1.product_tmpl_id.cost_method = 'average' + self.product1.product_tmpl_id.valuation = 'real_time' + self.product1.product_tmpl_id.purchase_method = 'purchase' + + self.product1.property_account_creditor_price_difference = self.price_diff_account + + # SetUp currency and rates + self.cr.execute("UPDATE res_company SET currency_id = %s WHERE id = %s", (self.usd_currency.id, company.id)) + self.env['res.currency.rate'].search([]).unlink() + self.env['res.currency.rate'].create({ + 'name': date_po, + 'rate': 1.0, + 'currency_id': self.usd_currency.id, + 'company_id': company.id, + }) + + self.env['res.currency.rate'].create({ + 'name': date_po, + 'rate': 1.5, + 'currency_id': self.eur_currency.id, + 'company_id': company.id, + }) + + # Proceed + po = self.env['purchase.order'].create({ + 'currency_id': self.eur_currency.id, + 'partner_id': self.partner_id.id, + 'order_line': [ + (0, 0, { + 'name': self.product1.name, + 'product_id': self.product1.id, + 'product_qty': 1.0, + 'product_uom': self.product1.uom_po_id.id, + 'price_unit': 100.0, + 'date_planned': date_po, + }), + ], + }) + po.button_confirm() + + inv = self.env['account.move'].with_context(default_move_type='in_invoice').create({ + 'move_type': 'in_invoice', + 'invoice_date': date_po, + 'date': date_po, + 'currency_id': self.eur_currency.id, + 'partner_id': self.partner_id.id, + 'invoice_line_ids': [(0, 0, { + 'name': 'Test', + 'price_unit': 100.0, + 'product_id': self.product1.id, + 'purchase_line_id': po.order_line.id, + 'quantity': 1.0, + 'account_id': self.stock_input_account.id, + })] + }) + + inv.action_post() + + move_lines = inv.line_ids + self.assertEqual(len(move_lines), 2) + + payable_line = move_lines.filtered(lambda l: l.account_id.internal_type == 'payable') + + self.assertEqual(payable_line.amount_currency, -100.0) + self.assertAlmostEqual(payable_line.balance, -66.67) + + stock_line = move_lines.filtered(lambda l: l.account_id == self.stock_input_account) + self.assertEqual(stock_line.amount_currency, 100.0) + self.assertAlmostEqual(stock_line.balance, 66.67) + + def test_realtime_anglo_saxon_valuation_multicurrency_different_dates(self): + """ + The PO and invoice are in the same foreign currency. + The PO is invoiced at a later date than its creation. + This should create a price difference entry for standard cost method + Not for average cost method though, since the PO and invoice have the same currency + """ + company = self.env.user.company_id + company.anglo_saxon_accounting = True + company.currency_id = self.usd_currency + self.product1.product_tmpl_id.categ_id.property_cost_method = 'average' + self.product1.product_tmpl_id.categ_id.property_valuation = 'real_time' + + date_po = '2019-01-01' + date_invoice = '2019-01-16' + + # SetUp product Average + self.product1.product_tmpl_id.write({ + 'purchase_method': 'purchase', + 'property_account_creditor_price_difference': self.price_diff_account.id, + }) + + # SetUp product Standard + # should have bought at 60 USD + # actually invoiced at 70 EUR > 35 USD + product_categ_standard = self.cat.copy({ + 'property_cost_method': 'standard', + 'property_stock_account_input_categ_id': self.stock_input_account.id, + 'property_stock_account_output_categ_id': self.stock_output_account.id, + 'property_stock_valuation_account_id': self.stock_valuation_account.id, + 'property_stock_journal': self.stock_journal.id, + }) + product_standard = self.product1_copy + product_standard.write({ + 'categ_id': product_categ_standard.id, + 'name': 'Standard Val', + 'standard_price': 60, + 'property_account_creditor_price_difference': self.price_diff_account.id + }) + + # SetUp currency and rates + self.cr.execute("UPDATE res_company SET currency_id = %s WHERE id = %s", (self.usd_currency.id, company.id)) + self.env['res.currency.rate'].search([]).unlink() + self.env['res.currency.rate'].create({ + 'name': date_po, + 'rate': 1.0, + 'currency_id': self.usd_currency.id, + 'company_id': company.id, + }) + + self.env['res.currency.rate'].create({ + 'name': date_po, + 'rate': 1.5, + 'currency_id': self.eur_currency.id, + 'company_id': company.id, + }) + + self.env['res.currency.rate'].create({ + 'name': date_invoice, + 'rate': 2, + 'currency_id': self.eur_currency.id, + 'company_id': company.id, + }) + + # To allow testing validation of PO + def _today(*args, **kwargs): + return date_po + patchers = [ + patch('odoo.fields.Date.context_today', _today), + ] + + for p in patchers: + p.start() + + # Proceed + po = self.env['purchase.order'].create({ + 'currency_id': self.eur_currency.id, + 'partner_id': self.partner_id.id, + 'order_line': [ + (0, 0, { + 'name': self.product1.name, + 'product_id': self.product1.id, + 'product_qty': 1.0, + 'product_uom': self.product1.uom_po_id.id, + 'price_unit': 100.0, + 'date_planned': date_po, + }), + (0, 0, { + 'name': product_standard.name, + 'product_id': product_standard.id, + 'product_qty': 1.0, + 'product_uom': product_standard.uom_po_id.id, + 'price_unit': 40.0, + 'date_planned': date_po, + }), + ], + }) + po.button_confirm() + + line_product_average = po.order_line.filtered(lambda l: l.product_id == self.product1) + line_product_standard = po.order_line.filtered(lambda l: l.product_id == product_standard) + + inv = self.env['account.move'].with_context(default_move_type='in_invoice').create({ + 'move_type': 'in_invoice', + 'invoice_date': date_invoice, + 'date': date_invoice, + 'currency_id': self.eur_currency.id, + 'partner_id': self.partner_id.id, + 'invoice_line_ids': [ + (0, 0, { + 'name': self.product1.name, + 'price_subtotal': 100.0, + 'price_unit': 100.0, + 'product_id': self.product1.id, + 'purchase_line_id': line_product_average.id, + 'quantity': 1.0, + 'account_id': self.stock_input_account.id, + }), + (0, 0, { + 'name': product_standard.name, + 'price_subtotal': 70.0, + 'price_unit': 70.0, + 'product_id': product_standard.id, + 'purchase_line_id': line_product_standard.id, + 'quantity': 1.0, + 'account_id': self.stock_input_account.id, + }) + ] + }) + + inv.action_post() + + for p in patchers: + p.stop() + + move_lines = inv.line_ids + self.assertEqual(len(move_lines), 5) + + # Ensure no exchange difference move has been created + self.assertTrue(all([not l.reconciled for l in move_lines])) + + # PAYABLE CHECK + payable_line = move_lines.filtered(lambda l: l.account_id.internal_type == 'payable') + self.assertEqual(payable_line.amount_currency, -170.0) + self.assertAlmostEqual(payable_line.balance, -85.00) + + # PRODUCTS CHECKS + + # NO EXCHANGE DIFFERENCE (average) + # We ordered for a value of 100 EUR + # But by the time we are invoiced for it + # the foreign currency appreciated from 1.5 to 2.0 + # We still have to pay 100 EUR, which now values at 50 USD + product_lines = move_lines.filtered(lambda l: l.product_id == self.product1) + + # Stock-wise, we have been invoiced 100 EUR, and we ordered 100 EUR + # there is no price difference + # However, 100 EUR should be converted at the time of the invoice + stock_lines = product_lines.filtered(lambda l: l.account_id == self.stock_input_account) + self.assertAlmostEqual(sum(stock_lines.mapped('amount_currency')), 100.00) + self.assertAlmostEqual(sum(stock_lines.mapped('balance')), 50.00) + + # PRICE DIFFERENCE (STANDARD) + # We ordered a product that should have cost 60 USD (120 EUR) + # However, we effectively got invoiced 70 EUR (35 USD) + product_lines = move_lines.filtered(lambda l: l.product_id == product_standard) + + stock_lines = product_lines.filtered(lambda l: l.account_id == self.stock_input_account) + self.assertAlmostEqual(sum(stock_lines.mapped('amount_currency')), 120.00) + self.assertAlmostEqual(sum(stock_lines.mapped('balance')), 60.00) + + price_diff_line = product_lines.filtered(lambda l: l.account_id == self.price_diff_account) + self.assertEqual(price_diff_line.amount_currency, -50.00) + self.assertAlmostEqual(price_diff_line.balance, -25.00) + + def test_average_realtime_with_delivery_anglo_saxon_valuation_multicurrency_different_dates(self): + """ + The PO and invoice are in the same foreign currency. + The delivery occurs in between PO validation and invoicing + The invoice is created at an even different date + This should create a price difference entry. + """ + company = self.env.user.company_id + company.anglo_saxon_accounting = True + company.currency_id = self.usd_currency + self.product1.product_tmpl_id.categ_id.property_cost_method = 'average' + self.product1.product_tmpl_id.categ_id.property_valuation = 'real_time' + + date_po = '2019-01-01' + date_delivery = '2019-01-08' + date_invoice = '2019-01-16' + + product_avg = self.product1_copy + product_avg.write({ + 'purchase_method': 'purchase', + 'name': 'AVG', + 'standard_price': 60, + 'property_account_creditor_price_difference': self.price_diff_account.id + }) + + # SetUp currency and rates + self.cr.execute("UPDATE res_company SET currency_id = %s WHERE id = %s", (self.usd_currency.id, company.id)) + self.env['res.currency.rate'].search([]).unlink() + self.env['res.currency.rate'].create({ + 'name': date_po, + 'rate': 1.0, + 'currency_id': self.usd_currency.id, + 'company_id': company.id, + }) + + self.env['res.currency.rate'].create({ + 'name': date_po, + 'rate': 1.5, + 'currency_id': self.eur_currency.id, + 'company_id': company.id, + }) + + self.env['res.currency.rate'].create({ + 'name': date_delivery, + 'rate': 0.7, + 'currency_id': self.eur_currency.id, + 'company_id': company.id, + }) + + self.env['res.currency.rate'].create({ + 'name': date_invoice, + 'rate': 2, + 'currency_id': self.eur_currency.id, + 'company_id': company.id, + }) + + # To allow testing validation of PO and Delivery + today = date_po + def _today(*args, **kwargs): + return datetime.strptime(today, "%Y-%m-%d").date() + def _now(*args, **kwargs): + return datetime.strptime(today + ' 01:00:00', "%Y-%m-%d %H:%M:%S") + + patchers = [ + patch('odoo.fields.Date.context_today', _today), + patch('odoo.fields.Datetime.now', _now), + ] + + for p in patchers: + p.start() + + # Proceed + po = self.env['purchase.order'].create({ + 'currency_id': self.eur_currency.id, + 'partner_id': self.partner_id.id, + 'order_line': [ + (0, 0, { + 'name': product_avg.name, + 'product_id': product_avg.id, + 'product_qty': 1.0, + 'product_uom': product_avg.uom_po_id.id, + 'price_unit': 30.0, + 'date_planned': date_po, + }) + ], + }) + po.button_confirm() + + line_product_avg = po.order_line.filtered(lambda l: l.product_id == product_avg) + + today = date_delivery + picking = po.picking_ids + (picking.move_lines + .filtered(lambda l: l.purchase_line_id == line_product_avg) + .write({'quantity_done': 1.0})) + + picking.button_validate() + # 5 Units received at rate 0.7 = 42.86 + self.assertAlmostEqual(product_avg.standard_price, 42.86) + + today = date_invoice + inv = self.env['account.move'].with_context(default_move_type='in_invoice').create({ + 'move_type': 'in_invoice', + 'invoice_date': date_invoice, + 'date': date_invoice, + 'currency_id': self.eur_currency.id, + 'partner_id': self.partner_id.id, + 'invoice_line_ids': [ + (0, 0, { + 'name': product_avg.name, + 'price_unit': 30.0, + 'product_id': product_avg.id, + 'purchase_line_id': line_product_avg.id, + 'quantity': 1.0, + 'account_id': self.stock_input_account.id, + }) + ] + }) + + inv.action_post() + + for p in patchers: + p.stop() + + move_lines = inv.line_ids + self.assertEqual(len(move_lines), 2) + + # PAYABLE CHECK + payable_line = move_lines.filtered(lambda l: l.account_id.internal_type == 'payable') + self.assertEqual(payable_line.amount_currency, -30.0) + self.assertAlmostEqual(payable_line.balance, -15.00) + + # PRODUCTS CHECKS + + # DELIVERY DIFFERENCE (AVERAGE) + # We ordered a product at 30 EUR valued at 20 USD + # We received it when the exchange rate has appreciated + # So, the actualized 20 USD are now 20*1.5/0.7 = 42.86 USD + product_lines = move_lines.filtered(lambda l: l.product_id == product_avg) + + # Although those 42.86 USD are just due to the exchange difference + stock_line = product_lines.filtered(lambda l: l.account_id == self.stock_input_account) + self.assertEqual(stock_line.journal_id, inv.journal_id) + self.assertEqual(stock_line.amount_currency, 30.00) + self.assertAlmostEqual(stock_line.balance, 15.00) + full_reconcile = stock_line.full_reconcile_id + self.assertTrue(full_reconcile.exists()) + + reconciled_lines = full_reconcile.reconciled_line_ids - stock_line + self.assertEqual(len(reconciled_lines), 2) + + stock_journal_line = reconciled_lines.filtered(lambda l: l.journal_id == self.stock_journal) + self.assertEqual(stock_journal_line.amount_currency, -30.00) + self.assertAlmostEqual(stock_journal_line.balance, -42.86) + + exhange_diff_journal = company.currency_exchange_journal_id.exists() + exchange_stock_line = reconciled_lines.filtered(lambda l: l.journal_id == exhange_diff_journal) + self.assertEqual(exchange_stock_line.amount_currency, 0.00) + self.assertAlmostEqual(exchange_stock_line.balance, 27.86) + + def test_average_realtime_with_two_delivery_anglo_saxon_valuation_multicurrency_different_dates(self): + """ + The PO and invoice are in the same foreign currency. + The deliveries occur at different times and rates + The invoice is created at an even different date + This should create a price difference entry. + """ + company = self.env.user.company_id + company.anglo_saxon_accounting = True + company.currency_id = self.usd_currency + exchange_diff_journal = company.currency_exchange_journal_id.exists() + + date_po = '2019-01-01' + date_delivery = '2019-01-08' + date_delivery1 = '2019-01-10' + date_invoice = '2019-01-16' + date_invoice1 = '2019-01-20' + + self.product1.categ_id.property_valuation = 'real_time' + self.product1.categ_id.property_cost_method = 'average' + product_avg = self.product1_copy + product_avg.write({ + 'purchase_method': 'purchase', + 'name': 'AVG', + 'standard_price': 0, + 'property_account_creditor_price_difference': self.price_diff_account.id + }) + + # SetUp currency and rates + self.cr.execute("UPDATE res_company SET currency_id = %s WHERE id = %s", (self.usd_currency.id, company.id)) + self.env['res.currency.rate'].search([]).unlink() + self.env['res.currency.rate'].create({ + 'name': date_po, + 'rate': 1.0, + 'currency_id': self.usd_currency.id, + 'company_id': company.id, + }) + self.env['res.currency.rate'].create({ + 'name': date_po, + 'rate': 1.5, + 'currency_id': self.eur_currency.id, + 'company_id': company.id, + }) + + self.env['res.currency.rate'].create({ + 'name': date_delivery, + 'rate': 0.7, + 'currency_id': self.eur_currency.id, + 'company_id': company.id, + }) + self.env['res.currency.rate'].create({ + 'name': date_delivery1, + 'rate': 0.8, + 'currency_id': self.eur_currency.id, + 'company_id': company.id, + }) + + self.env['res.currency.rate'].create({ + 'name': date_invoice, + 'rate': 2, + 'currency_id': self.eur_currency.id, + 'company_id': company.id, + }) + self.env['res.currency.rate'].create({ + 'name': date_invoice1, + 'rate': 2.2, + 'currency_id': self.eur_currency.id, + 'company_id': company.id, + }) + + # To allow testing validation of PO and Delivery + today = date_po + def _today(*args, **kwargs): + return datetime.strptime(today, "%Y-%m-%d").date() + def _now(*args, **kwargs): + return datetime.strptime(today + ' 01:00:00', "%Y-%m-%d %H:%M:%S") + + patchers = [ + patch('odoo.fields.Date.context_today', _today), + patch('odoo.fields.Datetime.now', _now), + ] + + for p in patchers: + p.start() + + # Proceed + po = self.env['purchase.order'].create({ + 'currency_id': self.eur_currency.id, + 'partner_id': self.partner_id.id, + 'date_order': date_po, + 'order_line': [ + (0, 0, { + 'name': product_avg.name, + 'product_id': product_avg.id, + 'product_qty': 10.0, + 'product_uom': product_avg.uom_po_id.id, + 'price_unit': 30.0, + 'date_planned': date_po, + }) + ], + }) + po.button_confirm() + + line_product_avg = po.order_line.filtered(lambda l: l.product_id == product_avg) + + today = date_delivery + picking = po.picking_ids + (picking.move_lines + .filtered(lambda l: l.purchase_line_id == line_product_avg) + .write({'quantity_done': 5.0})) + + picking.button_validate() + picking._action_done() # Create Backorder + # 5 Units received at rate 0.7 = 42.86 + self.assertAlmostEqual(product_avg.standard_price, 42.86) + + today = date_invoice + inv = self.env['account.move'].with_context(default_move_type='in_invoice').create({ + 'move_type': 'in_invoice', + 'invoice_date': date_invoice, + 'date': date_invoice, + 'currency_id': self.eur_currency.id, + 'partner_id': self.partner_id.id, + 'invoice_line_ids': [ + (0, 0, { + 'name': product_avg.name, + 'price_unit': 20.0, + 'product_id': product_avg.id, + 'purchase_line_id': line_product_avg.id, + 'quantity': 5.0, + 'account_id': self.stock_input_account.id, + }) + ] + }) + + inv.action_post() + + today = date_delivery1 + backorder_picking = self.env['stock.picking'].search([('backorder_id', '=', picking.id)]) + (backorder_picking.move_lines + .filtered(lambda l: l.purchase_line_id == line_product_avg) + .write({'quantity_done': 5.0})) + backorder_picking.button_validate() + # 5 Units received at rate 0.7 (42.86) + 5 Units received at rate 0.8 (37.50) = 40.18 + self.assertAlmostEqual(product_avg.standard_price, 40.18) + + today = date_invoice1 + inv1 = self.env['account.move'].with_context(default_move_type='in_invoice').create({ + 'move_type': 'in_invoice', + 'invoice_date': date_invoice1, + 'date': date_invoice1, + 'currency_id': self.eur_currency.id, + 'partner_id': self.partner_id.id, + 'invoice_line_ids': [ + (0, 0, { + 'name': product_avg.name, + 'price_unit': 40.0, + 'product_id': product_avg.id, + 'purchase_line_id': line_product_avg.id, + 'quantity': 5.0, + 'account_id': self.stock_input_account.id, + }) + ] + }) + + inv1.action_post() + + for p in patchers: + p.stop() + + ########################## + # Invoice 0 # + ########################## + move_lines = inv.line_ids + self.assertEqual(len(move_lines), 4) + + # PAYABLE CHECK + payable_line = move_lines.filtered(lambda l: l.account_id.internal_type == 'payable') + self.assertEqual(payable_line.amount_currency, -100.0) + self.assertAlmostEqual(payable_line.balance, -50.00) + + # # PRODUCTS CHECKS + + # DELIVERY DIFFERENCE (AVERAGE) + stock_lines = move_lines.filtered(lambda l: l.account_id == self.stock_input_account) + self.assertEqual(len(stock_lines), 2) + self.assertAlmostEqual(sum(stock_lines.mapped('amount_currency')), 150.00) + self.assertAlmostEqual(sum(stock_lines.mapped('balance')), 75.00) + + price_diff_line = move_lines.filtered(lambda l: l.account_id == self.price_diff_account) + self.assertAlmostEqual(price_diff_line.amount_currency, -50.00) + self.assertAlmostEqual(price_diff_line.balance, -25.00) + + full_reconcile = stock_lines.mapped('full_reconcile_id') + self.assertTrue(full_reconcile.exists()) + + reconciled_lines = full_reconcile.reconciled_line_ids - stock_lines + self.assertEqual(len(reconciled_lines), 2) + + stock_journal_line = reconciled_lines.filtered(lambda l: l.journal_id == self.stock_journal) + self.assertEqual(stock_journal_line.amount_currency, -150) + self.assertAlmostEqual(stock_journal_line.balance, -214.29) + + exchange_stock_line = reconciled_lines.filtered(lambda l: l.journal_id == exchange_diff_journal) + self.assertEqual(exchange_stock_line.amount_currency, 0.00) + self.assertAlmostEqual(exchange_stock_line.balance, 139.29) + + ########################## + # Invoice 1 # + ########################## + move_lines = inv1.line_ids + self.assertEqual(len(move_lines), 4) + + # PAYABLE CHECK + payable_line = move_lines.filtered(lambda l: l.account_id.internal_type == 'payable') + self.assertEqual(payable_line.amount_currency, -200.0) + self.assertAlmostEqual(payable_line.balance, -90.91) + + # # PRODUCTS CHECKS + + # DELIVERY DIFFERENCE (AVERAGE) + stock_lines = move_lines.filtered(lambda l: l.account_id == self.stock_input_account) + self.assertEqual(stock_lines.mapped('journal_id'), inv.journal_id) + self.assertAlmostEqual(sum(stock_lines.mapped('amount_currency')), 150.00) + self.assertAlmostEqual(sum(stock_lines.mapped('balance')), 68.18) + + price_diff_line = move_lines.filtered(lambda l: l.account_id == self.price_diff_account) + self.assertEqual(price_diff_line.amount_currency, 50.00) + self.assertAlmostEqual(price_diff_line.balance, 22.73) + + full_reconcile = stock_lines.mapped('full_reconcile_id') + self.assertTrue(full_reconcile.exists()) + + reconciled_lines = full_reconcile.reconciled_line_ids - stock_lines + self.assertEqual(len(reconciled_lines), 3) + + stock_journal_line = reconciled_lines.filtered(lambda l: l.journal_id == self.stock_journal) + self.assertEqual(stock_journal_line.amount_currency, -150) + self.assertAlmostEqual(stock_journal_line.balance, -187.5) + + exchange_stock_lines = reconciled_lines.filtered(lambda l: l.journal_id == exchange_diff_journal) + self.assertAlmostEqual(sum(exchange_stock_lines.mapped('amount_currency')), 0.00) + self.assertAlmostEqual(sum(exchange_stock_lines.mapped('balance')), 119.32) + + def test_anglosaxon_valuation_price_total_diff_discount(self): + """ + PO: price unit: 110 + Inv: price unit: 100 + discount: 10 + """ + self.env.company.anglo_saxon_accounting = True + self.product1.categ_id.property_cost_method = 'fifo' + self.product1.categ_id.property_valuation = 'real_time' + self.product1.property_account_creditor_price_difference = self.price_diff_account + + # Create PO + po_form = Form(self.env['purchase.order']) + po_form.partner_id = self.partner_id + with po_form.order_line.new() as po_line: + po_line.product_id = self.product1 + po_line.product_qty = 1 + po_line.price_unit = 110.0 + order = po_form.save() + order.button_confirm() + + # Receive the goods + receipt = order.picking_ids[0] + receipt.move_lines.quantity_done = 1 + receipt.button_validate() + + # Create an invoice with a different price and a discount + invoice_form = Form(self.env['account.move'].with_context(default_move_type='in_invoice')) + invoice_form.invoice_date = invoice_form.date + invoice_form.purchase_id = order + with invoice_form.invoice_line_ids.edit(0) as line_form: + line_form.price_unit = 100.0 + line_form.discount = 10.0 + invoice = invoice_form.save() + invoice.action_post() + + # Check what was posted in the price difference account + price_diff_aml = self.env['account.move.line'].search([('account_id','=', self.price_diff_account.id)]) + self.assertEqual(len(price_diff_aml), 1, "Only one line should have been generated in the price difference account.") + self.assertAlmostEqual(price_diff_aml.credit, 20, "Price difference should be equal to 20 (110-90)") + + # Check what was posted in stock input account + input_aml = self.env['account.move.line'].search([('account_id','=', self.stock_input_account.id)]) + self.assertEqual(len(input_aml), 3, "Only two lines should have been generated in stock input account: one when receiving the product, two when making the invoice.") + self.assertAlmostEqual(sum(input_aml.mapped('debit')), 110, "Total debit value on stock input account should be equal to the original PO price of the product.") + self.assertAlmostEqual(sum(input_aml.mapped('credit')), 110, "Total credit value on stock input account should be equal to the original PO price of the product.") + + def test_anglosaxon_valuation_discount(self): + """ + PO: price unit: 100 + Inv: price unit: 100 + discount: 10 + """ + self.env.company.anglo_saxon_accounting = True + self.product1.categ_id.property_cost_method = 'fifo' + self.product1.categ_id.property_valuation = 'real_time' + self.product1.property_account_creditor_price_difference = self.price_diff_account + + # Create PO + po_form = Form(self.env['purchase.order']) + po_form.partner_id = self.partner_id + with po_form.order_line.new() as po_line: + po_line.product_id = self.product1 + po_line.product_qty = 1 + po_line.price_unit = 100.0 + order = po_form.save() + order.button_confirm() + + # Receive the goods + receipt = order.picking_ids[0] + receipt.move_lines.quantity_done = 1 + receipt.button_validate() + + # Create an invoice with a different price and a discount + invoice_form = Form(self.env['account.move'].with_context(default_move_type='in_invoice')) + invoice_form.invoice_date = invoice_form.date + invoice_form.purchase_id = order + with invoice_form.invoice_line_ids.edit(0) as line_form: + line_form.tax_ids.clear() + line_form.discount = 10.0 + invoice = invoice_form.save() + invoice.action_post() + + # Check what was posted in the price difference account + price_diff_aml = self.env['account.move.line'].search([('account_id', '=', self.price_diff_account.id)]) + self.assertEqual(len(price_diff_aml), 1, "Only one line should have been generated in the price difference account.") + self.assertAlmostEqual(price_diff_aml.credit, 10, "Price difference should be equal to 10 (100-90)") + + # Check what was posted in stock input account + input_aml = self.env['account.move.line'].search([('account_id', '=', self.stock_input_account.id)]) + self.assertEqual(len(input_aml), 3, "Three lines generated in stock input account: one when receiving the product, two when making the invoice.") + self.assertAlmostEqual(sum(input_aml.mapped('debit')), 100, "Total debit value on stock input account should be equal to the original PO price of the product.") + self.assertAlmostEqual(sum(input_aml.mapped('credit')), 100, "Total credit value on stock input account should be equal to the original PO price of the product.") + + def test_anglosaxon_valuation_price_unit_diff_discount(self): + """ + PO: price unit: 90 + Inv: price unit: 100 + discount: 10 + """ + self.env.company.anglo_saxon_accounting = True + self.product1.categ_id.property_cost_method = 'fifo' + self.product1.categ_id.property_valuation = 'real_time' + self.product1.property_account_creditor_price_difference = self.price_diff_account + + # Create PO + po_form = Form(self.env['purchase.order']) + po_form.partner_id = self.partner_id + with po_form.order_line.new() as po_line: + po_line.product_id = self.product1 + po_line.product_qty = 1 + po_line.price_unit = 90.0 + order = po_form.save() + order.button_confirm() + + # Receive the goods + receipt = order.picking_ids[0] + receipt.move_lines.quantity_done = 1 + receipt.button_validate() + + # Create an invoice with a different price and a discount + invoice_form = Form(self.env['account.move'].with_context(default_move_type='in_invoice')) + invoice_form.invoice_date = invoice_form.date + invoice_form.purchase_id = order + with invoice_form.invoice_line_ids.edit(0) as line_form: + line_form.price_unit = 100.0 + line_form.discount = 10.0 + invoice = invoice_form.save() + invoice.action_post() + + # Check if something was posted in the price difference account + price_diff_aml = self.env['account.move.line'].search([('account_id','=', self.price_diff_account.id)]) + self.assertEqual(len(price_diff_aml), 0, "No line should have been generated in the price difference account.") + + # Check what was posted in stock input account + input_aml = self.env['account.move.line'].search([('account_id','=', self.stock_input_account.id)]) + self.assertEqual(len(input_aml), 2, "Only two lines should have been generated in stock input account: one when receiving the product, one when making the invoice.") + self.assertAlmostEqual(sum(input_aml.mapped('debit')), 90, "Total debit value on stock input account should be equal to the original PO price of the product.") + self.assertAlmostEqual(sum(input_aml.mapped('credit')), 90, "Total credit value on stock input account should be equal to the original PO price of the product.") diff --git a/addons/purchase_stock/views/assets.xml b/addons/purchase_stock/views/assets.xml new file mode 100644 index 00000000..8724db5b --- /dev/null +++ b/addons/purchase_stock/views/assets.xml @@ -0,0 +1,8 @@ + + + + diff --git a/addons/purchase_stock/views/product_category_views.xml b/addons/purchase_stock/views/product_category_views.xml new file mode 100644 index 00000000..43320f30 --- /dev/null +++ b/addons/purchase_stock/views/product_category_views.xml @@ -0,0 +1,13 @@ + + + + product.category.view.form.inherit.purchase.stock + product.category + + + + {'invisible':[('property_valuation', '=', 'manual_periodic')]} + + + + diff --git a/addons/purchase_stock/views/purchase_views.xml b/addons/purchase_stock/views/purchase_views.xml new file mode 100644 index 00000000..6f850b62 --- /dev/null +++ b/addons/purchase_stock/views/purchase_views.xml @@ -0,0 +1,72 @@ + + + + + purchase.order.form.inherit + purchase.order + + + + + + + + + + + + + + + + {'invisible': [('effective_date', '!=', False)]} + + + + + + + + + + + + {'column_invisible': [('parent.state', 'not in', ('purchase', 'done'))], 'readonly': [('product_type', 'in', ('consu', 'product'))]} + + + + + + + + + + + + purchase.order.line.form.inherit + purchase.order.line + + + + + + + + + + diff --git a/addons/purchase_stock/views/res_config_settings_views.xml b/addons/purchase_stock/views/res_config_settings_views.xml new file mode 100644 index 00000000..8bfcde03 --- /dev/null +++ b/addons/purchase_stock/views/res_config_settings_views.xml @@ -0,0 +1,78 @@ + + + + + res.config.settings.view.form.inherit.purchase + res.config.settings + + + + + +

Logistics

+
+
+
+ +
+
+
+
+
+
+
+
+ + + res.config.settings.view.form.inherit.purchase.stock + res.config.settings + + + + 0 + +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ +
diff --git a/addons/purchase_stock/views/res_partner_views.xml b/addons/purchase_stock/views/res_partner_views.xml new file mode 100644 index 00000000..b86ca351 --- /dev/null +++ b/addons/purchase_stock/views/res_partner_views.xml @@ -0,0 +1,30 @@ + + + + + res.partner.purchase.stock.form.inherit + res.partner + + + + + + + + diff --git a/addons/purchase_stock/views/stock_production_lot_views.xml b/addons/purchase_stock/views/stock_production_lot_views.xml new file mode 100644 index 00000000..15211756 --- /dev/null +++ b/addons/purchase_stock/views/stock_production_lot_views.xml @@ -0,0 +1,35 @@ + + + + stock.production.lot.view.form + stock.production.lot + + + + + + + + + + + + + + + + + + + + diff --git a/addons/purchase_stock/views/stock_rule_views.xml b/addons/purchase_stock/views/stock_rule_views.xml new file mode 100644 index 00000000..adbdd662 --- /dev/null +++ b/addons/purchase_stock/views/stock_rule_views.xml @@ -0,0 +1,17 @@ + + + + + + stock.rule.form.stock.inherit.purchase_stock + stock.rule + + + + {'required': [('action', 'in', ['pull', 'push', 'pull_push'])], 'invisible': [('action', '=', 'buy')]} + + + + + + diff --git a/addons/purchase_stock/views/stock_views.xml b/addons/purchase_stock/views/stock_views.xml new file mode 100644 index 00000000..2ea08b5e --- /dev/null +++ b/addons/purchase_stock/views/stock_views.xml @@ -0,0 +1,40 @@ + + + + stock.move.form + stock.move + + + + + + + + + + Stock Warehouse Inherited + stock.warehouse + + + + + + + {} + + + + + + stock.warehouse.orderpoint.tree.editable.inherit.mrp + stock.warehouse.orderpoint + + + + + + + + + + -- cgit v1.2.3