diff --git a/setup/stock_location_orderpoint/odoo/addons/stock_location_orderpoint b/setup/stock_location_orderpoint/odoo/addons/stock_location_orderpoint new file mode 120000 index 00000000..9736632f --- /dev/null +++ b/setup/stock_location_orderpoint/odoo/addons/stock_location_orderpoint @@ -0,0 +1 @@ +../../../../stock_location_orderpoint \ No newline at end of file diff --git a/setup/stock_location_orderpoint/setup.py b/setup/stock_location_orderpoint/setup.py new file mode 100644 index 00000000..28c57bb6 --- /dev/null +++ b/setup/stock_location_orderpoint/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/stock_location_orderpoint/README.rst b/stock_location_orderpoint/README.rst new file mode 100644 index 00000000..76cd790c --- /dev/null +++ b/stock_location_orderpoint/README.rst @@ -0,0 +1,140 @@ +========================= +stock_location_orderpoint +========================= + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fstock--logistics--orderpoint-lightgray.png?logo=github + :target: https://github.com/OCA/stock-logistics-orderpoint/tree/16.0/stock_location_orderpoint + :alt: OCA/stock-logistics-orderpoint +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/stock-logistics-orderpoint-16-0/stock-logistics-orderpoint-16-0-stock_location_orderpoint + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/webui/builds.html?repo=OCA/stock-logistics-orderpoint&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Declare orderpoint on a location allowing to replenish any product with the same criteria. +This is for an internal warehouse replenishment currently not compatible with the purchase buy route. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +First configuration +=================== + +#. In order to replenish your stock location from another one, you first need + to set the multi locations configuration. +#. So, go to Inventory > Configuration > Settings > Warehouse +#. Check the 'Storage Locations' box. +#. As you should be able to configure a dedicated route to replenishment, you + should also activate, in the same menu, the 'Multi-Step Routes' box. + +Locations configuration +======================= + +#. Identify the location you want to apply a replenishment rule (e.g.: WH/Stock) +#. Create (if not exists) a new location for replenishment under Warehouse view (e.g.: WH) + location as we want to get the stock in replenishment taken into account of + our product stock total quantity. + +Route Configuration +=================== + +#. You should configure at least a route with a rule that: + + * Pull from the Replenishment stock location. + * For the stock location you want to (e.g.: WH/Stock) + +Location Orderpoint configuration +================================= + +#. Go either by the stock location you want to replenish and click on 'Orderpoints' + or go to Inventory > Configuration > Settings > Warehouse > Stock Location Orderpoint +#. Click on 'Create' +#. Set a sequence +#. Choose if the rule will be applied: + + * Automatically (Auto/realtime): at each stock movement on the stock location, the rule will be + evaluated. + * Manually (Manual): If set, an action 'Run Replenishment' will be displayed on the rule + and allow to run it manually. + * by cron (Scheduled): A cron job will trigger the replenishment rules of this kind. +#. Choose a replenish method: + + * Fill up: The replenishment will be triggered when a move is waiting availability + and forecast quantity is negative at the location (i.e. min=0). The replenished quantity will + bring back the forecast quantity to 0 (i.e. max=0) but will be limited to what is available at + the source location to plan only reservable replenishment moves. +#. Choose the location to replenish +#. Choose the route to use to replenish. The source location will be computed automatically based on + the route value. +#. Define a procurement group if you want to group some movements together. +#. Define a priority for the created moves. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* MT Software +* BCIM + +Contributors +~~~~~~~~~~~~ + +* Michael Tietz (MT Software) +* Jacques-Etienne Baudoux (BCIM) +* Denis Roussel + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-mt-software-de| image:: https://github.com/mt-software-de.png?size=40px + :target: https://github.com/mt-software-de + :alt: mt-software-de + +Current `maintainer `__: + +|maintainer-mt-software-de| + +This module is part of the `OCA/stock-logistics-orderpoint `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/stock_location_orderpoint/__init__.py b/stock_location_orderpoint/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/stock_location_orderpoint/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/stock_location_orderpoint/__manifest__.py b/stock_location_orderpoint/__manifest__.py new file mode 100644 index 00000000..20910ff2 --- /dev/null +++ b/stock_location_orderpoint/__manifest__.py @@ -0,0 +1,33 @@ +# Copyright 2023 Michael Tietz (MT Software) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "stock_location_orderpoint", + "author": "MT Software, BCIM, Odoo Community Association (OCA)", + "summary": "Declare orderpoint on a location " + "allowing to replenish any product with the same criteria.", + "version": "16.0.1.0.0", + "data": [ + "security/ir.model.access.csv", + "data/ir_cron.xml", + "data/ir_sequence.xml", + "data/queue_job_channel.xml", + "data/queue_job_function.xml", + "views/stock_location_orderpoint_views.xml", + "views/stock_location.xml", + "views/menu.xml", + ], + "demo": [ + "demo/stock_location.xml", + "demo/stock_picking_type.xml", + "demo/stock_route.xml", + "demo/stock_location_orderpoint.xml", + ], + "depends": [ + "stock_helper", + "queue_job", + ], + "license": "AGPL-3", + "maintainers": ["mt-software-de"], + "website": "https://github.com/OCA/stock-logistics-orderpoint", +} diff --git a/stock_location_orderpoint/data/ir_cron.xml b/stock_location_orderpoint/data/ir_cron.xml new file mode 100644 index 00000000..46a520e6 --- /dev/null +++ b/stock_location_orderpoint/data/ir_cron.xml @@ -0,0 +1,17 @@ + + + + Procurement: run location replenishment + + code + + model.run_cron_replenishment() + + + + 10 + minutes + -1 + + + diff --git a/stock_location_orderpoint/data/ir_sequence.xml b/stock_location_orderpoint/data/ir_sequence.xml new file mode 100644 index 00000000..d261dc01 --- /dev/null +++ b/stock_location_orderpoint/data/ir_sequence.xml @@ -0,0 +1,11 @@ + + + + Stock locaation orderpoint + stock.location.orderpoint + LOP/ + 5 + 1 + 1 + + diff --git a/stock_location_orderpoint/data/queue_job_channel.xml b/stock_location_orderpoint/data/queue_job_channel.xml new file mode 100644 index 00000000..19b07798 --- /dev/null +++ b/stock_location_orderpoint/data/queue_job_channel.xml @@ -0,0 +1,10 @@ + + + + stock_location_orderpoint_auto_replenishment + + + diff --git a/stock_location_orderpoint/data/queue_job_function.xml b/stock_location_orderpoint/data/queue_job_function.xml new file mode 100644 index 00000000..b48490b0 --- /dev/null +++ b/stock_location_orderpoint/data/queue_job_function.xml @@ -0,0 +1,15 @@ + + + + + moves_auto_replenish + + + + diff --git a/stock_location_orderpoint/demo/stock_location.xml b/stock_location_orderpoint/demo/stock_location.xml new file mode 100644 index 00000000..0759857e --- /dev/null +++ b/stock_location_orderpoint/demo/stock_location.xml @@ -0,0 +1,17 @@ + + + + + Replenishment Zone + + + + Replenishment Stock + + + diff --git a/stock_location_orderpoint/demo/stock_location_orderpoint.xml b/stock_location_orderpoint/demo/stock_location_orderpoint.xml new file mode 100644 index 00000000..92289fe8 --- /dev/null +++ b/stock_location_orderpoint/demo/stock_location_orderpoint.xml @@ -0,0 +1,11 @@ + + + + + + + auto + fill_up + + diff --git a/stock_location_orderpoint/demo/stock_picking_type.xml b/stock_location_orderpoint/demo/stock_picking_type.xml new file mode 100644 index 00000000..4994329e --- /dev/null +++ b/stock_location_orderpoint/demo/stock_picking_type.xml @@ -0,0 +1,16 @@ + + + + + Replenishments + internal + + + + SRE + + diff --git a/stock_location_orderpoint/demo/stock_route.xml b/stock_location_orderpoint/demo/stock_route.xml new file mode 100644 index 00000000..1cc471fa --- /dev/null +++ b/stock_location_orderpoint/demo/stock_route.xml @@ -0,0 +1,24 @@ + + + + + Replenishment Route + + + + + Replenishment Rule + + + + + pull + + diff --git a/stock_location_orderpoint/i18n/stock_location_orderpoint.pot b/stock_location_orderpoint/i18n/stock_location_orderpoint.pot new file mode 100644 index 00000000..d0747554 --- /dev/null +++ b/stock_location_orderpoint/i18n/stock_location_orderpoint.pot @@ -0,0 +1,266 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * stock_location_orderpoint +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: stock_location_orderpoint +#: model:ir.model.fields,field_description:stock_location_orderpoint.field_stock_location_orderpoint__active +msgid "Active" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model.fields.selection,name:stock_location_orderpoint.selection__stock_location_orderpoint__trigger__auto +msgid "Auto/realtime" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model.fields,help:stock_location_orderpoint.field_stock_location_orderpoint__trigger +msgid "" +"Auto/realtime orderpoints are triggered on new moves\n" +"Manual orderpoints are triggered via the orderpoints' view\n" +"Scheduled orderpoints are triggered via scheduled actions per location" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model.fields,field_description:stock_location_orderpoint.field_stock_location_orderpoint__company_id +msgid "Company" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model.fields,field_description:stock_location_orderpoint.field_stock_location_orderpoint__create_uid +msgid "Created by" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model.fields,field_description:stock_location_orderpoint.field_stock_location_orderpoint__create_date +msgid "Created on" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model.fields,help:stock_location_orderpoint.field_stock_location_orderpoint__replenish_method +msgid "" +"Defines how the qty to replenish gets computed\n" +"Fill up = The replenishment will be triggered when a move is waiting availability and forecast quantity is negative at the location (i.e. min=0). The replenished quantity will bring back the forecast quantity to 0 (i.e. max=0) but will be limited to what is available at the source location to plan only reservable replenishment moves" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model.fields,field_description:stock_location_orderpoint.field_stock_location__display_name +#: model:ir.model.fields,field_description:stock_location_orderpoint.field_stock_location_orderpoint__display_name +#: model:ir.model.fields,field_description:stock_location_orderpoint.field_stock_move__display_name +#: model:ir.model.fields,field_description:stock_location_orderpoint.field_stock_rule__display_name +msgid "Display Name" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model.fields.selection,name:stock_location_orderpoint.selection__stock_location_orderpoint__replenish_method__fill_up +msgid "Fill up" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model.fields,field_description:stock_location_orderpoint.field_stock_location__id +#: model:ir.model.fields,field_description:stock_location_orderpoint.field_stock_location_orderpoint__id +#: model:ir.model.fields,field_description:stock_location_orderpoint.field_stock_move__id +#: model:ir.model.fields,field_description:stock_location_orderpoint.field_stock_rule__id +msgid "ID" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model,name:stock_location_orderpoint.model_stock_location +msgid "Inventory Locations" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model.fields,field_description:stock_location_orderpoint.field_stock_location____last_update +#: model:ir.model.fields,field_description:stock_location_orderpoint.field_stock_location_orderpoint____last_update +#: model:ir.model.fields,field_description:stock_location_orderpoint.field_stock_move____last_update +#: model:ir.model.fields,field_description:stock_location_orderpoint.field_stock_rule____last_update +msgid "Last Modified on" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model.fields,field_description:stock_location_orderpoint.field_stock_location_orderpoint__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model.fields,field_description:stock_location_orderpoint.field_stock_location_orderpoint__write_date +msgid "Last Updated on" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model.fields,field_description:stock_location_orderpoint.field_stock_location_orderpoint__location_id +msgid "Location" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model.fields,field_description:stock_location_orderpoint.field_stock_location__location_orderpoint_count +msgid "Location Orderpoint Count" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model.fields,field_description:stock_location_orderpoint.field_stock_location__location_orderpoint_ids +msgid "Location Orderpoints" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model.fields,help:stock_location_orderpoint.field_stock_location__location_orderpoint_ids +msgid "" +"Location Orderpoints. Rules that allows this location to be replenished." +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model.fields,field_description:stock_location_orderpoint.field_stock_location_orderpoint__location_src_id +msgid "Location Src" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model.fields.selection,name:stock_location_orderpoint.selection__stock_location_orderpoint__trigger__manual +msgid "Manual" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model.fields,help:stock_location_orderpoint.field_stock_location_orderpoint__group_id +msgid "" +"Moves created through this orderpoint will be put in this procurement group." +" If none is given, the moves generated by stock rules will be grouped into " +"one big picking." +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model.fields,field_description:stock_location_orderpoint.field_stock_location_orderpoint__name +msgid "Name" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model.fields.selection,name:stock_location_orderpoint.selection__stock_location_orderpoint__priority__0 +msgid "Normal" +msgstr "" + +#. module: stock_location_orderpoint +#: model_terms:ir.ui.view,arch_db:stock_location_orderpoint.view_location_form +msgid "Orderpoints" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model.fields,field_description:stock_location_orderpoint.field_stock_location_orderpoint__route_id +msgid "Preferred Route" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model.fields,field_description:stock_location_orderpoint.field_stock_location_orderpoint__priority +msgid "Priority" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model.fields,field_description:stock_location_orderpoint.field_stock_location_orderpoint__group_id +msgid "Procurement Group" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.actions.server,name:stock_location_orderpoint.ir_cron_location_replenishment_ir_actions_server +#: model:ir.cron,cron_name:stock_location_orderpoint.ir_cron_location_replenishment +#: model:ir.cron,name:stock_location_orderpoint.ir_cron_location_replenishment +msgid "Procurement: run location replenishment" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model.fields,field_description:stock_location_orderpoint.field_stock_location_orderpoint__replenish_method +msgid "Replenish Method" +msgstr "" + +#. module: stock_location_orderpoint +#: model:stock.location.route,name:stock_location_orderpoint.stock_route_replenish +msgid "Replenishment Route" +msgstr "" + +#. module: stock_location_orderpoint +#: model:stock.rule,name:stock_location_orderpoint.stock_rule_replenish +msgid "Replenishment Rule" +msgstr "" + +#. module: stock_location_orderpoint +#: model:stock.picking.type,name:stock_location_orderpoint.stock_picking_type_replenish +msgid "Replenishments" +msgstr "" + +#. module: stock_location_orderpoint +#: model_terms:ir.ui.view,arch_db:stock_location_orderpoint.stock_location_orderpoint_form +msgid "Run replenishment" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model.fields.selection,name:stock_location_orderpoint.selection__stock_location_orderpoint__trigger__cron +msgid "Scheduled" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model.fields,field_description:stock_location_orderpoint.field_stock_location_orderpoint__sequence +msgid "Sequence" +msgstr "" + +#. module: stock_location_orderpoint +#: model_terms:ir.ui.view,arch_db:stock_location_orderpoint.view_stock_location_orderpoint_tree_editable +msgid "Stock Location Oderpoints" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.actions.act_window,name:stock_location_orderpoint.action_stock_location_orderpoint +#: model:ir.ui.menu,name:stock_location_orderpoint.menu_stock_location_orderpoint +msgid "Stock Location Orderpoint" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model,name:stock_location_orderpoint.model_stock_move +msgid "Stock Move" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model,name:stock_location_orderpoint.model_stock_rule +msgid "Stock Rule" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model,name:stock_location_orderpoint.model_stock_location_orderpoint +#: model:ir.model.fields,field_description:stock_location_orderpoint.field_stock_move__location_orderpoint_id +#: model:ir.model.fields,field_description:stock_location_orderpoint.field_stock_reservation__location_orderpoint_id +msgid "Stock location orderpoint" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model.constraint,message:stock_location_orderpoint.constraint_stock_location_orderpoint_location_route_unique +msgid "The combination of Company, Location and Route must be unique" +msgstr "" + +#. module: stock_location_orderpoint +#: code:addons/stock_location_orderpoint/models/stock_location_orderpoint.py:0 +#, python-format +msgid "" +"The selected route {} must contain a rule where the Destination Location is " +"{}" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model.fields,field_description:stock_location_orderpoint.field_stock_location_orderpoint__trigger +msgid "Trigger" +msgstr "" + +#. module: stock_location_orderpoint +#: code:addons/stock_location_orderpoint/models/stock_move.py:0 +#, python-format +msgid "Try to replenish quantities for location {} and product {}" +msgstr "" + +#. module: stock_location_orderpoint +#: model:ir.model.fields.selection,name:stock_location_orderpoint.selection__stock_location_orderpoint__priority__1 +msgid "Urgent" +msgstr "" diff --git a/stock_location_orderpoint/models/__init__.py b/stock_location_orderpoint/models/__init__.py new file mode 100644 index 00000000..ed2c3326 --- /dev/null +++ b/stock_location_orderpoint/models/__init__.py @@ -0,0 +1,4 @@ +from . import stock_location_orderpoint +from . import stock_rule +from . import stock_move +from . import stock_location diff --git a/stock_location_orderpoint/models/stock_location.py b/stock_location_orderpoint/models/stock_location.py new file mode 100644 index 00000000..ab7b4d75 --- /dev/null +++ b/stock_location_orderpoint/models/stock_location.py @@ -0,0 +1,42 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import fields, models +from odoo.tools.safe_eval import safe_eval + + +class StockLocation(models.Model): + _inherit = "stock.location" + + location_orderpoint_ids = fields.One2many( + comodel_name="stock.location.orderpoint", + inverse_name="location_id", + string="Location Orderpoints", + help="Location Orderpoints. Rules that allows this location to be replenished.", + ) + location_orderpoint_count = fields.Integer( + compute="_compute_location_orderpoint_count", + ) + + def _compute_location_orderpoint_count(self): + groups = self.env["stock.location.orderpoint"].read_group( + [("location_id", "in", self.ids)], ["location_id"], ["location_id"] + ) + result = { + data["location_id"][0]: (data["location_id_count"]) for data in groups + } + for location in self: + location.location_orderpoint_count = result.get(location.id, 0) + + def action_open_location_orderpoints(self): + action = self.env["ir.actions.act_window"]._for_xml_id( + "stock_location_orderpoint.action_stock_location_orderpoint" + ) + action["domain"] = [("location_id", "in", self.ids)] + if len(self.ids) == 1: + if "context" in action: + context = safe_eval(action["context"]) + context.update({"default_location_id": self.id}) + action["context"] = str(context) + else: + action["context"] = str({"default_location_id": self.id}) + return action diff --git a/stock_location_orderpoint/models/stock_location_orderpoint.py b/stock_location_orderpoint/models/stock_location_orderpoint.py new file mode 100644 index 00000000..64501a82 --- /dev/null +++ b/stock_location_orderpoint/models/stock_location_orderpoint.py @@ -0,0 +1,414 @@ +# Copyright 2023 Michael Tietz (MT Software) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from collections import defaultdict +from copy import copy + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError +from odoo.osv import expression +from odoo.tools import float_compare, split_every + +from odoo.addons.stock.models.stock_move import PROCUREMENT_PRIORITIES + + +class StockLocationOrderpoint(models.Model): + _name = "stock.location.orderpoint" + _description = "Stock location orderpoint" + _order = "priority desc, sequence" + _check_company_auto = True + + name = fields.Char( + copy=False, + required=True, + readonly=True, + default=lambda self: self.env["ir.sequence"].next_by_code( + "stock.location.orderpoint" + ), + ) + company_id = fields.Many2one( + "res.company", + "Company", + required=True, + default=lambda self: self.env.company, + ) + location_id = fields.Many2one( + "stock.location", + "Location", + ondelete="cascade", + required=True, + check_company=True, + ) + trigger = fields.Selection( + [("auto", "Auto/realtime"), ("manual", "Manual"), ("cron", "Scheduled")], + default="auto", + required=True, + help="Auto/realtime orderpoints are triggered on new moves\n" + "Manual orderpoints are triggered via the orderpoints' view\n" + "Scheduled orderpoints are triggered via scheduled actions per location", + ) + replenish_method = fields.Selection( + [("fill_up", "Fill up")], + default="fill_up", + required=True, + help="Defines how the qty to replenish gets computed\n" + "Fill up = The replenishment will be triggered when a move is waiting availability " + "and forecast quantity is negative at the location (i.e. min=0). " + "The replenished quantity will bring back the forecast quantity to 0 (i.e. max=0) " + "but will be limited to what is available at the source location " + "to plan only reservable replenishment moves", + ) + sequence = fields.Integer(default=10) + route_id = fields.Many2one( + "stock.route", + string="Preferred Route", + domain="[('rule_ids.location_dest_id', 'in', [location_id])]", + ) + group_id = fields.Many2one( + "procurement.group", + "Procurement Group", + copy=False, + help="Moves created through this orderpoint " + "will be put in this procurement group. " + "If none is given, the moves generated by stock rules " + "will be grouped into one big picking.", + ) + location_src_id = fields.Many2one( + "stock.location", compute="_compute_location_src_id", store=True + ) + active = fields.Boolean(default=True) + priority = fields.Selection( + PROCUREMENT_PRIORITIES, + default="0", + ) + + _sql_constraints = [ + ( + "location_route_unique", + "unique(location_id, route_id, company_id, replenish_method)", + "The combination of Company, Location, Route and Replenish method must be unique", + ) + ] + + @api.constrains("location_id", "route_id") + def _check_location_id_route_id(self): + for orderpoint in self: + if ( + not orderpoint.route_id + or orderpoint.location_id + in orderpoint.route_id.rule_ids.location_dest_id + ): + continue + raise ValidationError( + _( + "The selected route %(route_name)s must contain " + "a rule where the Destination Location is %(location_name)s" + ) + % { + "route_name": orderpoint.route_id.display_name, + "location_name": orderpoint.location_id.display_name, + } + ) + + @api.depends("location_id", "route_id") + def _compute_location_src_id(self): + for orderpoint in self: + location = False + if orderpoint.location_id and orderpoint.route_id: + location = orderpoint.location_id._get_source_location_from_route( + orderpoint.route_id, + "make_to_stock", + ) + orderpoint.location_src_id = location + + def _prepare_procurement(self, product, qty, date_planned, proc_vals): + self.ensure_one() + proc_vals = copy(proc_vals) + proc_vals.update( + { + "date_planned": date_planned or fields.Datetime.now(), + } + ) + return self.env["procurement.group"].Procurement( + product, + qty, + product.uom_id, + self.location_id, + self.name, + self.name, + self.company_id, + proc_vals, + ) + + def _prepare_procurement_values(self): + self.ensure_one() + return { + "route_ids": self.route_id, + "date_deadline": False, + "warehouse_id": self.location_id.warehouse_id, + "group_id": self.group_id, + "priority": self.priority or "0", + "location_orderpoint_id": self.id, + } + + def _get_waiting_move_domain(self): + """ + Returns a domain which selects waiting moves + which should be replenished by given orderpoints + """ + domain = [ + ("state", "in", ["confirmed", "partially_available"]), + ("move_orig_ids", "=", False), + ("procure_method", "=", "make_to_stock"), + ] + location_domains = [] + for orderpoint in self: + location_domains.append( + [ + ("location_id", "child_of", orderpoint.location_id.ids), + "!", + ("location_dest_id", "child_of", orderpoint.location_id.ids), + ] + ) + if location_domains: + domain = expression.AND([domain, expression.OR(location_domains)]) + return domain + + def _find_potential_moves_to_replenish_by_location(self, products=False): + """Return a dictionary of products per location that potentially require a replenishment + based on the fact there are moves not reserved for those products. + This reduces the list of products for which the quantity will be computed""" + domain = self._get_waiting_move_domain() + if products: + domain = expression.AND([domain, [("product_id", "in", products.ids)]]) + moves_grouped = self.env["stock.move"].read_group( + domain, + ["ids:array_agg(id)", "location_id"], + "location_id", + ) + return { + self.env["stock.location"] + .browse(res["location_id"][0]): self.env["stock.move"] + .browse(res["ids"]) + for res in moves_grouped + } + + def _sort_orderpoints(self): + return self.sorted() + + @api.model + def _compute_quantities_dict(self, locations, products): + qties = {} + for location in locations: + qties_on_location = qties.setdefault(location, {}) + products = products.with_context(location=location.id) + for product_id, qties_dict in products._compute_quantities_dict( + None, None, None + ).items(): + product = products.browse(product_id) + qties_on_location[product] = qties_dict + return qties + + def _get_qty_to_replenish( + self, product, qties_on_locations, qty_already_replenished=0 + ): + """ + Returns a qty to replenish for a given orderpoint and product + """ + self.ensure_one() + product.ensure_one() + + if self.replenish_method == "fill_up": + return self._get_qty_to_replenish_fill_up( + product, qties_on_locations, qty_already_replenished + ) + return 0 + + def _get_qty_to_replenish_fill_up( + self, product, qties_on_locations, qty_already_replenished=0 + ): + if not self.location_src_id: + return 0 + + qties_on_dest = qties_on_locations[self.location_id][product] + virtual_available_on_dest = qties_on_dest["virtual_available"] + if ( + float_compare( + virtual_available_on_dest, 0, precision_rounding=product.uom_id.rounding + ) + >= 0 + ): + return 0 + + virtual_available_on_dest = abs(virtual_available_on_dest) + qties_on_src = qties_on_locations[self.location_src_id][product] + virtual_available_on_src = ( + qties_on_src["virtual_available"] - qties_on_src["incoming_qty"] + ) + if ( + float_compare( + virtual_available_on_src, + 0, + precision_rounding=product.uom_id.rounding, + ) + <= 0 + ): + return 0 + + qty_to_replenish = virtual_available_on_dest - qty_already_replenished + return min(qty_to_replenish, virtual_available_on_src) + + def _get_qties_to_replenish(self, moves_by_location): + products = set() + for moves in moves_by_location.values(): + products.update(moves.product_id.ids) + qties_on_locations = self._compute_quantities_dict( + (self.location_id | self.location_src_id), + self.env["product.product"].browse(products), + ) + qties_replenished = defaultdict(lambda: defaultdict(lambda: 0)) + qties_to_replenish = defaultdict(list) + for orderpoint in self: + if orderpoint.location_id not in moves_by_location: + continue + + for product in moves_by_location[orderpoint.location_id].product_id: + qties_replenished_for_location = qties_replenished[ + orderpoint.location_id + ] + qty_to_replenish = orderpoint._get_qty_to_replenish( + product, + qties_on_locations, + qties_replenished_for_location[product], + ) + if ( + float_compare( + qty_to_replenish, 0, precision_rounding=product.uom_id.rounding + ) + > 0 + ): + qties_to_replenish[orderpoint].append((product, qty_to_replenish)) + qties_replenished_for_location[product] += qty_to_replenish + return qties_to_replenish + + def __prepare_procurements(self, moves_by_location): + qties_to_replenish_by_orderpoint = self._get_qties_to_replenish( + moves_by_location + ) + procurements = [] + for orderpoint, qties_to_replenish in qties_to_replenish_by_orderpoint.items(): + proc_vals = orderpoint._prepare_procurement_values() + for product, qty in qties_to_replenish: + date_planned = moves_by_location[ + orderpoint.location_id + ]._get_location_orderpoint_replenishment_date(product) + procurements.append( + orderpoint._prepare_procurement( + product, qty, date_planned, proc_vals + ) + ) + return procurements + + def _prepare_procurements(self, products=False): + moves_by_location = self._find_potential_moves_to_replenish_by_location( + products + ) + return self._sort_orderpoints().__prepare_procurements(moves_by_location) + + def run_replenishment(self, products=False): + """Run the replenishment for all potential products or only a selection""" + procurements = self._prepare_procurements(products) + if not procurements: + return + self.env["procurement.group"].with_context(from_orderpoint=True).run( + procurements, raise_user_error=False + ) + self._after_replenishment() + + def _prepare_to_assign_replenishment_move_domain(self): + """Returns a domain which selects moves created by a replenishment""" + domain = [ + ("state", "in", ["confirmed", "partially_available"]), + ("procure_method", "=", "make_to_stock"), + ("location_orderpoint_id", "in", self.ids), + ] + return domain + + def _assign_replenishment_moves(self): + """Assigns moves created by the orderpoints""" + domain = self._prepare_to_assign_replenishment_move_domain() + moves_to_assign = self.env["stock.move"].search( + domain, order="priority desc, date asc, id asc" + ) + for moves_chunk in split_every(100, moves_to_assign.ids): + self.env["stock.move"].browse(moves_chunk)._action_assign() + + def _after_replenishment(self): + self._assign_replenishment_moves() + + @api.model + def _prepare_orderpoint_domain_location(self, location_ids, location_field=False): + """ + Returns the domain part of the location selection of _get_orderpoints + :param list int location_ids: list of stock.location ids + :param str location_field: location_id or location_src_id + To create the domain searching the specific location field + """ + ids = location_ids + if not isinstance(ids, list): + ids = ids.ids + + location_field = not location_field and "location_id" or location_field + return [(location_field, "parent_of", ids)] + + def _prepare_orderpoint_domain( + self, trigger, locations=False, location_field=False + ): + """Returns the domain for _get_orderpoints""" + domain = [("trigger", "=", trigger)] + if locations: + domain = expression.AND( + [ + domain, + self._prepare_orderpoint_domain_location(locations, location_field), + ] + ) + return domain + + @api.model + def _get_orderpoints(self, trigger, locations=False, location_field=False): + """Returns orderpoints selected by trigger, locations and location_field""" + domain = self._prepare_orderpoint_domain(trigger, locations, location_field) + return self.search(domain) + + def _is_location_parent_of(self, location, location_field): + """ + Checks if one location of the given orderpoints + is a parent of the given location + + :param location: browse record of stock.location + :param location_field: should be location_id or location_src_id + orderpoints location field to check against + """ + for parent_location in getattr(self, location_field): + if location.parent_path.startswith(parent_location.parent_path): + return True + + @api.model + def run_auto_replenishment(self, products, locations, location_field=False): + """ + Run the replenishment for all given products + Selects the right orderpoints by locations and location_field + + :param products: browse record list of product.product + :param locations: browse record list of stock.location + :param location_field: should be location_id or location_src_id + """ + if not locations or not products: + return + self = self._get_orderpoints("auto", locations, location_field) + self.run_replenishment(products) + + @api.model + def run_cron_replenishment(self, location_ids=False): + self = self._get_orderpoints("cron", location_ids) + self.run_replenishment() diff --git a/stock_location_orderpoint/models/stock_move.py b/stock_location_orderpoint/models/stock_move.py new file mode 100644 index 00000000..a55fc5d1 --- /dev/null +++ b/stock_location_orderpoint/models/stock_move.py @@ -0,0 +1,112 @@ +# Copyright 2023 Michael Tietz (MT Software) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from collections import defaultdict + +from odoo import _, fields, models +from odoo.tools import ormcache + +from odoo.addons.queue_job.job import identity_exact + + +class StockMove(models.Model): + _inherit = "stock.move" + + location_orderpoint_id = fields.Many2one( + "stock.location.orderpoint", "Stock location orderpoint", index=True + ) + + @ormcache("self", "product") + def _get_location_orderpoint_replenishment_date(self, product): + return min( + self.filtered(lambda move: move.product_id == product).mapped("date") + ) + + def _prepare_auto_replenishment_for_waiting_moves(self): + self._prepare_auto_replenishment( + "location_id", + self.env["stock.location.orderpoint"]._get_waiting_move_domain(), + ) + + def _prepare_auto_replenishment_for_done_moves(self): + self._prepare_auto_replenishment( + "location_dest_id", + [ + ("move_dest_ids", "=", False), + ("procure_method", "=", "make_to_stock"), + ("state", "=", "done"), + ], + ) + + def _prepare_auto_replenishment(self, location_field, domain): + if self.env.context.get("skip_auto_replenishment"): + return + locations_products = defaultdict(set) + location_ids = set() + product_obj = self.env["product.product"] + for move in self.filtered_domain(domain): + location = getattr(move, location_field) + locations_products[location].add(move.product_id.id) + location_ids.add(location.id) + # Map the the move's location field + # to the correspoding stock.location.orderpoint's location field + location_field = ( + location_field == "location_id" and location_field or "location_src_id" + ) + orderpoints = self.env["stock.location.orderpoint"]._get_orderpoints( + "auto", list(location_ids), location_field + ) + for location, products in locations_products.items(): + if not orderpoints._is_location_parent_of(location, location_field): + continue + for product in product_obj.browse(products): + self._enqueue_auto_replenishment( + location, product, location_field + ).delay() + + def _enqueue_auto_replenishment( + self, location, product, location_field, **job_options + ): + """Enqueue a job stock.location.orderpoint.moves_auto_replenishment() + + Can be extended to pass different options to the job (priority, ...). + The usage of `.setdefault` allows to override the options set by default. + + return: a `Job` instance + """ + job_options = job_options.copy() + job_options.setdefault( + "description", + _( + "Try to replenish quantities %(in_or_out)s location %(location_name)s " + "for product %(product_name)s" + ) + % { + "in_or_out": location_field == "location_id" and _("in") or _("from"), + "location_name": location.display_name, + "product_name": product.display_name, + }, + ) + # do not enqueue 2 jobs for the same location and product set + job_options.setdefault("identity_key", identity_exact) + delayable = self.env["stock.location.orderpoint"].delayable(**job_options) + job = delayable.run_auto_replenishment( + product, + location, + location_field, + ) + return job + + def _action_assign(self, *args, **kwargs): + """This triggers the replenishment for new moves which are waiting for stock""" + res = super()._action_assign(*args, **kwargs) + self._prepare_auto_replenishment_for_waiting_moves() + return res + + def _action_done(self, *args, **kwargs): + """ + This triggers the replenishment for waiting moves + when the stock increases on a source location of an orderpoint + """ + moves = super()._action_done(*args, **kwargs) + moves._prepare_auto_replenishment_for_done_moves() + return moves diff --git a/stock_location_orderpoint/models/stock_rule.py b/stock_location_orderpoint/models/stock_rule.py new file mode 100644 index 00000000..2d38822e --- /dev/null +++ b/stock_location_orderpoint/models/stock_rule.py @@ -0,0 +1,11 @@ +# Copyright 2023 Michael Tietz (MT Software) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import models + + +class StockRule(models.Model): + _inherit = "stock.rule" + + def _get_custom_move_fields(self): + res = super()._get_custom_move_fields() + return res + ["location_orderpoint_id"] diff --git a/stock_location_orderpoint/readme/CONFIGURE.rst b/stock_location_orderpoint/readme/CONFIGURE.rst new file mode 100644 index 00000000..e49d8f43 --- /dev/null +++ b/stock_location_orderpoint/readme/CONFIGURE.rst @@ -0,0 +1,51 @@ +First configuration +=================== + +#. In order to replenish your stock location from another one, you first need + to set the multi locations configuration. +#. So, go to Inventory > Configuration > Settings > Warehouse +#. Check the 'Storage Locations' box. +#. As you should be able to configure a dedicated route to replenishment, you + should also activate, in the same menu, the 'Multi-Step Routes' box. + +Locations configuration +======================= + +#. Identify the location you want to apply a replenishment rule (e.g.: WH/Stock) +#. Create (if not exists) a new location for replenishment under Warehouse view (e.g.: WH) + location as we want to get the stock in replenishment taken into account of + our product stock total quantity. + +Route Configuration +=================== + +#. You should configure at least a route with a rule that: + + * Pull from the Replenishment stock location. + * For the stock location you want to (e.g.: WH/Stock) + +Location Orderpoint configuration +================================= + +#. Go either by the stock location you want to replenish and click on 'Orderpoints' + or go to Inventory > Configuration > Warehouse > Stock Location Orderpoint +#. Click on 'Create' +#. Set a sequence +#. Choose if the rule will be applied: + + * Automatically (Auto/realtime): at each stock movement on the stock location, the rule will be + evaluated. + * Manually (Manual): If set, an action 'Run Replenishment' will be displayed on the rule + and allow to run it manually. + * by cron (Scheduled): A cron job will trigger the replenishment rules of this kind. +#. Choose a replenish method: + + * Fill up: The replenishment will be triggered when a move is waiting availability + and forecast quantity is negative at the location (i.e. min=0). The replenished quantity will + bring back the forecast quantity to 0 (i.e. max=0) but will be limited to what is available at + the source location to plan only reservable replenishment moves. +#. Choose the location to replenish +#. Choose the route to use to replenish. The source location will be computed automatically based on + the route value. +#. Define a procurement group if you want to group some movements together. +#. Define a priority for the created moves. diff --git a/stock_location_orderpoint/readme/CONTRIBUTORS.rst b/stock_location_orderpoint/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000..866de0e8 --- /dev/null +++ b/stock_location_orderpoint/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* Michael Tietz (MT Software) +* Jacques-Etienne Baudoux (BCIM) +* Denis Roussel diff --git a/stock_location_orderpoint/readme/DESCRIPTION.rst b/stock_location_orderpoint/readme/DESCRIPTION.rst new file mode 100644 index 00000000..5289309a --- /dev/null +++ b/stock_location_orderpoint/readme/DESCRIPTION.rst @@ -0,0 +1,2 @@ +Declare orderpoint on a location allowing to replenish any product with the same criteria. +This is for an internal warehouse replenishment currently not compatible with the purchase buy route. diff --git a/stock_location_orderpoint/security/ir.model.access.csv b/stock_location_orderpoint/security/ir.model.access.csv new file mode 100644 index 00000000..e01e438a --- /dev/null +++ b/stock_location_orderpoint/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_stock_location_orderpoint_manager,stock.location.orderpoint - manager,model_stock_location_orderpoint,stock.group_stock_manager,1,1,1,1 +access_stock_location_orderpoint_user,stock.location.orderpoint - user,model_stock_location_orderpoint,stock.group_stock_user,1,0,0,0 diff --git a/stock_location_orderpoint/static/description/icon.png b/stock_location_orderpoint/static/description/icon.png new file mode 100644 index 00000000..3a0328b5 Binary files /dev/null and b/stock_location_orderpoint/static/description/icon.png differ diff --git a/stock_location_orderpoint/static/description/index.html b/stock_location_orderpoint/static/description/index.html new file mode 100644 index 00000000..7623b565 --- /dev/null +++ b/stock_location_orderpoint/static/description/index.html @@ -0,0 +1,508 @@ + + + + + + +stock_location_orderpoint + + + +
+

stock_location_orderpoint

+ + +

Beta License: AGPL-3 OCA/stock-logistics-orderpoint Translate me on Weblate Try me on Runboat

+

Declare orderpoint on a location allowing to replenish any product with the same criteria. +This is for an internal warehouse replenishment currently not compatible with the purchase buy route.

+

Table of contents

+ + +
+

First configuration

+
    +
  1. In order to replenish your stock location from another one, you first need +to set the multi locations configuration.
  2. +
  3. So, go to Inventory > Configuration > Settings > Warehouse
  4. +
  5. Check the ‘Storage Locations’ box.
  6. +
  7. As you should be able to configure a dedicated route to replenishment, you +should also activate, in the same menu, the ‘Multi-Step Routes’ box.
  8. +
+
+
+

Locations configuration

+
    +
  1. Identify the location you want to apply a replenishment rule (e.g.: WH/Stock)
  2. +
  3. Create (if not exists) a new location for replenishment under Warehouse view (e.g.: WH) +location as we want to get the stock in replenishment taken into account of +our product stock total quantity.
  4. +
+
+
+

Route Configuration

+
    +
  1. You should configure at least a route with a rule that:

    +
    +
      +
    • Pull from the Replenishment stock location.
    • +
    • For the stock location you want to (e.g.: WH/Stock)
    • +
    +
    +
  2. +
+
+
+

Location Orderpoint configuration

+
    +
  1. Go either by the stock location you want to replenish and click on ‘Orderpoints’ +or go to Inventory > Configuration > Settings > Warehouse > Stock Location Orderpoint

    +
  2. +
  3. Click on ‘Create’

    +
  4. +
  5. Set a sequence

    +
  6. +
  7. Choose if the rule will be applied:

    +
    +
      +
    • Automatically (Auto/realtime): at each stock movement on the stock location, the rule will be +evaluated.
    • +
    • Manually (Manual): If set, an action ‘Run Replenishment’ will be displayed on the rule +and allow to run it manually.
    • +
    • by cron (Scheduled): A cron job will trigger the replenishment rules of this kind.
    • +
    +
    +
  8. +
  9. Choose a replenish method:

    +
    +
      +
    • Fill up: The replenishment will be triggered when a move is waiting availability +and forecast quantity is negative at the location (i.e. min=0). The replenished quantity will +bring back the forecast quantity to 0 (i.e. max=0) but will be limited to what is available at +the source location to plan only reservable replenishment moves.
    • +
    +
    +
  10. +
  11. Choose the location to replenish

    +
  12. +
  13. Choose the route to use to replenish. The source location will be computed automatically based on +the route value.

    +
  14. +
  15. Define a procurement group if you want to group some movements together.

    +
  16. +
  17. Define a priority for the created moves.

    +
  18. +
+
+
+

Bug Tracker

+

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

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • MT Software
  • +
  • BCIM
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

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

+

Current maintainer:

+

mt-software-de

+

This module is part of the OCA/stock-logistics-orderpoint project on GitHub.

+

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

+
+
+
+ + diff --git a/stock_location_orderpoint/tests/__init__.py b/stock_location_orderpoint/tests/__init__.py new file mode 100644 index 00000000..3fee42fb --- /dev/null +++ b/stock_location_orderpoint/tests/__init__.py @@ -0,0 +1 @@ +from . import test_location_orderpoint diff --git a/stock_location_orderpoint/tests/common.py b/stock_location_orderpoint/tests/common.py new file mode 100644 index 00000000..18a36be0 --- /dev/null +++ b/stock_location_orderpoint/tests/common.py @@ -0,0 +1,162 @@ +# Copyright 2023 Michael Tietz (MT Software) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from datetime import datetime + +from odoo.tests.common import Form, TransactionCase + + +class TestLocationOrderpointCommon(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.uom_unit = cls.env.ref("uom.product_uom_unit") + cls.product = cls.env["product.product"].create( + { + "name": "Desk Combination", + "type": "product", + } + ) + cls.warehouse = cls.env.ref("stock.warehouse0") + cls.location_dest = cls.warehouse.lot_stock_id + cls.env["stock.location.orderpoint"].search([]).write({"active": False}) + + def _create_picking_type(self, name, location_src, location_dest, warehouse): + return self.env["stock.picking.type"].create( + { + "name": name, + "sequence_code": f"INT/REPL/{location_src.name}", + "default_location_src_id": location_src.id, + "default_location_dest_id": location_dest.id, + "code": "internal", + "warehouse_id": warehouse.id, + "show_operations": True, + } + ) + + def _create_route(self, name, picking_type, location_src, location_dest, warehouse): + return self.env["stock.route"].create( + { + "name": name, + "sequence": 0, + "rule_ids": [ + ( + 0, + 0, + { + "name": name, + "action": "pull", + "location_dest_id": location_dest.id, + "location_src_id": location_src.id, + "picking_type_id": picking_type.id, + "warehouse_id": warehouse.id, + }, + ) + ], + "warehouse_ids": [(6, 0, warehouse.ids)], + } + ) + + def _create_picking_type_route_rule(self, location): + name = "Internal Replenishment" + name = f"{name}-{location.name}" + picking_type = self._create_picking_type( + name, location, self.location_dest, self.warehouse + ) + route = self._create_route( + name, picking_type, location, self.location_dest, self.warehouse + ) + return picking_type, route + + def _create_orderpoint(self, **kwargs): + location_orderpoint = Form(self.env["stock.location.orderpoint"]) + location_orderpoint.location_id = self.location_dest + for field, value in kwargs.items(): + setattr(location_orderpoint, field, value) + return location_orderpoint.save() + + def _create_move(self, name, qty, location, location_dest): + move = self.env["stock.move"].create( + { + "name": name, + "date": datetime.today(), + "product_id": self.product.id, + "product_uom": self.uom_unit.id, + "product_uom_qty": qty, + "location_id": location.id, + "location_dest_id": location_dest.id, + } + ) + move._write({"create_date": datetime.now()}) + move._action_confirm() + return move + + def _create_scrap_move(self, qty, location): + scrap = self.env["stock.location"].search( + [("scrap_location", "=", True)], limit=1 + ) + move = self._create_move("Scrap", qty, location, scrap) + move.move_line_ids.write({"qty_done": qty}) + move._action_done() + return move + + def _create_incoming_move(self, qty, location): + move = self._create_move( + "Receive", qty, self.env.ref("stock.stock_location_suppliers"), location + ) + move.move_line_ids.write({"qty_done": qty}) + move._action_done() + return move + + def _create_outgoing_move(self, qty, location=None): + move = self._create_move( + "Delivery", + qty, + location or self.location_dest, + self.env.ref("stock.stock_location_customers"), + ) + move._action_assign() + return move + + def _create_quants(self, product, location, qty): + self.env["stock.quant"].create( + { + "product_id": product.id, + "location_id": location.id, + "quantity": qty, + } + ) + + def _run_replenishment(self, orderpoints): + self.product.invalidate_recordset() + orderpoints.run_replenishment() + + def _get_replenishment_move(self, orderpoints): + return self.env["stock.move"].search( + [ + ("origin", "in", orderpoints.mapped("name")), + ("product_id", "=", self.product.id), + ("state", "!=", "cancel"), + ] + ) + + def _check_replenishment_move(self, move, qty, orderpoint): + self.assertEqual(move.rule_id, orderpoint.route_id.rule_ids) + self.assertEqual(move.location_orderpoint_id, orderpoint) + self.assertEqual(move.product_qty, qty) + self.assertEqual(move.location_id, orderpoint.location_src_id) + self.assertEqual(move.location_dest_id, orderpoint.location_id) + self.assertEqual(move.state, "assigned") + self.assertEqual(move.priority, orderpoint.priority) + + def _create_location(self, name): + return self.env["stock.location"].create( + {"name": name, "location_id": self.location_dest.location_id.id} + ) + + def _create_orderpoint_complete(self, location_name, **kwargs): + location = self._create_location(location_name) + picking_type, route = self._create_picking_type_route_rule(location) + values = kwargs or {} + values.update({"route_id": route}) + orderpoint = self._create_orderpoint(**values) + return orderpoint, location diff --git a/stock_location_orderpoint/tests/test_location_orderpoint.py b/stock_location_orderpoint/tests/test_location_orderpoint.py new file mode 100644 index 00000000..3bceff91 --- /dev/null +++ b/stock_location_orderpoint/tests/test_location_orderpoint.py @@ -0,0 +1,193 @@ +# Copyright 2023 Michael Tietz (MT Software) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from psycopg2 import IntegrityError + +from odoo.exceptions import ValidationError +from odoo.tools import mute_logger + +from odoo.addons.queue_job.job import identity_exact +from odoo.addons.queue_job.tests.common import trap_jobs + +from .common import TestLocationOrderpointCommon + + +class TestLocationOrderpoint(TestLocationOrderpointCommon): + def test_manual_replenishment(self): + orderpoint, location_src = self._create_orderpoint_complete( + "Stock2", trigger="manual" + ) + orderpoint2, location_src2 = self._create_orderpoint_complete( + "Stock2.2", trigger="manual" + ) + + self.assertEqual(orderpoint.location_src_id, location_src) + move = self._create_outgoing_move(12) + move = self._create_outgoing_move(1) + self.assertEqual(move.state, "confirmed") + + orderpoints = orderpoint | orderpoint2 + self._run_replenishment(orderpoints) + + replenish_move = self._get_replenishment_move(orderpoints) + self.assertFalse(replenish_move) + + self._create_quants(self.product, location_src, 12) + + self._run_replenishment(orderpoints) + replenish_move = self._get_replenishment_move(orderpoints) + self._check_replenishment_move(replenish_move, 12, orderpoint) + + replenish_move._action_cancel() + + self._create_quants(self.product, location_src2, 12) + self._run_replenishment(orderpoints) + + replenish_moves = self._get_replenishment_move(orderpoints) + self.assertEqual(len(replenish_moves), 2) + self.assertEqual(sum(replenish_moves.mapped("product_qty")), 13) + + move = replenish_moves.filtered( + lambda _move: _move.rule_id == orderpoint.route_id.rule_ids + ) + self._check_replenishment_move(move, 12, orderpoint) + + move = replenish_moves - move + self._check_replenishment_move(move, 1, orderpoint2) + + def test_check_unique(self): + orderpoint, location_src = self._create_orderpoint_complete("Stock2") + with mute_logger("odoo.sql_db"): + with self.assertRaises(IntegrityError): + self._create_orderpoint(route_id=orderpoint.route_id) + + def test_check_constrains(self): + with self.assertRaises(ValidationError): + self._create_orderpoint(route_id=self.warehouse.delivery_route_id) + + def test_cron_replenishment(self): + cron = self.env.ref("stock_location_orderpoint.ir_cron_location_replenishment") + orderpoint, location_src = self._create_orderpoint_complete( + "Stock2", trigger="cron" + ) + self._create_outgoing_move(12) + + self.product.invalidate_recordset() + cron.method_direct_trigger() + + replenish_move = self._get_replenishment_move(orderpoint) + self.assertFalse(replenish_move) + + self._create_quants(self.product, location_src, 12) + + self.product.invalidate_recordset() + cron.method_direct_trigger() + + replenish_move = self._get_replenishment_move(orderpoint) + self._check_replenishment_move(replenish_move, 12, orderpoint) + + def test_auto_replenishment(self): + job_func = self.env["stock.location.orderpoint"].run_auto_replenishment + move_qty = 12 + with trap_jobs() as trap: + move = self._create_outgoing_move(move_qty) + trap.assert_jobs_count(0, only=job_func) + trap.perform_enqueued_jobs() + replenish_move = self.env["stock.move"].search( + [ + ("product_id", "=", move.product_id.id), + ("location_dest_id", "=", move.location_id.id), + ] + ) + self.assertFalse(replenish_move) + + orderpoint, location_src = self._create_orderpoint_complete( + "Stock2", trigger="auto" + ) + with trap_jobs() as trap: + move = self._create_outgoing_move(move_qty) + trap.assert_jobs_count(1, only=job_func) + trap.assert_enqueued_job( + orderpoint.browse([]).run_auto_replenishment, + args=(move.product_id, move.location_id, "location_id"), + kwargs={}, + properties=dict( + identity_key=identity_exact, + ), + ) + self.product.invalidate_recordset() + trap.perform_enqueued_jobs() + replenish_move = self._get_replenishment_move(orderpoint) + self.assertFalse(replenish_move) + + with trap_jobs() as trap: + move = self._create_incoming_move(move_qty, location_src) + trap.assert_jobs_count(1, only=job_func) + trap.assert_enqueued_job( + orderpoint.browse([]).run_auto_replenishment, + args=(move.product_id, move.location_dest_id, "location_src_id"), + kwargs={}, + properties=dict( + identity_key=identity_exact, + ), + ) + self.product.invalidate_recordset() + trap.perform_enqueued_jobs() + replenish_move = self._get_replenishment_move(orderpoint) + self._check_replenishment_move(replenish_move, move_qty, orderpoint) + + # Create a second incoming move so that the qty_available would be 24 + move = self._create_incoming_move(move_qty, location_src) + with trap_jobs() as trap: + move = self._create_outgoing_move(move_qty) + trap.assert_jobs_count(1, only=job_func) + trap.assert_enqueued_job( + orderpoint.browse([]).run_auto_replenishment, + args=(move.product_id, move.location_id, "location_id"), + kwargs={}, + properties=dict( + identity_key=identity_exact, + ), + ) + self.product.invalidate_recordset() + trap.perform_enqueued_jobs() + replenish_move_new = self._get_replenishment_move(orderpoint) + self.assertEqual(replenish_move, replenish_move_new) + self._check_replenishment_move(replenish_move, move_qty * 2, orderpoint) + + def test_auto_no_replenishment(self): + """ + Create a stock move that should not create a replenishment: + - A move from a new stock location 'WH/Stock 2' to Scrap + """ + job_func = self.env["stock.location.orderpoint"].run_auto_replenishment + with trap_jobs() as trap: + new_location = self.env["stock.location"].create( + { + "name": "Other Stock", + "location_id": self.location_dest.location_id.id, + } + ) + _, _ = self._create_orderpoint_complete("Stock2", trigger="auto") + self.location_dest = new_location + self._create_quants(self.product, self.location_dest, 10.0) + move = self._create_scrap_move(10.0, self.location_dest) + trap.assert_jobs_count(0, only=job_func) + trap.perform_enqueued_jobs() + replenish_move = self.env["stock.move"].search( + [ + ("product_id", "=", move.product_id.id), + ("location_dest_id", "=", move.location_id.id), + ] + ) + self.assertFalse(replenish_move) + + def test_orderpoint_count(self): + """ + One orderpoint has already been created in demo data. + Check after each creation that count is increasing. + """ + _, _ = self._create_orderpoint_complete("Stock2", trigger="cron") + self.assertEqual(1, self.location_dest.location_orderpoint_count) + _, _ = self._create_orderpoint_complete("Stock3", trigger="cron") + self.assertEqual(2, self.location_dest.location_orderpoint_count) diff --git a/stock_location_orderpoint/views/menu.xml b/stock_location_orderpoint/views/menu.xml new file mode 100644 index 00000000..627266a1 --- /dev/null +++ b/stock_location_orderpoint/views/menu.xml @@ -0,0 +1,15 @@ + + + Stock Location Orderpoint + ir.actions.act_window + stock.location.orderpoint + tree,form + + + diff --git a/stock_location_orderpoint/views/stock_location.xml b/stock_location_orderpoint/views/stock_location.xml new file mode 100644 index 00000000..5c2ebd98 --- /dev/null +++ b/stock_location_orderpoint/views/stock_location.xml @@ -0,0 +1,28 @@ + + + + + stock.location.form (in stock_location_orderpoint) + stock.location + + +
+ +
+
+
+
diff --git a/stock_location_orderpoint/views/stock_location_orderpoint_views.xml b/stock_location_orderpoint/views/stock_location_orderpoint_views.xml new file mode 100644 index 00000000..5502c94a --- /dev/null +++ b/stock_location_orderpoint/views/stock_location_orderpoint_views.xml @@ -0,0 +1,86 @@ + + + + stock.location.orderpoint.tree.editable + stock.location.orderpoint + + + + + + + + + + + + + + + + + stock.location.orderpoint.form + stock.location.orderpoint + +
+ +
+
+ +
+
+ + + + + + + + + + + + + + + +
+ +
+
+ + stock.location.orderpoint.search + stock.location.orderpoint + + + + + + + + + + + + +