diff --git a/purchase_stock_ux/README.rst b/purchase_stock_ux/README.rst index bedcf368..4e9ff058 100644 --- a/purchase_stock_ux/README.rst +++ b/purchase_stock_ux/README.rst @@ -19,12 +19,16 @@ Several Improvements to purchases. On purchase orders: #. Add a to filter by PO with billable returns. +#. Add receipt status filters and column, and allow to force the receipt status (administrators only). +#. Cancel the remaining quantities of several purchase orders at once from the action menu. +#. Bills do not include lines with nothing to bill after a return. On purchase lines: #. Add receipt status on purchase lines #. Add button on purchase lines to allow cancelling of remaining qty to be received -#. Add return quantity when you return products with "To Refund" option. +#. Add returned quantity when products are returned to the vendor. +#. Warn when the ordered quantity of a confirmed line is decreased, suggesting to cancel the remaining quantity. On incoming pickings: @@ -34,13 +38,15 @@ On Stock Moves: #. Add button to access to purchase order related. -On Stock Warehouse Orderpoints: +On replenishment: -#. Set main seller as default supplier in replenishment rules. +#. When the vendor has no price, the purchase line takes the product cost. +#. Requests for quotation created by replenishment are grouped by buyer. On purchase settings: #. Add option to enable supplier currency selection when creating a PO. +#. Add option to propagate the unit of measure of the purchase order to the picking. Installation diff --git a/purchase_stock_ux/__manifest__.py b/purchase_stock_ux/__manifest__.py index 037fdc04..0684d442 100644 --- a/purchase_stock_ux/__manifest__.py +++ b/purchase_stock_ux/__manifest__.py @@ -19,7 +19,7 @@ ############################################################################## { "name": "Purchase Stock UX", - "version": "19.0.1.4.0", + "version": "20.0.1.0.0", "category": "Purchases", "sequence": 14, "summary": "", @@ -33,7 +33,7 @@ "stock_ux", ], "data": [ - "security/ir.model.access.csv", + "security/ir.access.csv", "views/purchase_order_views.xml", "views/purchase_line_views.xml", "views/stock_move_views.xml", @@ -41,7 +41,7 @@ "wizards/purchase_order_cancel_remaining.xml", ], "demo": [], - "installable": False, + "installable": True, "auto_install": True, "application": False, } diff --git a/purchase_stock_ux/i18n/es.po b/purchase_stock_ux/i18n/es.po index 2e9b4add..13fed817 100644 --- a/purchase_stock_ux/i18n/es.po +++ b/purchase_stock_ux/i18n/es.po @@ -9,19 +9,17 @@ # msgid "" msgstr "" -"Project-Id-Version: Odoo Server 18.0+e\n" +"Project-Id-Version: Odoo Server 20.0+e\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-08-26 19:10+0000\n" -"PO-Revision-Date: 2026-02-23 22:21+0000\n" +"POT-Creation-Date: 2026-10-29 15:29+0000\n" +"PO-Revision-Date: 2026-10-29 15:29+0000\n" "Last-Translator: BAF \n" -"Language-Team: Spanish \n" +"Language-Team: Spanish \n" "Language: es\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: \n" -"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n != 0 && n % 1000000 == " -"0) ? 1 : 2);\n" +"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2);\n" "X-Generator: Weblate 5.10.4\n" #. module: purchase_stock_ux @@ -30,17 +28,129 @@ msgid "" "'Do you want to cancel remaining quantities to receive for these orders? " "This can´t be undone'" msgstr "" +"'¿Desea cancelar las cantidades remanentes a recibir de estas órdenes? Esta " +"acción no se puede deshacer'" + +#. module: purchase_stock_ux +#: model:ir.module.module,description:base.module_purchase_stock_ux +msgid "" +".. |company| replace:: ADHOC SA\n" +"\n" +".. |company_logo| image:: https://raw.githubusercontent.com/ingadhoc/maintainer-tools/master/resources/adhoc-logo.png\n" +" :alt: ADHOC SA\n" +" :target: https://www.adhoc.com.ar\n" +"\n" +".. |icon| image:: https://raw.githubusercontent.com/ingadhoc/maintainer-tools/master/resources/adhoc-icon.png\n" +"\n" +".. image:: https://img.shields.io/badge/license-AGPL--3-blue.png\n" +" :target: https://www.gnu.org/licenses/agpl\n" +" :alt: License: AGPL-3\n" +"\n" +"=================\n" +"Purchase Stock UX\n" +"=================\n" +"\n" +"Several Improvements to purchases.\n" +"\n" +"On purchase orders:\n" +"\n" +"#. Add a to filter by PO with billable returns.\n" +"#. Add receipt status filters and column, and allow to force the receipt status (administrators only).\n" +"#. Cancel the remaining quantities of several purchase orders at once from the action menu.\n" +"#. Bills do not include lines with nothing to bill after a return.\n" +"\n" +"On purchase lines:\n" +"\n" +"#. Add receipt status on purchase lines\n" +"#. Add button on purchase lines to allow cancelling of remaining qty to be received\n" +"#. Add returned quantity when products are returned to the vendor. The returned quantity is not billed.\n" +"#. Warn when the ordered quantity of a confirmed line is decreased, suggesting to cancel the remaining quantity.\n" +"\n" +"On incoming pickings:\n" +"\n" +"#. Add moving internal notes of the PO to the picking.\n" +"\n" +"On Stock Moves:\n" +"\n" +"#. Add button to access to purchase order related.\n" +"\n" +"On replenishment:\n" +"\n" +"#. When the vendor has no price, the purchase line takes the product cost.\n" +"#. Requests for quotation created by replenishment are grouped by buyer.\n" +"\n" +"On purchase settings:\n" +"\n" +"#. Add option to enable supplier currency selection when creating a PO.\n" +"#. Add option to propagate the unit of measure of the purchase order to the picking.\n" +"\n" +"\n" +"Installation\n" +"============\n" +"\n" +"To install this module, you need to:\n" +"\n" +"#. Just install this module.\n" +"\n" +"\n" +"Configuration\n" +"=============\n" +"\n" +"To configure this module, you need to:\n" +"\n" +"#. No configuration nedeed.\n" +"\n" +"Usage\n" +"=====\n" +"\n" +"To use this module, you need to:\n" +"\n" +"#. Go to ...\n" +"\n" +".. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas\n" +" :alt: Try me on Runbot\n" +" :target: http://runbot.adhoc.com.ar/\n" +"\n" +"Bug Tracker\n" +"===========\n" +"\n" +"Bugs are tracked on `GitHub Issues\n" +"`_. In case of trouble, please\n" +"check there if your issue has already been reported. If you spotted it first,\n" +"help us smashing it by providing a detailed and welcomed feedback.\n" +"\n" +"Credits\n" +"=======\n" +"\n" +"Images\n" +"------\n" +"\n" +"* |company| |icon|\n" +"\n" +"Contributors\n" +"------------\n" +"\n" +"Maintainer\n" +"----------\n" +"\n" +"|company_logo|\n" +"\n" +"This module is maintained by the |company|.\n" +"\n" +"To contribute to this module, please visit https://www.adhoc.com.ar.\n" +msgstr "" #. module: purchase_stock_ux #: model_terms:ir.ui.view,arch_db:purchase_stock_ux.purchase_order_cancel_remaining_form msgid "Cancel" -msgstr "" +msgstr "Cancelar" #. module: purchase_stock_ux #. odoo-python #: code:addons/purchase_stock_ux/models/purchase_order_line.py:0 msgid "" -"Cancel remaining call for line \"%s\" (id %s), line qty updated from %s to %s" +"Cancel remaining call for line \"%s\" (id %s), line qty updated from %s to " +"%s" msgstr "" "Cancelar remanente para línea \"%s\" (id %s), cantidad actualizada de la " "linea desde %s a %s" @@ -58,8 +168,8 @@ msgstr "" #. module: purchase_stock_ux #: model_terms:ir.ui.view,arch_db:purchase_stock_ux.purchase_order_form msgid "" -"Cancel remaining quanitity to be received. This will set line quantity equal " -"to received qty and will cancel remaining moves" +"Cancel remaining quanitity to be received. This will set line quantity equal" +" to received qty and will cancel remaining moves" msgstr "" "Cancelar cantidades pendientes de recibir. Al hacer click en el botón se " "ejecutará una acción que disminuirá la cantidad a comprar igualándola con la " @@ -73,7 +183,7 @@ msgstr "Ajustes de configuración" #. module: purchase_stock_ux #: model_terms:ir.ui.view,arch_db:purchase_stock_ux.purchase_order_cancel_remaining_form msgid "Confirm" -msgstr "" +msgstr "Confirmar" #. module: purchase_stock_ux #: model:ir.model.fields,field_description:purchase_stock_ux.field_res_config_settings__use_supplier_currency @@ -92,12 +202,12 @@ msgstr "" #. module: purchase_stock_ux #: model:ir.model.fields,field_description:purchase_stock_ux.field_purchase_order_cancel_remaining__create_uid msgid "Created by" -msgstr "" +msgstr "Creado por" #. module: purchase_stock_ux #: model:ir.model.fields,field_description:purchase_stock_ux.field_purchase_order_cancel_remaining__create_date msgid "Created on" -msgstr "" +msgstr "Creado el" #. module: purchase_stock_ux #: model:ir.model.fields,field_description:purchase_stock_ux.field_purchase_order__display_name @@ -106,9 +216,8 @@ msgstr "" #: model:ir.model.fields,field_description:purchase_stock_ux.field_res_config_settings__display_name #: model:ir.model.fields,field_description:purchase_stock_ux.field_stock_move__display_name #: model:ir.model.fields,field_description:purchase_stock_ux.field_stock_rule__display_name -#: model:ir.model.fields,field_description:purchase_stock_ux.field_stock_warehouse_orderpoint__display_name msgid "Display Name" -msgstr "Nombre a Mostrar" +msgstr "Mostrar nombre" #. module: purchase_stock_ux #: model:ir.model.fields,field_description:purchase_stock_ux.field_purchase_order__force_delivered_status @@ -119,7 +228,7 @@ msgstr "Forzar estado de entrega" #: model:ir.model.fields.selection,name:purchase_stock_ux.selection__purchase_order__force_delivered_status__full #: model:ir.model.fields.selection,name:purchase_stock_ux.selection__purchase_order_line__receipt_status__full msgid "Fully Received" -msgstr "" +msgstr "Totalmente recibido" #. module: purchase_stock_ux #: model:ir.model.fields,field_description:purchase_stock_ux.field_purchase_order__id @@ -128,29 +237,23 @@ msgstr "" #: model:ir.model.fields,field_description:purchase_stock_ux.field_res_config_settings__id #: model:ir.model.fields,field_description:purchase_stock_ux.field_stock_move__id #: model:ir.model.fields,field_description:purchase_stock_ux.field_stock_rule__id -#: model:ir.model.fields,field_description:purchase_stock_ux.field_stock_warehouse_orderpoint__id msgid "ID" -msgstr "" +msgstr "ID" #. module: purchase_stock_ux #: model:ir.model.fields,field_description:purchase_stock_ux.field_purchase_order_cancel_remaining__write_uid msgid "Last Updated by" -msgstr "" +msgstr "Última actualización por" #. module: purchase_stock_ux #: model:ir.model.fields,field_description:purchase_stock_ux.field_purchase_order_cancel_remaining__write_date msgid "Last Updated on" -msgstr "" +msgstr "Última actualización el" #. module: purchase_stock_ux #: model:ir.actions.act_window,name:purchase_stock_ux.action_purchase_order_massive_cancel_wizard msgid "Mass cancellation of remaining quantities" -msgstr "" - -#. module: purchase_stock_ux -#: model:ir.model,name:purchase_stock_ux.model_stock_warehouse_orderpoint -msgid "Minimum Inventory Rule" -msgstr "Regla de inventario mínimo" +msgstr "Cancelación masiva de cantidades remanentes" #. module: purchase_stock_ux #: model:ir.model.fields.selection,name:purchase_stock_ux.selection__purchase_order__force_delivered_status__pending @@ -158,7 +261,7 @@ msgstr "Regla de inventario mínimo" #: model_terms:ir.ui.view,arch_db:purchase_stock_ux.purchase_order_line_search #: model_terms:ir.ui.view,arch_db:purchase_stock_ux.view_purchase_order_filter msgid "Not Received" -msgstr "" +msgstr "No recibido" #. module: purchase_stock_ux #: model:ir.model.fields,field_description:purchase_stock_ux.field_purchase_order_line__qty_on_voucher @@ -175,8 +278,6 @@ msgstr "" #. module: purchase_stock_ux #. odoo-python #: code:addons/purchase_stock_ux/models/purchase_order.py:0 -#, fuzzy -#| msgid "Only users with \"%s / %s\" can Set Received manually" msgid "Only users with \"%s\" can Set Received manually" msgstr "" "Sólo usuarios con \"%s / %s\" pueden establecer como Recibido manualmente" @@ -193,7 +294,7 @@ msgstr "Alerta sobre disminunción de cantidades" #: model_terms:ir.ui.view,arch_db:purchase_stock_ux.purchase_order_line_search #: model_terms:ir.ui.view,arch_db:purchase_stock_ux.view_purchase_order_filter msgid "Partially Received" -msgstr "" +msgstr "Parcialmente recibido" #. module: purchase_stock_ux #: model:ir.model.fields,field_description:purchase_stock_ux.field_res_config_settings__propagate_uom @@ -205,23 +306,28 @@ msgstr "Propagar unidad de medida" #: model:ir.model.fields,field_description:purchase_stock_ux.field_stock_move__purchase_id #: model_terms:ir.ui.view,arch_db:purchase_stock_ux.view_move_form msgid "Purchase Order" -msgstr "Pedido de compra" +msgstr "Orden de compra" #. module: purchase_stock_ux #: model:ir.model,name:purchase_stock_ux.model_purchase_order_cancel_remaining msgid "Purchase Order Cancel Remaining" -msgstr "" +msgstr "Cancelar remanente de orden de compra" #. module: purchase_stock_ux #: model:ir.model,name:purchase_stock_ux.model_purchase_order_line #: model:ir.model.fields,field_description:purchase_stock_ux.field_purchase_order_cancel_remaining__purchase_order_line_ids msgid "Purchase Order Line" -msgstr "Línea de pedido de compra" +msgstr "Línea de la orden de compra" + +#. module: purchase_stock_ux +#: model:ir.module.module,shortdesc:base.module_purchase_stock_ux +msgid "Purchase Stock UX" +msgstr "" #. module: purchase_stock_ux #: model_terms:ir.ui.view,arch_db:purchase_stock_ux.view_purchase_order_filter msgid "Purchase orders that include lines partially received." -msgstr "" +msgstr "Órdenes de compra que incluyen líneas parcialmente recibidas." #. module: purchase_stock_ux #: model_terms:ir.ui.view,arch_db:purchase_stock_ux.view_purchase_order_filter @@ -236,7 +342,7 @@ msgstr "Órdenes de compra con devoluciones facturables" #. module: purchase_stock_ux #: model_terms:ir.ui.view,arch_db:purchase_stock_ux.view_purchase_order_filter msgid "Purchase orders with no lines received." -msgstr "" +msgstr "Órdenes de compra sin líneas recibidas." #. module: purchase_stock_ux #: model:ir.model.fields,field_description:purchase_stock_ux.field_purchase_order_line__receipt_status @@ -248,7 +354,7 @@ msgstr "Estado de la recepción" #: model_terms:ir.ui.view,arch_db:purchase_stock_ux.purchase_order_line_search #: model_terms:ir.ui.view,arch_db:purchase_stock_ux.view_purchase_order_filter msgid "Received" -msgstr "" +msgstr "Recibido" #. module: purchase_stock_ux #: model:ir.model.fields,field_description:purchase_stock_ux.field_purchase_order_line__qty_returned @@ -270,6 +376,17 @@ msgstr "Movimiento de stock" msgid "Stock Rule" msgstr "Regla de inventario" +#. module: purchase_stock_ux +#. odoo-python +#: code:addons/purchase_stock_ux/models/purchase_order_line.py:0 +msgid "" +"The subcontractor already recorded production on %(productions)s. Receive " +"what was produced or cancel that manufacturing order before cancelling the " +"remaining quantity." +msgstr "" +"El subcontratista ya registró producción en %(productions)s. Reciba lo " +"producido o cancele esa orden de producción antes de cancelar el remanente." + #. module: purchase_stock_ux #: model_terms:ir.ui.view,arch_db:purchase_stock_ux.purchase_order_form msgid "" @@ -324,19 +441,3 @@ msgid "" "¡Está reduciendo la cantidad pedida! Recomendamos usar el botón para " "cancelar remanente y luego setear la cantidad deseada." msgstr "" - -#~ msgid "" -#~ "Cancel remaining quantities can't be called for blocked purchase orders. " -#~ "First unblock the purchase order" -#~ msgstr "" -#~ "Cancelar las cantidades remanentes no puede utilizarse para ordenes de " -#~ "compra bloqueadas. Primero desbloquee la orden de compra." - -#~ msgid "Purchase orders that include lines not received." -#~ msgstr "Pedidos de compra que incluyen líneas no recibidas" - -#~ msgid "full" -#~ msgstr "Recibido" - -#~ msgid "Order Reference" -#~ msgstr "Referencia de la orden" diff --git a/purchase_stock_ux/i18n/purchase_stock_ux.pot b/purchase_stock_ux/i18n/purchase_stock_ux.pot index df29dd5f..81a6a00c 100644 --- a/purchase_stock_ux/i18n/purchase_stock_ux.pot +++ b/purchase_stock_ux/i18n/purchase_stock_ux.pot @@ -4,10 +4,10 @@ # msgid "" msgstr "" -"Project-Id-Version: Odoo Server 19.0+e\n" +"Project-Id-Version: Odoo Server 20.0+e\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-08-26 19:10+0000\n" -"PO-Revision-Date: 2026-08-26 19:10+0000\n" +"POT-Creation-Date: 2026-10-29 15:29+0000\n" +"PO-Revision-Date: 2026-10-29 15:29+0000\n" "Last-Translator: \n" "Language-Team: \n" "MIME-Version: 1.0\n" @@ -22,6 +22,115 @@ msgid "" "This can´t be undone'" msgstr "" +#. module: purchase_stock_ux +#: model:ir.module.module,description:base.module_purchase_stock_ux +msgid "" +".. |company| replace:: ADHOC SA\n" +"\n" +".. |company_logo| image:: https://raw.githubusercontent.com/ingadhoc/maintainer-tools/master/resources/adhoc-logo.png\n" +" :alt: ADHOC SA\n" +" :target: https://www.adhoc.com.ar\n" +"\n" +".. |icon| image:: https://raw.githubusercontent.com/ingadhoc/maintainer-tools/master/resources/adhoc-icon.png\n" +"\n" +".. image:: https://img.shields.io/badge/license-AGPL--3-blue.png\n" +" :target: https://www.gnu.org/licenses/agpl\n" +" :alt: License: AGPL-3\n" +"\n" +"=================\n" +"Purchase Stock UX\n" +"=================\n" +"\n" +"Several Improvements to purchases.\n" +"\n" +"On purchase orders:\n" +"\n" +"#. Add a to filter by PO with billable returns.\n" +"#. Add receipt status filters and column, and allow to force the receipt status (administrators only).\n" +"#. Cancel the remaining quantities of several purchase orders at once from the action menu.\n" +"#. Bills do not include lines with nothing to bill after a return.\n" +"\n" +"On purchase lines:\n" +"\n" +"#. Add receipt status on purchase lines\n" +"#. Add button on purchase lines to allow cancelling of remaining qty to be received\n" +"#. Add returned quantity when products are returned to the vendor. The returned quantity is not billed.\n" +"#. Warn when the ordered quantity of a confirmed line is decreased, suggesting to cancel the remaining quantity.\n" +"\n" +"On incoming pickings:\n" +"\n" +"#. Add moving internal notes of the PO to the picking.\n" +"\n" +"On Stock Moves:\n" +"\n" +"#. Add button to access to purchase order related.\n" +"\n" +"On replenishment:\n" +"\n" +"#. When the vendor has no price, the purchase line takes the product cost.\n" +"#. Requests for quotation created by replenishment are grouped by buyer.\n" +"\n" +"On purchase settings:\n" +"\n" +"#. Add option to enable supplier currency selection when creating a PO.\n" +"#. Add option to propagate the unit of measure of the purchase order to the picking.\n" +"\n" +"\n" +"Installation\n" +"============\n" +"\n" +"To install this module, you need to:\n" +"\n" +"#. Just install this module.\n" +"\n" +"\n" +"Configuration\n" +"=============\n" +"\n" +"To configure this module, you need to:\n" +"\n" +"#. No configuration nedeed.\n" +"\n" +"Usage\n" +"=====\n" +"\n" +"To use this module, you need to:\n" +"\n" +"#. Go to ...\n" +"\n" +".. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas\n" +" :alt: Try me on Runbot\n" +" :target: http://runbot.adhoc.com.ar/\n" +"\n" +"Bug Tracker\n" +"===========\n" +"\n" +"Bugs are tracked on `GitHub Issues\n" +"`_. In case of trouble, please\n" +"check there if your issue has already been reported. If you spotted it first,\n" +"help us smashing it by providing a detailed and welcomed feedback.\n" +"\n" +"Credits\n" +"=======\n" +"\n" +"Images\n" +"------\n" +"\n" +"* |company| |icon|\n" +"\n" +"Contributors\n" +"------------\n" +"\n" +"Maintainer\n" +"----------\n" +"\n" +"|company_logo|\n" +"\n" +"This module is maintained by the |company|.\n" +"\n" +"To contribute to this module, please visit https://www.adhoc.com.ar.\n" +msgstr "" + #. module: purchase_stock_ux #: model_terms:ir.ui.view,arch_db:purchase_stock_ux.purchase_order_cancel_remaining_form msgid "Cancel" @@ -89,7 +198,6 @@ msgstr "" #: model:ir.model.fields,field_description:purchase_stock_ux.field_res_config_settings__display_name #: model:ir.model.fields,field_description:purchase_stock_ux.field_stock_move__display_name #: model:ir.model.fields,field_description:purchase_stock_ux.field_stock_rule__display_name -#: model:ir.model.fields,field_description:purchase_stock_ux.field_stock_warehouse_orderpoint__display_name msgid "Display Name" msgstr "" @@ -111,7 +219,6 @@ msgstr "" #: model:ir.model.fields,field_description:purchase_stock_ux.field_res_config_settings__id #: model:ir.model.fields,field_description:purchase_stock_ux.field_stock_move__id #: model:ir.model.fields,field_description:purchase_stock_ux.field_stock_rule__id -#: model:ir.model.fields,field_description:purchase_stock_ux.field_stock_warehouse_orderpoint__id msgid "ID" msgstr "" @@ -130,11 +237,6 @@ msgstr "" msgid "Mass cancellation of remaining quantities" msgstr "" -#. module: purchase_stock_ux -#: model:ir.model,name:purchase_stock_ux.model_stock_warehouse_orderpoint -msgid "Minimum Inventory Rule" -msgstr "" - #. module: purchase_stock_ux #: model:ir.model.fields.selection,name:purchase_stock_ux.selection__purchase_order__force_delivered_status__pending #: model:ir.model.fields.selection,name:purchase_stock_ux.selection__purchase_order_line__receipt_status__pending @@ -197,6 +299,11 @@ msgstr "" msgid "Purchase Order Line" msgstr "" +#. module: purchase_stock_ux +#: model:ir.module.module,shortdesc:base.module_purchase_stock_ux +msgid "Purchase Stock UX" +msgstr "" + #. module: purchase_stock_ux #: model_terms:ir.ui.view,arch_db:purchase_stock_ux.view_purchase_order_filter msgid "Purchase orders that include lines partially received." @@ -249,6 +356,15 @@ msgstr "" msgid "Stock Rule" msgstr "" +#. module: purchase_stock_ux +#. odoo-python +#: code:addons/purchase_stock_ux/models/purchase_order_line.py:0 +msgid "" +"The subcontractor already recorded production on %(productions)s. Receive " +"what was produced or cancel that manufacturing order before cancelling the " +"remaining quantity." +msgstr "" + #. module: purchase_stock_ux #: model_terms:ir.ui.view,arch_db:purchase_stock_ux.purchase_order_form msgid "" diff --git a/purchase_stock_ux/models/__init__.py b/purchase_stock_ux/models/__init__.py index 4cf81522..62fd5c56 100644 --- a/purchase_stock_ux/models/__init__.py +++ b/purchase_stock_ux/models/__init__.py @@ -6,4 +6,3 @@ from . import purchase_order_line from . import stock_rule from . import stock_move -from . import stock_orderpoint diff --git a/purchase_stock_ux/models/purchase_order.py b/purchase_stock_ux/models/purchase_order.py index bb0aeeff..a6ac2ce9 100644 --- a/purchase_stock_ux/models/purchase_order.py +++ b/purchase_stock_ux/models/purchase_order.py @@ -63,13 +63,13 @@ def check_force_delivered_status(self, vals): raise UserError(_('Only users with "%s" can Set Received manually') % (group.name)) def action_create_invoice(self, attachment_ids=False): - """Drop the zero-quantity lines Odoo 19 leaves on the draft vendor bill. + """Drop the zero-quantity lines Odoo leaves on the draft vendor bill. - Odoo 19's ``action_create_invoice`` iterates over every ``order_line`` + Odoo's ``action_create_invoice`` iterates over every ``order_line`` without filtering by ``qty_to_invoice`` (the old ``_get_invoiceable_lines`` - is gone). Our ``_compute_qty_invoiced`` subtracts ``qty_returned``, so a - fully-returned line has ``qty_to_invoice = 0`` and core still adds it to - the bill as a zero-quantity line. + is gone). Billed on received quantities, a fully-returned line has + ``qty_to_invoice = 0`` and core still adds it to the bill as a + zero-quantity line. We let ``super`` build the invoice and clean it up afterwards instead of reimplementing the whole method, so we don't bypass other overrides of it @@ -90,8 +90,3 @@ def action_create_invoice(self, attachment_ids=False): def button_cancel(self): self = self.with_context(cancel_from_order=True) return super().button_cancel() - - def _prepare_picking(self): - res = super(PurchaseOrder, self)._prepare_picking() - res["note"] = self.internal_notes - return res diff --git a/purchase_stock_ux/models/purchase_order_line.py b/purchase_stock_ux/models/purchase_order_line.py index cf36a55e..6fc93689 100644 --- a/purchase_stock_ux/models/purchase_order_line.py +++ b/purchase_stock_ux/models/purchase_order_line.py @@ -2,10 +2,8 @@ # For copyright and license notices, see __manifest__.py file in module root # directory ############################################################################## -import json import logging -from lxml import etree from odoo import _, api, fields, models from odoo.exceptions import UserError from odoo.tools import float_compare, float_is_zero @@ -85,13 +83,6 @@ def button_cancel_remaining(self): # solo computa la cantidad entregada cuando todo el kit se entregó. # Cuestión que, por ahora, desactivamos la cancelación de kits. - # Manejar órdenes bloqueadas (done): desbloquear temporalmente sin tracking - orders_to_relock = self.env["purchase.order"] - for order in self.mapped("order_id").filtered(lambda o: o.state == "done"): - orders_to_relock |= order - # Desbloquear sin generar mensaje en el chatter - order.with_context(tracking_disable=True).write({"state": "purchase"}) - bom_enable = "bom_ids" in self.env["product.template"]._fields for rec in self: old_product_qty = rec.product_qty @@ -113,20 +104,67 @@ def button_cancel_remaining(self): raise UserError( _("Cancel remaining can't be called for Kit Products (products with a bom of type kit).") ) + # Subcontracting: once core nets the negative move, mrp_subcontracting runs action_assign + # on the emptied receipt and raises. Cancel the remainder first so no negative move is + # created; like core's netting, don't propagate the cancel to destination moves. + subcontracted_moves = rec._get_remaining_moves_to_cancel().filtered( + lambda m: "is_subcontract" in m._fields and m.is_subcontract + ) + if subcontracted_moves: + # The MO is the subcontractor's: a purchase user may not have manufacturing access + subcontracted_moves = subcontracted_moves.sudo() + recorded_productions = subcontracted_moves._get_subcontract_production().filtered( + lambda p: p.state in ("progress", "to_close") + ) + if recorded_productions: + raise UserError( + _( + "The subcontractor already recorded production on %(productions)s. " + "Receive what was produced or cancel that manufacturing order before " + "cancelling the remaining quantity.", + productions=", ".join(recorded_productions.mapped("name")), + ) + ) + # With refunded returns the negative move does not empty the receipt, and cancelling + # first would make core create a new receipt (and MO) for the returned quantity. + if rec.uom_id.is_zero(rec.qty_returned): + subcontracted_moves.write({"propagate_cancel": False}) + subcontracted_moves.with_context(cancel_from_order=True)._action_cancel() + if not rec._get_remaining_moves_to_cancel(): + rec = rec.with_context(cancel_remaining_line_ids=rec.ids) rec.with_context(cancel_from_order=True).product_qty = rec.qty_received + rec.qty_returned - # la realidad es que probablemente esto de acá no sea necesario. modificar product_qty ya hace que odoo, - # apartir de 16 al menos, baje las cantidades de los moves. Justamente por esta razon es que ahora - # pasamos contexto arriba de "cancel_from_order", porque ahora es odoo quien cancela los pickings + # Bajar product_qty hace que Odoo (>=16) reduzca los moves, pero no siempre cancela el + # remanente de recepción: si hubo una devolución con reembolso, o si el move negativo no + # netea contra el pendiente (precio/ubicación final distintos), queda una recepción viva en + # el pronóstico (o una contra-entrega al proveedor). Cancelamos explícitamente el remanente + # abierto de la línea. En recepción de 1, 2 o 3 pasos el pendiente vive siempre en el move de + # primer paso (proveedor->entrada), que cuelga de move_ids; core (_action_cancel) propaga a + # los downstream si hiciera falta. Acotamos a las recepciones forward genuinas del proveedor: + # excluimos los reemplazos de devolución con cambio (recepción legítima esperada) y las + # devoluciones al proveedor abiertas sin validar (origin_returned_move_id), que no son parte + # del remanente y no deben cancelarse. + rec._get_remaining_moves_to_cancel().with_context(cancel_from_order=True)._action_cancel() if rec.product_qty < old_product_qty: rec.order_id._log_decrease_ordered_quantity({rec: (rec.product_qty, old_product_qty)}) rec.order_id.message_post( body=_('Cancel remaining call for line "%s" (id %s), line qty updated from %s to %s') - % (rec.name, rec.id, old_product_qty, rec.product_qty) + % (rec.label, rec.id, old_product_qty, rec.product_qty) ) - # Volver a bloquear las órdenes que estaban bloqueadas sin generar mensaje - if orders_to_relock: - orders_to_relock.with_context(tracking_disable=True).write({"state": "done"}) + def _create_or_update_picking(self): + # Nothing left to receive: unlike 19, core 20 would create a new receipt for the destination + # moves still open (MTO) when the line quantity drops + skip = self.filtered(lambda line: line.id in self.env.context.get("cancel_remaining_line_ids", [])) + return super(PurchaseOrderLine, self - skip)._create_or_update_picking() + + def _get_remaining_moves_to_cancel(self): + """Open moves of the line, without exchange replacements nor moves created by a return.""" + self.ensure_one() + return self.move_ids.filtered( + lambda m: m.state not in ("done", "cancel") + and not m._is_exchange_receipt() + and not m.origin_returned_move_id + ) def _compute_vouchers(self): # Cambiamos esta lógica ya que antes teníamos si o si voucher_ids por dependencias y ahora va a depender de que esté instalado stock_voucher @@ -194,7 +232,7 @@ def _compute_qty_returned(self): for move in line._get_po_line_moves().filtered( lambda m: m.state == "done" and m.to_refund and m._is_purchase_return() ): - qty += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom_id) + qty += move.uom_id._compute_quantity(move.product_uom_qty, line.uom_id) line.qty_returned = qty # Overwrite the origin method to introduce the qty_on_voucher @@ -202,36 +240,6 @@ def action_add_all_to_invoice(self): for rec in self: rec.invoice_qty = rec.qty_on_voucher or (rec.qty_to_invoice + rec.invoice_qty) - @api.model - def fields_view_get(self, view_id=None, view_type="form", toolbar=False, submenu=False): - """ - If we came from invoice, we send in context 'force_line_edit' - and we change list view to make editable and also field qty - """ - res = super().fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu) - if self.env.context.get("force_line_edit") and view_type == "tree": - doc = etree.XML(res["arch"]) - placeholder = doc.xpath("//field[1]")[0] - placeholder.addprevious( - etree.Element( - "field", - { - "name": "qty_on_voucher", - }, - ) - ) - - # make all fields not editable - node = doc.xpath("//field[1]")[0] - node.set("readonly", "1") - modifiers = json.loads(node.get("modifiers") or "{}") - modifiers["readonly"] = True - node.set("modifiers", json.dumps(modifiers)) - res["fields"].update(self.fields_get(["qty_on_voucher"])) - res["arch"] = etree.tostring(doc) - - return res - @api.model def _prepare_purchase_order_line(self, product_id, product_qty, product_uom, company_id, supplier, po): res = super()._prepare_purchase_order_line(product_id, product_qty, product_uom, company_id, supplier, po) @@ -241,35 +249,27 @@ def _prepare_purchase_order_line(self, product_id, product_qty, product_uom, com po.user_id = self.env.user return res - @api.depends("qty_invoiced", "qty_received", "order_id.state", "qty_returned") - def _compute_qty_invoiced(self): - super()._compute_qty_invoiced() - for line in self: - if line.order_id.state in ["purchase", "done"]: - if line.product_id.purchase_method == "purchase": - line.qty_to_invoice = line.product_qty - line.qty_invoiced - line.qty_returned - else: - line.qty_to_invoice = line.qty_received - line.qty_invoiced - else: - line.qty_to_invoice = 0 - - @api.depends("order_id.state", "qty_invoiced", "product_qty", "qty_to_invoice", "order_id.force_invoiced_status") - def _compute_invoice_status(self): - precision = self.env["decimal.precision"].precision_get("Product Unit") - super()._compute_invoice_status() - for line in self: - if line.order_id.force_invoiced_status: - # keep the forced status that super() already applied - continue - if not float_is_zero(line.qty_to_invoice, precision_digits=precision): - line.invoice_status = "to invoice" - elif ( - float_compare(line.qty_invoiced, (line.product_qty - line.qty_returned), precision_digits=precision) - >= 0 - ): - line.invoice_status = "invoiced" - else: - line.invoice_status = "no" + @api.model + def _prepare_purchase_order_line_from_procurement( + self, product_id, product_qty, product_uom, location_dest_id, name, origin, company_id, values, po + ): + res = super()._prepare_purchase_order_line_from_procurement( + product_id, product_qty, product_uom, location_dest_id, name, origin, company_id, values, po + ) + # if price was not computed (not seller or seller price = 0.0), then + # use standar price + if not res["price_unit"]: + price_unit = product_id.with_company(company_id).standard_price + company_currency = company_id.currency_id + if price_unit and po.currency_id != company_currency: + price_unit = company_currency._convert( + price_unit, po.currency_id, company_id, po.date_order or fields.Date.today() + ) + line_uom = self.env["uom.uom"].browse(res["uom_id"]) + if price_unit and line_uom and product_id.uom_id != line_uom: + price_unit = product_id.uom_id._compute_price(price_unit, line_uom) + res["price_unit"] = price_unit + return res @api.depends() def _compute_price_unit_and_date_planned_and_name(self): diff --git a/purchase_stock_ux/models/stock_move.py b/purchase_stock_ux/models/stock_move.py index 11fe0335..df05d6c8 100644 --- a/purchase_stock_ux/models/stock_move.py +++ b/purchase_stock_ux/models/stock_move.py @@ -17,7 +17,30 @@ def _compute_origin_description(self): super()._compute_origin_description() for rec in self: if rec.purchase_line_id: - rec.origin_description = rec.purchase_line_id.name + rec.origin_description = rec.purchase_line_id.label + + def _is_exchange_receipt(self): + # Replacement receipt created with "Exchange" on a vendor return (no origin returned move). + # Core may later add other moves of the line to that picking: those are created afterwards. + self.ensure_one() + return bool( + self.picking_id.return_id + and not self.origin_returned_move_id + and self.create_date == self.picking_id.create_date + ) + + def _should_count_for_quantity_received(self): + # An exchange receipt restocks a vendor return that did not update the PO (returns made in 19) + if self._is_exchange_receipt() and not self.to_refund: + return False + return super()._should_count_for_quantity_received() + + def _get_new_picking_values(self): + vals = super()._get_new_picking_values() + order = self.purchase_line_id.order_id + if len(order) == 1 and order.internal_notes: + vals["note"] = order.internal_notes + return vals @api.model def _prepare_merge_moves_distinct_fields(self): diff --git a/purchase_stock_ux/models/stock_orderpoint.py b/purchase_stock_ux/models/stock_orderpoint.py deleted file mode 100644 index 7bf1806c..00000000 --- a/purchase_stock_ux/models/stock_orderpoint.py +++ /dev/null @@ -1,19 +0,0 @@ -############################################################################## -# For copyright and license notices, see __manifest__.py file in module root -# directory -############################################################################## -from odoo import api, models - - -class StockWarehouseOrderpoint(models.Model): - _inherit = "stock.warehouse.orderpoint" - - @api.model - def _get_orderpoint_values(self, product, location): - values = super()._get_orderpoint_values(product, location) - product = self.env["product.product"].browse(product) - location = self.env["stock.location"].browse(location) - seller = product.with_company(location.company_id)._select_seller() - if seller: - values["supplier_id"] = seller.id - return values diff --git a/purchase_stock_ux/models/stock_rule.py b/purchase_stock_ux/models/stock_rule.py index daf2e487..4f6d2df4 100644 --- a/purchase_stock_ux/models/stock_rule.py +++ b/purchase_stock_ux/models/stock_rule.py @@ -2,40 +2,12 @@ # For copyright and license notices, see __manifest__.py file in module root # directory ############################################################################## -from odoo import api, fields, models +from odoo import fields, models class StockRule(models.Model): _inherit = "stock.rule" - @api.model - def _prepare_purchase_order_line(self, product_id, product_qty, product_uom, company_id, values, po): - res = super()._prepare_purchase_order_line( - product_id=product_id, - product_qty=product_qty, - product_uom=product_uom, - company_id=company_id, - values=values, - po=po, - ) - # Solo asignar user_id si NO es odoobot (uid=1) y la PO no tiene usuario asignado - if not po.user_id and not self.env.is_superuser(): - po.user_id = self.env.user - # if price was not computed (not seller or seller price = 0.0), then - # use standar price - if not res["price_unit"]: - price_unit = product_id.with_company(company_id).standard_price - company_currency = company_id.currency_id - if price_unit and po.currency_id != company_currency: - price_unit = company_currency._convert( - price_unit, po.currency_id, company_id, po.date_order or fields.Date.today() - ) - if price_unit and res["product_uom"] and product_id.uom_id.id != res["product_uom"]: - product_uom = self.env["uom.uom"].browse(res["product_uom"]) - price_unit = product_id.uom_id._compute_price(price_unit, product_uom) - res["price_unit"] = price_unit - return res - def _update_purchase_order_line(self, product_id, product_qty, product_uom, company_id, values, line): res = super()._update_purchase_order_line(product_id, product_qty, product_uom, company_id, values, line) @@ -48,8 +20,8 @@ def _update_purchase_order_line(self, product_id, product_qty, product_uom, comp price_unit = company_currency._convert( price_unit, line.order_id.currency_id, company_id, line.order_id.date_order or fields.Date.today() ) - if price_unit and line.product_uom_id and product_id.uom_id != line.product_uom_id: - price_unit = product_id.uom_id._compute_price(price_unit, line.product_uom_id) + if price_unit and line.uom_id and product_id.uom_id != line.uom_id: + price_unit = product_id.uom_id._compute_price(price_unit, line.uom_id) res["price_unit"] = price_unit return res @@ -57,7 +29,7 @@ def _make_po_get_domain(self, company_id, values, partner): domain = super()._make_po_get_domain(company_id, values, partner) current_user_id = self.env.user.id new_domain = [] - config = self.env["res.config.settings"].sudo().get_values() + use_supplier_currency = self.env["ir.config_parameter"].sudo().get_bool("purchase.use_supplier_currency") for condition in domain: field, operator, value = condition if field == "user_id" and value == False: @@ -73,7 +45,7 @@ def _make_po_get_domain(self, company_id, values, partner): ) elif ( field == "currency_id" - and config.get("use_supplier_currency", False) + and use_supplier_currency and partner.property_purchase_currency_id and partner.property_purchase_currency_id.id != value ): diff --git a/purchase_stock_ux/security/ir.access.csv b/purchase_stock_ux/security/ir.access.csv new file mode 100644 index 00000000..cec1c9b1 --- /dev/null +++ b/purchase_stock_ux/security/ir.access.csv @@ -0,0 +1,2 @@ +id,name,model_id,group_id/id,operation,domain +access_purchase_order_cancel_remaining_wizard,access_purchase_order_cancel_remaining_wizard,purchase.order.cancel.remaining,base.group_user,cru, diff --git a/purchase_stock_ux/security/ir.model.access.csv b/purchase_stock_ux/security/ir.model.access.csv deleted file mode 100644 index 69b40db4..00000000 --- a/purchase_stock_ux/security/ir.model.access.csv +++ /dev/null @@ -1,2 +0,0 @@ -id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_purchase_order_cancel_remaining_wizard,access_purchase_order_cancel_remaining_wizard,model_purchase_order_cancel_remaining,base.group_user,1,1,1,0 diff --git a/purchase_stock_ux/tests/__init__.py b/purchase_stock_ux/tests/__init__.py index 99e464fd..20695fd0 100644 --- a/purchase_stock_ux/tests/__init__.py +++ b/purchase_stock_ux/tests/__init__.py @@ -4,6 +4,7 @@ ############################################################################## from . import test_purchase_order -from . import test_stock_orderpoint from . import test_qty_returned from . import test_stock_rule +from . import test_cancel_remaining +from . import test_exchange_qty_received diff --git a/purchase_stock_ux/tests/test_cancel_remaining.py b/purchase_stock_ux/tests/test_cancel_remaining.py new file mode 100644 index 00000000..855c7d41 --- /dev/null +++ b/purchase_stock_ux/tests/test_cancel_remaining.py @@ -0,0 +1,487 @@ +from odoo import Command +from odoo.addons.purchase_stock.tests.common import PurchaseTestCommon +from odoo.exceptions import UserError + + +class TestCancelRemaining(PurchaseTestCommon): + """Cobertura de "Cancelar remanente" (button_cancel_remaining) en compras. + + Al cancelar remanente NO debe quedar (ni generarse) recepción pendiente del proveedor: + * Ticket 124773: tras una devolución con reembolso quedaba un IN pendiente vivo, inflando + el pronóstico de unidades a recibir. + * Ticket 124957: cuando el move negativo no neteaba, se generaba una contra-entrega (OUT) al + proveedor en lugar de cancelar el remanente. + + Se valida en recepción de 1, 2 y 3 pasos: el remanente vive siempre en el move de primer paso + (proveedor->entrada) y debe quedar cancelado, preservando lo ya recibido en tránsito a stock. + """ + + # Run as admin, as in 19: these tests set up warehouses, routes, BoMs and sale orders + _test_user_groups = () + + STEPS = ("one_step", "two_steps", "three_steps") + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env["res.partner"].create({"name": "Cancel Remaining Vendor"}) + cls.warehouse = cls.env["stock.warehouse"].search([("company_id", "=", cls.env.company.id)], limit=1) + + # ------------------------------------------------------------------ helpers + def _product(self, name): + return self.env["product.product"].create({"name": name, "type": "consu", "is_storable": True}) + + def _confirm_po(self, product, qty): + po = self.env["purchase.order"].create( + { + "partner_id": self.partner.id, + "picking_type_id": self.warehouse.in_type_id.id, + "order_line": [ + Command.create( + { + "product_id": product.id, + "product_qty": qty, + "price_unit": 10.0, + "name": product.name, + } + ) + ], + } + ) + po.button_confirm() + return po + + def _chain(self, line): + """Toda la cadena de moves de la línea (en 2/3 pasos los internos cuelgan por move_dest_ids).""" + line.invalidate_recordset() + chain = frontier = line.move_ids + while frontier: + frontier = (frontier.move_dest_ids | frontier.move_orig_ids) - chain + chain |= frontier + return chain + + def _validate(self, picking, qty=None, backorder=True): + if picking.state not in ("assigned", "partially_available"): + picking.action_assign() + for move in picking.move_ids.filtered(lambda m: m.state not in ("done", "cancel")): + move.quantity = move.product_uom_qty if qty is None else qty + move.picked = True + res = picking.button_validate() + if isinstance(res, dict) and res.get("res_model") == "stock.backorder.confirmation": + wizard = self.env[res["res_model"]].with_context(**res["context"]).create({}) + wizard.process() if backorder else wizard.process_cancel_backorder() + + def _receive_full(self, po): + """Recibe la cantidad pedida hasta stock, empujando toda la cadena (1/2/3 pasos).""" + for _i in range(8): + pickings = po.picking_ids.filtered(lambda p: p.state not in ("done", "cancel")) + for picking in pickings: + picking.action_assign() + ready = pickings.filtered(lambda p: p.state in ("assigned", "partially_available")) + if not ready: + break + for picking in ready: + self._validate(picking) + + def _receive_partial_at_input(self, po, qty): + """Recibe parcialmente en el primer picking (proveedor->entrada) y deja backorder.""" + picking = po.picking_ids.filtered( + lambda p: p.picking_type_id.code == "incoming" and p.state not in ("done", "cancel") + ).sorted("id")[:1] + self._validate(picking, qty=qty, backorder=True) + + def _return(self, po, qty, to_refund=True, validate=True): + receipt = po.picking_ids.filtered(lambda p: p.picking_type_id.code == "incoming" and p.state == "done").sorted( + "id" + )[-1:] + return_picking = self.env["stock.picking"].browse(receipt.action_return()["res_id"]) + return_picking.move_ids.write({"product_uom_qty": qty, "to_refund": to_refund}) + return_picking.action_confirm() + if validate: + self._validate(return_picking) + return return_picking + + # -------------------------------------------------------------- assertions + def _assert_no_vendor_pending(self, line): + """No debe quedar recepción pendiente DEL PROVEEDOR ni contra-entrega al proveedor.""" + chain = self._chain(line) + vendor_pending = chain.filtered( + lambda m: m.state not in ("done", "cancel") + and m.location_id.usage == "supplier" + and not m._is_exchange_receipt() + ) + self.assertFalse(vendor_pending, "quedó recepción pendiente del proveedor tras cancelar remanente") + out_to_vendor = chain.filtered( + lambda m: m.state not in ("done", "cancel") + and m.location_dest_id.usage == "supplier" + and not m._is_exchange_receipt() + ) + self.assertFalse(out_to_vendor, "quedó una contra-entrega (OUT) al proveedor tras cancelar remanente") + self.assertAlmostEqual(line.product_qty, line.qty_received + line.qty_returned) + + # -------------------------------------------------------------------- tests + def test_partial_receipt_cancel_remaining(self): + """Caso base sin devoluciones: recibir parcial y cancelar remanente cierra la línea, en 1/2/3 pasos.""" + for steps in self.STEPS: + with self.subTest(steps=steps): + self.warehouse.reception_steps = steps + product = self._product("CR base %s" % steps) + po = self._confirm_po(product, 6) + line = po.order_line + self._receive_partial_at_input(po, 2) + line.button_cancel_remaining() + self._assert_no_vendor_pending(line) + self.assertEqual(line.product_qty, 2) + + def test_return_refund_then_cancel_remaining(self): + """Ticket 124773: recibir todo, devolver con reembolso, subir la cantidad y cancelar remanente + no debe dejar (ni inflar) el IN pendiente, en 1/2/3 pasos.""" + for steps in self.STEPS: + with self.subTest(steps=steps): + self.warehouse.reception_steps = steps + product = self._product("CR 124773 %s" % steps) + po = self._confirm_po(product, 4) + line = po.order_line + self._receive_full(po) + self._return(po, 4, to_refund=True) + line.invalidate_recordset() + line.product_qty = 6 + line.button_cancel_remaining() + self._assert_no_vendor_pending(line) + + def test_open_return_not_cancelled(self): + """Una devolución al proveedor ABIERTA (sin validar) no es parte del remanente y NO debe + cancelarse al cancelar remanente (arista reportada en base cummotors: cancelaba todo).""" + for to_refund in (True, False): + with self.subTest(to_refund=to_refund): + self.warehouse.reception_steps = "one_step" + product = self._product("CR open return %s" % to_refund) + po = self._confirm_po(product, 10) + line = po.order_line + self._receive_partial_at_input(po, 6) # deja backorder de 4 = remanente a cancelar + open_return = self._return(po, 2, to_refund=to_refund, validate=False) + self.assertNotIn(open_return.state, ("done", "cancel")) + line.button_cancel_remaining() + open_return.invalidate_recordset() + self.assertNotEqual( + open_return.state, "cancel", "cancelar remanente canceló una devolución abierta en curso" + ) + self.assertTrue( + open_return.move_ids.filtered(lambda m: m.state != "cancel"), + "el move de la devolución abierta quedó cancelado", + ) + # el remanente forward del proveedor (backorder de 4) sí debe cancelarse + forward_pending = self._chain(line).filtered( + lambda m: m.state not in ("done", "cancel") + and m.location_id.usage == "supplier" + and not m.origin_returned_move_id + ) + self.assertFalse(forward_pending, "no se canceló el remanente forward del proveedor") + + def test_received_goods_preserved(self): + """Cancelar remanente no debe cancelar lo ya recibido: en 2/3 pasos el tránsito interno sobrevive.""" + self.warehouse.reception_steps = "two_steps" + product = self._product("CR preserva") + po = self._confirm_po(product, 6) + line = po.order_line + self._receive_partial_at_input(po, 2) + line.button_cancel_remaining() + chain = self._chain(line) + internal_alive = chain.filtered( + lambda m: m.state not in ("done", "cancel") + and m.location_id.usage in ("internal", "transit") + and m.location_dest_id.usage in ("internal", "transit") + ) + self.assertTrue(internal_alive, "se canceló por error la mercadería recibida en tránsito a stock") + self.assertAlmostEqual(sum(internal_alive.mapped("product_uom_qty")), 2) + + def _subcontracted_product(self, name): + """Storable product with a subcontracting BoM (subcontractor: self.partner) whose component + is resupplied on order. Skips the test when mrp_subcontracting is not installed.""" + resupply_route = self.env.ref("mrp_subcontracting.route_resupply_subcontractor_mto", raise_if_not_found=False) + if not resupply_route: + self.skipTest("mrp_subcontracting is not installed") + component = self._product("%s component" % name) + resupply_route.product_selectable = True + component.route_ids = [Command.link(resupply_route.id)] + finished = self._product(name) + bom = self.env["mrp.bom"].create( + { + "product_tmpl_id": finished.product_tmpl_id.id, + "type": "subcontract", + "subcontractor_ids": [Command.link(self.partner.id)], + "bom_line_ids": [Command.create({"product_id": component.id, "product_qty": 1})], + } + ) + return finished, bom + + def _open_moves_of_order(self, po, exclude=None): + return self.env["stock.move"].search( + [("reference_ids", "in", po.reference_ids.ids), ("state", "not in", ("done", "cancel"))] + ) - (exclude or self.env["stock.move"]) + + def test_subcontracted_cancel_remaining_nothing_received(self): + """Subcontracting: cancel remaining with nothing received must not raise and must cancel the + whole receipt chain (1/2/3 steps), the subcontracting MO and the resupply, so the line can be + re-ordered with the new BoM.""" + for steps in self.STEPS: + with self.subTest(steps=steps): + self.warehouse.reception_steps = steps + finished, bom = self._subcontracted_product("CR subcontract %s" % steps) + po = self._confirm_po(finished, 10) + line = po.order_line + production = self.env["mrp.production"].search([("product_id", "=", finished.id)]) + resupply = production.picking_ids + self.assertTrue(resupply, "no resupply picking was created for the subcontracting MO") + # the BoM changes after the order was confirmed + new_comp = self._product("CR subcontract new component %s" % steps) + bom.bom_line_ids = [Command.clear(), Command.create({"product_id": new_comp.id, "product_qty": 1})] + + line.button_cancel_remaining() + + self.assertEqual(line.product_qty, 0) + self.assertFalse(self._open_moves_of_order(po), "a receipt leg was left open") + self.assertEqual(production.state, "cancel") + self.assertEqual(resupply.state, "cancel") + + # ordering again takes the current BoM + line.product_qty = 10 + new_production = self.env["mrp.production"].search( + [("product_id", "=", finished.id), ("state", "!=", "cancel")] + ) + self.assertEqual(new_production.move_raw_ids.product_id, new_comp) + + def test_subcontracted_recorded_production_blocks_cancel_remaining(self): + """If the subcontractor already recorded production, cancel remaining stops with a message + instead of cancelling the MO and losing what was recorded.""" + self.warehouse.reception_steps = "one_step" + # In 20 the MO is in progress once a component is consumed; without work orders it never gets to_close + for state in ("progress",): + with self.subTest(state=state): + finished, _bom = self._subcontracted_product("CR subcontract recorded %s" % state) + po = self._confirm_po(finished, 10) + line = po.order_line + production = self.env["mrp.production"].search([("product_id", "=", finished.id)]) + production.move_raw_ids.picked = True + self.assertEqual(production.state, state) + + with self.assertRaisesRegex(UserError, production.name): + line.button_cancel_remaining() + self.assertEqual(line.product_qty, 10) + self.assertNotEqual(production.state, "cancel") + + def test_subcontracted_partial_receipt_cancel_remaining(self): + """Subcontracting with a partial receipt: cancel remaining cancels the pending receipt and its + MO, and keeps the internal legs (2/3 steps) that bring the received goods to stock.""" + for steps in self.STEPS: + with self.subTest(steps=steps): + self.warehouse.reception_steps = steps + finished, _bom = self._subcontracted_product("CR subcontract partial %s" % steps) + po = self._confirm_po(finished, 10) + line = po.order_line + self._receive_partial_at_input(po, 4) + + line.button_cancel_remaining() + + self.assertEqual(line.product_qty, 4) + self.assertFalse( + self._open_moves_of_order(po).filtered("purchase_line_id"), "the pending receipt was left open" + ) + self.assertFalse( + self.env["mrp.production"].search( + [("product_id", "=", finished.id), ("state", "not in", ("done", "cancel"))] + ), + "the MO of the pending quantity was left open", + ) + internal_alive = self._open_moves_of_order(po).filtered( + lambda m: m.location_id.usage == "internal" + and m.location_dest_id.usage == "internal" + and not m.location_dest_id.is_subcontract() + ) + if steps == "one_step": + self.assertFalse(internal_alive) + else: + self.assertAlmostEqual(sum(internal_alive.mapped("product_uom_qty")), 4) + + def test_subcontracted_cancel_remaining_keeps_sale_delivery(self): + """Subcontracting bought on order for a sale: cancel remaining on the purchase cancels the + receipt chain (1/2/3 steps) but not the customer delivery, as core's netting does not either.""" + if "sale_line_id" not in self.env["stock.move"]._fields: + self.skipTest("sale_stock is not installed") + self.warehouse.delivery_steps = "ship_only" + mto_route = self.warehouse.mto_pull_id.route_id + mto_route.active = True + mto_route.product_selectable = True + self.warehouse.buy_pull_id.route_id.product_selectable = True + for steps in self.STEPS: + with self.subTest(steps=steps): + self.warehouse.reception_steps = steps + # the purchase line takes it from the buy rule; with it on, a plain cancel would reach the delivery + self.warehouse.buy_pull_id.propagate_cancel = True + finished, _bom = self._subcontracted_product("CR subcontract MTO %s" % steps) + finished.route_ids = [ + Command.link(mto_route.id), + Command.link(self.warehouse.buy_pull_id.route_id.id), + ] + finished.seller_ids = [Command.create({"partner_id": self.partner.id, "price": 10.0})] + sale_vals = { + "partner_id": self.env["res.partner"].create({"name": "CR customer"}).id, + "order_line": [Command.create({"product_id": finished.id, "product_uom_qty": 5})], + } + # sale exception rules installed in the build must not block this confirmation + if "ignore_exception" in self.env["sale.order"]._fields: + sale_vals["ignore_exception"] = True + sale = self.env["sale.order"].create(sale_vals) + sale.action_confirm() + delivery_move = sale.order_line.move_ids + line = self.env["purchase.order.line"].search([("product_id", "=", finished.id)]) + self.assertEqual(len(line), 1, "the sale did not generate the purchase to the subcontractor") + line.order_id.button_confirm() + self.assertTrue(line.propagate_cancel) + self.assertIn(delivery_move, self._chain(line)) + + line.button_cancel_remaining() + + self.assertFalse( + self._open_moves_of_order(line.order_id, exclude=delivery_move), "a receipt leg was left open" + ) + self.assertNotEqual(delivery_move.state, "cancel", "cancel remaining cancelled the customer delivery") + + def test_cancel_remaining_on_locked_order(self): + """A locked purchase order can cancel its remaining quantity and stays locked.""" + self.warehouse.reception_steps = "one_step" + product = self._product("CR locked") + po = self._confirm_po(product, 6) + line = po.order_line + self._receive_partial_at_input(po, 2) + po.button_lock() + line.button_cancel_remaining() + self._assert_no_vendor_pending(line) + self.assertEqual(line.product_qty, 2) + self.assertTrue(po.locked) + + def test_cancel_remaining_more_invoiced_than_received(self): + """With more billed than received, cancel remaining stops.""" + self.warehouse.reception_steps = "one_step" + product = self._product("CR invoiced") + product.purchase_method = "purchase" + po = self._confirm_po(product, 6) + line = po.order_line + self._receive_partial_at_input(po, 2) + bill = self.env["account.move"].browse(po.action_create_invoice()["res_id"]) + bill.invoice_date = bill.date + bill.action_post() + self.assertEqual(line.qty_invoiced, 6) + with self.assertRaises(UserError): + line.button_cancel_remaining() + self.assertEqual(line.product_qty, 6) + + def test_exchange_receipt_counted_and_not_cancelled(self): + """Odoo 20 exchange: the return lowers the received quantity and the replacement raises it + again; cancel remaining keeps the pending replacement receipt.""" + self.warehouse.reception_steps = "one_step" + product = self._product("CR exchange") + po = self._confirm_po(product, 10) + line = po.order_line + self._receive_full(po) + return_picking = self._return(po, 1) + self.assertEqual(line.qty_received, 9) + exchange = self.env["stock.picking"].browse(return_picking.action_exchange()["res_id"]) + self.assertEqual(exchange.move_ids.purchase_line_id, line) + + line.button_cancel_remaining() + + self.assertNotEqual(exchange.state, "cancel", "cancel remaining cancelled the exchange receipt") + self._validate(exchange) + self.assertEqual(line.qty_received, 10) + + def test_line_increase_in_exchange_picking_is_cancelled(self): + """A quantity added to the line after an exchange lands in the exchange picking: cancel remaining + cancels it and keeps only the replacement.""" + self.warehouse.reception_steps = "one_step" + product = self._product("CR exchange increase") + po = self._confirm_po(product, 10) + line = po.order_line + self._receive_full(po) + return_picking = self._return(po, 1) + exchange = self.env["stock.picking"].browse(return_picking.action_exchange()["res_id"]) + replacement = exchange.move_ids + line.product_qty = 15 + added = exchange.move_ids - replacement + self.assertTrue(added, "core did not add the new quantity to the exchange picking") + # in real use the quantity is added in a later transaction than the exchange + self.env.cr.execute( + "UPDATE stock_move SET create_date = create_date + interval '1 minute' WHERE id IN %s", [tuple(added.ids)] + ) + added.invalidate_recordset(["create_date"]) + self.assertTrue(replacement._is_exchange_receipt()) + self.assertFalse(any(move._is_exchange_receipt() for move in added)) + + line.button_cancel_remaining() + + self.assertEqual(line.product_qty, 10) + open_moves = line.move_ids.filtered(lambda m: m.state not in ("done", "cancel")) + self.assertEqual(open_moves, replacement) + self.assertEqual(replacement.product_uom_qty, 1) + + def test_subcontracted_cancel_remaining_without_manufacturing_access(self): + """A purchase manager without manufacturing access can cancel the remaining quantity of a + subcontracted line with nothing received.""" + self.warehouse.reception_steps = "one_step" + finished, _bom = self._subcontracted_product("CR subcontract purchase user") + user = self.env["res.users"].create( + { + "name": "CR purchase manager", + "login": "cr_purchase_manager", + "company_id": self.env.company.id, + "company_ids": [Command.set(self.env.company.ids)], + "group_ids": [ + Command.set( + [self.env.ref("purchase.group_purchase_manager").id, self.env.ref("stock.group_stock_user").id] + ) + ], + } + ) + self.assertFalse(user.has_group("mrp.group_mrp_user")) + po = self._confirm_po(finished, 10) + line = po.order_line + production = self.env["mrp.production"].search([("product_id", "=", finished.id)]) + + line.with_user(user).button_cancel_remaining() + + self.assertEqual(line.product_qty, 0) + self.assertEqual(production.state, "cancel") + + def test_mto_cancelled_receipt_cancel_remaining(self): + """Bought on order for a sale, with the receipt cancelled by hand: cancel remaining does not + create a new receipt, as in 19.""" + if "sale_line_id" not in self.env["stock.move"]._fields: + self.skipTest("sale_stock is not installed") + self.warehouse.reception_steps = "one_step" + self.warehouse.delivery_steps = "ship_only" + mto_route = self.warehouse.mto_pull_id.route_id + mto_route.active = True + mto_route.product_selectable = True + buy_route = self.warehouse.buy_pull_id.route_id + buy_route.product_selectable = True + product = self._product("CR MTO cancelled receipt") + product.route_ids = [Command.link(mto_route.id), Command.link(buy_route.id)] + product.seller_ids = [Command.create({"partner_id": self.partner.id, "price": 10.0})] + sale_vals = { + "partner_id": self.env["res.partner"].create({"name": "CR customer"}).id, + "order_line": [Command.create({"product_id": product.id, "product_uom_qty": 5})], + } + # sale exception rules installed in the build must not block this confirmation + if "ignore_exception" in self.env["sale.order"]._fields: + sale_vals["ignore_exception"] = True + self.env["sale.order"].create(sale_vals).action_confirm() + line = self.env["purchase.order.line"].search([("product_id", "=", product.id)]) + po = line.order_id + po.button_confirm() + receipts = po.picking_ids + receipts.action_cancel() + + line.button_cancel_remaining() + + self.assertEqual(line.product_qty, 0) + self.assertEqual(po.picking_ids, receipts, "a new receipt was created") diff --git a/purchase_stock_ux/tests/test_exchange_qty_received.py b/purchase_stock_ux/tests/test_exchange_qty_received.py new file mode 100644 index 00000000..c607cefe --- /dev/null +++ b/purchase_stock_ux/tests/test_exchange_qty_received.py @@ -0,0 +1,81 @@ +from odoo import Command +from odoo.addons.purchase_stock.tests.common import PurchaseTestCommon + + +class TestExchangeQtyReceived(PurchaseTestCommon): + """An exchange on a vendor return kept from 19 without "to refund" must not change the received quantity.""" + + # Run as admin, as in 19: these tests set up warehouses + _test_user_groups = () + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env["res.partner"].create({"name": "Exchange Vendor"}) + cls.warehouse = cls.env["stock.warehouse"].search([("company_id", "=", cls.env.company.id)], limit=1) + cls.warehouse.reception_steps = "one_step" + + def _product(self, purchase_method): + return self.env["product.product"].create( + { + "name": f"Exchange product {purchase_method}", + "type": "consu", + "is_storable": True, + "purchase_method": purchase_method, + } + ) + + def _validate(self, picking): + picking.action_assign() + for move in picking.move_ids.filtered(lambda m: m.state not in ("done", "cancel")): + move.quantity = move.product_uom_qty + move.picked = True + picking.button_validate() + + def _received_and_invoiced_po(self, product, qty=10): + po = self.env["purchase.order"].create( + { + "partner_id": self.partner.id, + "picking_type_id": self.warehouse.in_type_id.id, + "order_line": [Command.create({"product_id": product.id, "product_qty": qty, "price_unit": 10.0})], + } + ) + po.button_confirm() + self._validate(po.picking_ids) + po.action_create_invoice() + po.invoice_ids.invoice_date = po.date_order + po.invoice_ids.action_post() + return po + + def _exchange(self, po, qty, to_refund): + receipt = po.picking_ids.filtered(lambda p: p.state == "done") + return_picking = self.env["stock.picking"].browse(receipt.action_return()["res_id"]) + return_picking.move_ids.write({"product_uom_qty": qty, "to_refund": to_refund}) + return_picking.action_confirm() + self._validate(return_picking) + exchange = self.env["stock.picking"].browse(return_picking.action_exchange()["res_id"]) + self._validate(exchange) + self.assertEqual(len(po.picking_ids.filtered(lambda p: p.state == "done")), 3) + + def test_exchange_of_return_without_refund(self): + """Return made in 19 with "to refund" unchecked, exchanged in 20: 10 received and nothing to bill.""" + for purchase_method in ("receive", "purchase"): + with self.subTest(purchase_method=purchase_method): + po = self._received_and_invoiced_po(self._product(purchase_method)) + self._exchange(po, 1, to_refund=False) + line = po.order_line + self.assertEqual(line.qty_received, 10) + self.assertEqual(line.qty_returned, 0) + self.assertEqual(line.qty_to_invoice, 0) + + def test_exchange_with_refund(self): + """Exchange of a 20 return: the return lowers the received quantity and the replacement raises it. + The ordered quantity stays fully billed: the returned quantity is not deducted from the bill.""" + for purchase_method in ("receive", "purchase"): + with self.subTest(purchase_method=purchase_method): + po = self._received_and_invoiced_po(self._product(purchase_method)) + self._exchange(po, 1, to_refund=True) + line = po.order_line + self.assertEqual(line.qty_received, 10) + self.assertEqual(line.qty_to_invoice, 0) + self.assertEqual(line.invoice_status, "invoiced") diff --git a/purchase_stock_ux/tests/test_purchase_order.py b/purchase_stock_ux/tests/test_purchase_order.py index 3726ca43..091999e9 100644 --- a/purchase_stock_ux/tests/test_purchase_order.py +++ b/purchase_stock_ux/tests/test_purchase_order.py @@ -4,6 +4,9 @@ class TestPurchaseOrder(PurchaseTestCommon): + # Run as admin, as in 19: these tests set up warehouses, routes, BoMs and sale orders + _test_user_groups = () + @classmethod def setUpClass(cls): super().setUpClass() @@ -34,17 +37,12 @@ def setUpClass(cls): def _create_return_for_product(self, picking, product, qty, to_refund=True): """Return `qty` units of `product` from a validated picking.""" - return_wiz = ( - self.env["stock.return.picking"].with_context(active_id=picking.id, active_model="stock.picking").create({}) - ) - for line in return_wiz.product_return_moves: - if line.product_id == product: - line.quantity = qty - line.to_refund = to_refund - else: - line.quantity = 0 - return_picking = return_wiz._create_return() - return_picking.move_ids.quantity = qty + return_picking = self.env["stock.picking"].browse(picking.action_return()["res_id"]) + return_picking.move_ids.filtered(lambda m: m.product_id != product).product_uom_qty = 0 + return_move = return_picking.move_ids.filtered(lambda m: m.product_id == product) + return_move.write({"product_uom_qty": qty, "to_refund": to_refund}) + return_picking.action_confirm() + return_move.quantity = qty return_picking.with_context(skip_backorder=True).button_validate() return return_picking @@ -54,7 +52,8 @@ def test_invoice_excludes_fully_returned_lines(self): { "name": "Product 2 Storable", "is_storable": True, - "purchase_method": "purchase", + # billed on received quantities: a fully returned line has nothing to bill + "purchase_method": "receive", } ) po = self.env["purchase.order"].create( @@ -191,7 +190,7 @@ def test_subcontract_receipt_not_flagged_as_returned(self): { "product_id": self.product.id, "product_uom_qty": 10, - "product_uom": self.product.uom_id.id, + "uom_id": self.product.uom_id.id, "location_id": internal_loc.id, "location_dest_id": internal_loc.id, "to_refund": True, @@ -214,7 +213,8 @@ def test_invoice_keeps_note_lines(self): { "name": "Product 2 Storable", "is_storable": True, - "purchase_method": "purchase", + # billed on received quantities: a fully returned line has nothing to bill + "purchase_method": "receive", } ) po = self.env["purchase.order"].create( @@ -234,7 +234,7 @@ def test_invoice_keeps_note_lines(self): "name": "Handle with care", "product_id": False, "product_qty": 0.0, - "product_uom_id": False, + "uom_id": False, "price_unit": 0.0, } ), @@ -301,9 +301,9 @@ def test_unlink_line_with_done_dest_move(self): { "product_id": self.product.id, "product_uom_qty": 5, - "product_uom": self.product.uom_id.id, - "location_id": self.env.ref("stock.stock_location_stock").id, - "location_dest_id": self.env.ref("stock.stock_location_customers").id, + "uom_id": self.product.uom_id.id, + "location_id": self.stock_location.id, + "location_dest_id": self.customer_location.id, } ) done_move.write({"state": "done"}) @@ -335,7 +335,7 @@ def _confirmed_po_with_note(self): "name": "Handle with care", "product_id": False, "product_qty": 0.0, - "product_uom_id": False, + "uom_id": False, "price_unit": 0.0, } ), @@ -376,3 +376,34 @@ def test_receipt_status_partial_and_full(self): backorder.move_ids.quantity = 6 backorder.with_context(skip_backorder=True).button_validate() self.assertEqual(line.receipt_status, "full") + + def test_internal_notes_go_to_receipt(self): + """The internal notes of the purchase order are copied to the receipt it creates.""" + receipt = self.purchase_order.picking_ids + self.assertEqual(len(receipt), 1) + self.assertIn("Test internal notes", receipt.note) + + def test_receipt_origin_description_is_line_label(self): + """The receipt move shows the purchase line as the purchase shows it: product and description.""" + line = self.purchase_order.order_line + self.assertTrue(line.label) + self.assertEqual(line.move_ids.origin_description, line.label) + + def test_cancel_remaining_message_names_the_line(self): + """The chatter message of cancel remaining names the line, not an empty description.""" + line = self.purchase_order.order_line + line.button_cancel_remaining() + self.assertIn(line.label, self.purchase_order.message_ids[:1].body) + + def test_config_settings_are_saved(self): + """Unit of measure propagation and supplier currency settings are stored and read back.""" + self.env["res.config.settings"].create({"propagate_uom": True, "use_supplier_currency": True}).execute() + settings = self.env["res.config.settings"].create({}) + self.assertTrue(settings.propagate_uom) + self.assertTrue(settings.use_supplier_currency) + self.assertTrue(self.env["ir.config_parameter"].get_bool("stock.propagate_uom")) + settings.write({"propagate_uom": False, "use_supplier_currency": False}) + settings.execute() + settings = self.env["res.config.settings"].create({}) + self.assertFalse(settings.propagate_uom) + self.assertFalse(settings.use_supplier_currency) diff --git a/purchase_stock_ux/tests/test_qty_returned.py b/purchase_stock_ux/tests/test_qty_returned.py index 64da61b4..558b2e90 100644 --- a/purchase_stock_ux/tests/test_qty_returned.py +++ b/purchase_stock_ux/tests/test_qty_returned.py @@ -13,6 +13,9 @@ class TestQtyReturned(PurchaseTestCommon): igual que _compute_qty_received del core. """ + # Run as admin, as in 19: these tests set up warehouses, routes, BoMs and sale orders + _test_user_groups = () + @classmethod def setUpClass(cls): super().setUpClass() @@ -66,16 +69,8 @@ def _return_all(self, po, qty, to_refund=True): receipt = po.picking_ids.filtered(lambda p: p.picking_type_id.code == "incoming" and p.state == "done").sorted( "id" )[-1:] - wizard = ( - self.env["stock.return.picking"] - .with_context(active_id=receipt.id, active_model="stock.picking", active_ids=receipt.ids) - .create({}) - ) - for wizard_line in wizard.product_return_moves: - wizard_line.quantity = qty - wizard_line.to_refund = to_refund - action = wizard.action_create_returns() - return_picking = self.env["stock.picking"].browse(action["res_id"]) + return_picking = self.env["stock.picking"].browse(receipt.action_return()["res_id"]) + return_picking.move_ids.write({"product_uom_qty": qty, "to_refund": to_refund}) self._validate(return_picking) return return_picking @@ -101,7 +96,8 @@ def test_return_of_replaced_product_not_counted(self): self.assertEqual(line.invoice_status, "to invoice") def test_return_of_same_product_counted(self): - """No regresión: la devolución del mismo producto de la línea sí debe seguir contando.""" + """No regresión: la devolución del mismo producto de la línea sí debe seguir contando. + Como en Odoo, no descuenta lo pendiente a facturar: se resuelve con nota de crédito.""" product = self._product("QR mismo producto") po = self._confirm_po(product, 10) line = po.order_line @@ -110,4 +106,4 @@ def test_return_of_same_product_counted(self): line.invalidate_recordset() self.assertEqual(line.qty_returned, 10) - self.assertEqual(line.qty_to_invoice, 0) + self.assertEqual(line.qty_to_invoice, 10) diff --git a/purchase_stock_ux/tests/test_stock_orderpoint.py b/purchase_stock_ux/tests/test_stock_orderpoint.py deleted file mode 100644 index 072721f6..00000000 --- a/purchase_stock_ux/tests/test_stock_orderpoint.py +++ /dev/null @@ -1,44 +0,0 @@ -from odoo import Command -from odoo.addons.stock.tests.common import TestStockCommon - - -class TestStockOrderpoint(TestStockCommon): - @classmethod - def setUpClass(cls): - super().setUpClass() - - cls.partner = cls.env["res.partner"].create( - { - "name": "Main Supplier", - } - ) - - cls.product = cls.env["product.product"].create( - { - "name": "Test Product with Supplier", - "seller_ids": [Command.create({"partner_id": cls.partner.id})], - } - ) - - cls.location_stock = cls.env.ref("stock.stock_location_stock") - - cls.orderpoint = cls.env["stock.warehouse.orderpoint"].create( - { - "product_id": cls.product.id, - "location_id": cls.location_stock.id, - "product_min_qty": 10, - "product_max_qty": 20, - } - ) - - def test_get_orderpoint_values_with_default_supplier(self): - """ - Test the main seller is set as a the default supplier in replenishment rules. - """ - # In stock_orderpoint model, the method _get_orderpoint_action(self) calls _get_orderpoint_values. - values = self.orderpoint._get_orderpoint_values(self.product.id, self.location_stock.id) - self.assertEqual( - values["supplier_id"], - self.product.seller_ids[0].id, - "The main seller should be set as the supplier in replenishment rules", - ) diff --git a/purchase_stock_ux/tests/test_stock_rule.py b/purchase_stock_ux/tests/test_stock_rule.py index 34dccf38..c2569b6f 100644 --- a/purchase_stock_ux/tests/test_stock_rule.py +++ b/purchase_stock_ux/tests/test_stock_rule.py @@ -9,12 +9,14 @@ def setUpClass(cls): super().setUpClass() cls.partner = cls.env["res.partner"].create({"name": "Vendor"}) cls.warehouse = cls.env["stock.warehouse"].search([("company_id", "=", cls.env.company.id)], limit=1) + buy_route = cls.env.ref("purchase_stock.route_warehouse0_buy") + buy_route.product_selectable = True cls.product = cls.env["product.product"].create( { "name": "Product without vendor price", "is_storable": True, "standard_price": 100.0, - "route_ids": [Command.link(cls.env.ref("purchase_stock.route_warehouse0_buy").id)], + "route_ids": [Command.link(buy_route.id)], "seller_ids": [Command.create({"partner_id": cls.partner.id, "price": 0.0})], } ) @@ -43,3 +45,10 @@ def test_merge_po_line_falls_back_to_standard_price(self): self.assertEqual(len(line), 1) self.assertEqual(line.product_qty, 2) self.assertEqual(line.price_unit, 100.0) + + def test_new_po_line_falls_back_to_standard_price(self): + """A new purchase line from a procurement uses the product cost when the vendor has no price.""" + self._run_buy_procurement(1) + line = self.env["purchase.order.line"].search([("product_id", "=", self.product.id)]) + self.assertEqual(len(line), 1) + self.assertEqual(line.price_unit, 100.0) diff --git a/purchase_stock_ux/views/purchase_order_views.xml b/purchase_stock_ux/views/purchase_order_views.xml index 373e3a36..38503d25 100644 --- a/purchase_stock_ux/views/purchase_order_views.xml +++ b/purchase_stock_ux/views/purchase_order_views.xml @@ -14,7 +14,7 @@ -