diff options
| author | Arnold Moyaux <arm@odoo.com> | 2020-03-17 13:47:15 +0000 |
|---|---|---|
| committer | Arnold Moyaux <arm@odoo.com> | 2020-04-22 07:52:45 +0000 |
| commit | 943da6df0f1d39e8e10869b25c5ba8cc13a2aa54 (patch) | |
| tree | a9bcb2724d00e3fbc13d7feb6635e46279ae2c83 /addons | |
| parent | b6fe6de9e9638a2d7db2861e976a7aa720d947f1 (diff) | |
[IMP] stock, purchase_stock, mrp: Replenishment Report
New report to easily order missing products in each warehouse.
Orderpoint have a new type: 'manual'. Manual orderpoint are not
trigger by the scheduler but the user could create replenishment
from the report directly. The report use the forecast report in
order to detect missing quantity for today (it does not search
depending the lead time for each product warehouse due to lake
of performance). It compares missing quantity with incoming RFQ
and other ongoing orderpoint. If it still missing a new entry
for replenihsment is created (it's also automaticaly deleted if
the replenihsment has been done another way).
Task: 2161378
Diffstat (limited to 'addons')
25 files changed, 890 insertions, 91 deletions
diff --git a/addons/mrp/models/stock_orderpoint.py b/addons/mrp/models/stock_orderpoint.py new file mode 100644 index 00000000000..262ed5f83c1 --- /dev/null +++ b/addons/mrp/models/stock_orderpoint.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import _, models + + +class StockWarehouseOrderpoint(models.Model): + _inherit = 'stock.warehouse.orderpoint' + + def _get_replenishment_order_notification(self): + self.ensure_one() + production = self.env['mrp.production'].search([ + ('orderpoint_id', 'in', self.ids) + ], order='create_date desc', limit=1) + if production: + action = self.env.ref('mrp.action_mrp_production_form') + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('The following replenishment order has been generated'), + 'message': '<a href="#action=%d&id=%d&model=mrp.production" target="_blank">%s</a>' % (action.id, production.id, production.name), + 'sticky': False, + } + } + return super()._get_replenishment_order_notification() diff --git a/addons/mrp/views/mrp_production_views.xml b/addons/mrp/views/mrp_production_views.xml index f08cc162472..241101570d0 100644 --- a/addons/mrp/views/mrp_production_views.xml +++ b/addons/mrp/views/mrp_production_views.xml @@ -440,7 +440,6 @@ <field name="type">ir.actions.act_window</field> <field name="res_model">mrp.production</field> <field name="view_mode">form</field> - <field name="context">{'default_picking_type_id': active_id}</field> </record> </data> </odoo> diff --git a/addons/mrp/views/stock_picking_views.xml b/addons/mrp/views/stock_picking_views.xml index 3ab7e51634c..215c2368208 100644 --- a/addons/mrp/views/stock_picking_views.xml +++ b/addons/mrp/views/stock_picking_views.xml @@ -77,7 +77,7 @@ <span>New</span> </div> <div role="menuitem"> - <a name="%(action_mrp_production_form)d" type="action">Production Order</a> + <a name="%(action_mrp_production_form)d" context="{'default_picking_type_id': active_id}" type="action">Production Order</a> </div> </div> </div> diff --git a/addons/mrp_subcontracting/tests/test_subcontracting.py b/addons/mrp_subcontracting/tests/test_subcontracting.py index ce27039a047..281e32c96ea 100644 --- a/addons/mrp_subcontracting/tests/test_subcontracting.py +++ b/addons/mrp_subcontracting/tests/test_subcontracting.py @@ -238,7 +238,7 @@ class TestSubcontractingFlows(TestMrpSubcontractingCommon): orderpoint_form.product_min_qty = 0.0 orderpoint_form.product_max_qty = 10.0 orderpoint_form.location_id = self.env.company.subcontracting_location_id - orderpoint_form.save() + orderpoint = orderpoint_form.save() # Create a receipt picking from the subcontractor picking_form = Form(self.env['stock.picking']) diff --git a/addons/purchase/views/purchase_views.xml b/addons/purchase/views/purchase_views.xml index 9f61fd92fd0..04f80d3a832 100644 --- a/addons/purchase/views/purchase_views.xml +++ b/addons/purchase/views/purchase_views.xml @@ -742,4 +742,13 @@ </field> </record> + <record id="action_rfq_form" model="ir.actions.act_window"> + <field name="name">Requests for Quotation</field> + <field name="res_model">purchase.order</field> + <field name="view_mode">form</field> + <field name="view_id" ref="purchase.purchase_order_form"/> + <field name="search_view_id" ref="view_purchase_order_filter"/> + <field name="target">main</field> + </record> + </odoo> diff --git a/addons/purchase_stock/models/product.py b/addons/purchase_stock/models/product.py index 97f56bdc931..f09db2e1167 100644 --- a/addons/purchase_stock/models/product.py +++ b/addons/purchase_stock/models/product.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models +from odoo.osv import expression class ProductTemplate(models.Model): @@ -16,3 +17,53 @@ class ProductTemplate(models.Model): return [] route_ids = fields.Many2many(default=lambda self: self._get_buy_route()) + + +class ProductProduct(models.Model): + _name = 'product.product' + _inherit = 'product.product' + + def _get_quantity_in_progress(self, location_ids=False, warehouse_ids=False): + if not location_ids: + location_ids = [] + if not warehouse_ids: + warehouse_ids = [] + + qty_by_product_location, qty_by_product_wh = super()._get_quantity_in_progress(location_ids, warehouse_ids) + domain = [] + rfq_domain = [ + ('state', 'in', ('draft', 'sent', 'to approve')), + ('product_id', 'in', self.ids) + ] + if location_ids: + domain = expression.AND([rfq_domain, [ + '|', + ('order_id.picking_type_id.default_location_dest_id', 'in', location_ids), + '&', + ('move_dest_ids', '=', False), + ('orderpoint_id.location_id', 'in', location_ids) + ]]) + if warehouse_ids: + wh_domain = expression.AND([rfq_domain, [ + '|', + ('order_id.picking_type_id.warehouse_id', 'in', warehouse_ids), + '&', + ('move_dest_ids', '=', False), + ('orderpoint_id.warehouse_id', 'in', warehouse_ids) + ]]) + domain = expression.OR([domain, wh_domain]) + groups = self.env['purchase.order.line'].read_group(domain, + ['product_id', 'product_qty', 'order_id', 'product_uom', 'orderpoint_id'], + ['order_id', 'product_id', 'product_uom', 'orderpoint_id'], lazy=False) + for group in groups: + if group.get('orderpoint_id'): + location = self.env['stock.warehouse.orderpoint'].browse(group['orderpoint_id'][:1]).location_id + else: + order = self.env['purchase.order'].browse(group['order_id'][0]) + location = order.picking_type_id.default_location_dest_id + product = self.env['product.product'].browse(group['product_id'][0]) + uom = self.env['uom.uom'].browse(group['product_uom'][0]) + product_qty = uom._compute_quantity(group['product_qty'], product.uom_id, round=False) + qty_by_product_location[(product.id, location.id)] += product_qty + qty_by_product_wh[(product.id, location.get_warehouse().id)] += product_qty + return qty_by_product_location, qty_by_product_wh diff --git a/addons/purchase_stock/models/stock.py b/addons/purchase_stock/models/stock.py index 20681254bd2..2af59c74ae7 100644 --- a/addons/purchase_stock/models/stock.py +++ b/addons/purchase_stock/models/stock.py @@ -163,12 +163,6 @@ class ReturnPicking(models.TransientModel): class Orderpoint(models.Model): _inherit = "stock.warehouse.orderpoint" - def _quantity_in_progress(self): - res = super(Orderpoint, self)._quantity_in_progress() - for poline in self.env['purchase.order.line'].search([('state', 'in', ('draft', 'sent', 'to approve')), ('orderpoint_id', 'in', self.ids), ('move_dest_ids', '=', False)]): - res[poline.orderpoint_id.id] += poline.product_uom._compute_quantity(poline.product_qty, poline.orderpoint_id.product_uom, round=False) - return res - def action_view_purchase(self): """ This function returns an action that display existing purchase orders of given orderpoint. @@ -185,6 +179,33 @@ class Orderpoint(models.Model): return result + def _get_replenishment_order_notification(self): + self.ensure_one() + order = self.env['purchase.order.line'].search([ + ('orderpoint_id', 'in', self.ids) + ], limit=1).order_id + if order: + action = self.env.ref('purchase.action_rfq_form') + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('The following replenishment order has been generated'), + 'message': '<a href="#action=%d&id=%d&model=purchase.order" target="_blank">%s</a>' % (action.id, order.id, order.display_name), + 'sticky': False, + } + } + return super()._get_replenishment_order_notification() + + def _quantity_in_progress(self): + res = super()._quantity_in_progress() + qty_by_product_location, dummy = self.product_id._get_quantity_in_progress(self.location_id.ids) + for orderpoint in self: + product_qty = qty_by_product_location.get((orderpoint.product_id.id, orderpoint.location_id.id), 0.0) + product_uom_qty = orderpoint.product_id.uom_id._compute_quantity(product_qty, orderpoint.product_uom, round=False) + res[orderpoint.id] += product_uom_qty + return res + class ProductionLot(models.Model): _inherit = 'stock.production.lot' diff --git a/addons/purchase_stock/tests/test_purchase_lead_time.py b/addons/purchase_stock/tests/test_purchase_lead_time.py index a6026a511f2..f01aad815e3 100644 --- a/addons/purchase_stock/tests/test_purchase_lead_time.py +++ b/addons/purchase_stock/tests/test_purchase_lead_time.py @@ -334,7 +334,7 @@ class TestPurchaseLeadTime(PurchaseTestCommon): orderpoint_form.save() self.mock_date.today.return_value = fields.Date.today() + timedelta(days=1) - self.env.cache.invalidate() + orderpoint._compute_qty() self.env['procurement.group'].run_scheduler() po_line = self.env['purchase.order.line'].search([('product_id', '=', product.id)]) self.assertEqual(len(po_line), 2) diff --git a/addons/purchase_stock/tests/test_reordering_rule.py b/addons/purchase_stock/tests/test_reordering_rule.py index 374c58ab8f9..370cd247135 100644 --- a/addons/purchase_stock/tests/test_reordering_rule.py +++ b/addons/purchase_stock/tests/test_reordering_rule.py @@ -4,6 +4,7 @@ from datetime import datetime as dt from datetime import timedelta as td +from odoo import SUPERUSER_ID from odoo.tests import Form from odoo.tests.common import SavepointCase @@ -45,7 +46,7 @@ class TestReorderingRule(SavepointCase): orderpoint_form.product_min_qty = 0.000 orderpoint_form.product_max_qty = 0.000 order_point = orderpoint_form.save() - + print('picking') # Create Delivery Order of 10 product picking_form = Form(self.env['stock.picking']) picking_form.partner_id = self.partner @@ -54,10 +55,8 @@ class TestReorderingRule(SavepointCase): move.product_id = self.product_01 move.product_uom_qty = 10.0 customer_picking = picking_form.save() - # picking confirm customer_picking.action_confirm() - # Run scheduler self.env['procurement.group'].run_scheduler() @@ -157,6 +156,203 @@ class TestReorderingRule(SavepointCase): self.assertEqual(purchase_order.picking_ids.move_lines[-1].product_qty, 5) self.assertEqual(purchase_order.picking_ids.move_lines[-1].location_dest_id, warehouse_1.lot_stock_id) + def test_replenish_report_1(self): + """Tests the auto generation of manual orderpoints. + + Opening multiple times the report should not duplicate the generated orderpoints. + MTO products should not trigger the creation of generated orderpoints + """ + partner = self.env['res.partner'].create({ + 'name': 'Tintin' + }) + route_buy = self.env.ref('purchase_stock.route_warehouse0_buy') + route_mto = self.env.ref('stock.route_warehouse0_mto') + + product_form = Form(self.env['product.product']) + product_form.name = 'Simple Product' + product_form.type = 'product' + with product_form.seller_ids.new() as s: + s.name = partner + product = product_form.save() + + product_form = Form(self.env['product.product']) + product_form.name = 'Product BUY + MTO' + product_form.type = 'product' + product_form.route_ids.add(route_buy) + product_form.route_ids.add(route_mto) + with product_form.seller_ids.new() as s: + s.name = partner + product_buy_mto = product_form.save() + + # Create Delivery Order of 20 product and 10 buy + MTO + picking_form = Form(self.env['stock.picking']) + picking_form.partner_id = partner + picking_form.picking_type_id = self.env.ref('stock.picking_type_out') + with picking_form.move_ids_without_package.new() as move: + move.product_id = product + move.product_uom_qty = 10.0 + with picking_form.move_ids_without_package.new() as move: + move.product_id = product + move.product_uom_qty = 10.0 + with picking_form.move_ids_without_package.new() as move: + move.product_id = product_buy_mto + move.product_uom_qty = 10.0 + customer_picking = picking_form.save() + customer_picking.move_lines.filtered(lambda m: m.product_id == product_buy_mto).procure_method = 'make_to_order' + customer_picking.action_confirm() + self.env['stock.warehouse.orderpoint']._get_orderpoint_action() + self.env['stock.warehouse.orderpoint']._get_orderpoint_action() + + orderpoint_product = self.env['stock.warehouse.orderpoint'].search( + [('product_id', '=', product.id)]) + orderpoint_product_mto_buy = self.env['stock.warehouse.orderpoint'].search( + [('product_id', '=', product_buy_mto.id)]) + self.assertFalse(orderpoint_product_mto_buy) + self.assertEqual(len(orderpoint_product), 1.0) + self.assertEqual(orderpoint_product.qty_to_order, 20.0) + self.assertEqual(orderpoint_product.trigger, 'manual') + self.assertEqual(orderpoint_product.create_uid.id, SUPERUSER_ID) + + orderpoint_product.action_replenish() + po = self.env['purchase.order'].search([('partner_id', '=', partner.id)]) + self.assertTrue(po) + self.assertEqual(len(po.order_line), 2.0) + po_line_product_mto = po.order_line.filtered(lambda l: l.product_id == product_buy_mto) + po_line_product = po.order_line.filtered(lambda l: l.product_id == product) + self.assertEqual(po_line_product_mto.product_uom_qty, 10.0) + self.assertEqual(po_line_product.product_uom_qty, 20.0) + + self.env['stock.warehouse.orderpoint']._get_orderpoint_action() + orderpoint_product = self.env['stock.warehouse.orderpoint'].search( + [('product_id', '=', product.id)]) + orderpoint_product_mto_buy = self.env['stock.warehouse.orderpoint'].search( + [('product_id', '=', product_buy_mto.id)]) + self.assertFalse(orderpoint_product) + self.assertFalse(orderpoint_product_mto_buy) + + # Create Delivery Order of 10 product and 10 buy + MTO + picking_form = Form(self.env['stock.picking']) + picking_form.partner_id = partner + picking_form.picking_type_id = self.env.ref('stock.picking_type_out') + with picking_form.move_ids_without_package.new() as move: + move.product_id = product + move.product_uom_qty = 10.0 + with picking_form.move_ids_without_package.new() as move: + move.product_id = product_buy_mto + move.product_uom_qty = 10.0 + customer_picking = picking_form.save() + customer_picking.move_lines.filtered(lambda m: m.product_id == product_buy_mto).procure_method = 'make_to_order' + customer_picking.action_confirm() + self.env['stock.warehouse.orderpoint'].flush() + + self.env['stock.warehouse.orderpoint']._get_orderpoint_action() + orderpoint_product = self.env['stock.warehouse.orderpoint'].search( + [('product_id', '=', product.id)]) + orderpoint_product_mto_buy = self.env['stock.warehouse.orderpoint'].search( + [('product_id', '=', product_buy_mto.id)]) + self.assertFalse(orderpoint_product_mto_buy) + self.assertEqual(len(orderpoint_product), 1.0) + self.assertEqual(orderpoint_product.qty_to_order, 10.0) + self.assertEqual(orderpoint_product.trigger, 'manual') + self.assertEqual(orderpoint_product.create_uid.id, SUPERUSER_ID) + + def test_replenish_report_2(self): + """Same then `test_replenish_report_1` but with two steps receipt enabled""" + partner = self.env['res.partner'].create({ + 'name': 'Tintin' + }) + for wh in self.env['stock.warehouse'].search([]): + wh.write({'reception_steps': 'two_steps'}) + route_buy = self.env.ref('purchase_stock.route_warehouse0_buy') + route_mto = self.env.ref('stock.route_warehouse0_mto') + + product_form = Form(self.env['product.product']) + product_form.name = 'Simple Product' + product_form.type = 'product' + with product_form.seller_ids.new() as s: + s.name = partner + product = product_form.save() + + product_form = Form(self.env['product.product']) + product_form.name = 'Product BUY + MTO' + product_form.type = 'product' + product_form.route_ids.add(route_buy) + product_form.route_ids.add(route_mto) + with product_form.seller_ids.new() as s: + s.name = partner + product_buy_mto = product_form.save() + + # Create Delivery Order of 20 product and 10 buy + MTO + picking_form = Form(self.env['stock.picking']) + picking_form.partner_id = partner + picking_form.picking_type_id = self.env.ref('stock.picking_type_out') + with picking_form.move_ids_without_package.new() as move: + move.product_id = product + move.product_uom_qty = 10.0 + with picking_form.move_ids_without_package.new() as move: + move.product_id = product + move.product_uom_qty = 10.0 + with picking_form.move_ids_without_package.new() as move: + move.product_id = product_buy_mto + move.product_uom_qty = 10.0 + customer_picking = picking_form.save() + customer_picking.move_lines.filtered(lambda m: m.product_id == product_buy_mto).procure_method = 'make_to_order' + customer_picking.action_confirm() + self.env['stock.warehouse.orderpoint']._get_orderpoint_action() + orderpoint_product = self.env['stock.warehouse.orderpoint'].search( + [('product_id', '=', product.id)]) + orderpoint_product_mto_buy = self.env['stock.warehouse.orderpoint'].search( + [('product_id', '=', product_buy_mto.id)]) + self.assertFalse(orderpoint_product_mto_buy) + self.assertEqual(len(orderpoint_product), 1.0) + self.assertEqual(orderpoint_product.qty_to_order, 20.0) + self.assertEqual(orderpoint_product.trigger, 'manual') + self.assertEqual(orderpoint_product.create_uid.id, SUPERUSER_ID) + + orderpoint_product.action_replenish() + po = self.env['purchase.order'].search([('partner_id', '=', partner.id)]) + self.assertTrue(po) + self.assertEqual(len(po.order_line), 2.0) + po_line_product_mto = po.order_line.filtered(lambda l: l.product_id == product_buy_mto) + po_line_product = po.order_line.filtered(lambda l: l.product_id == product) + self.assertEqual(po_line_product_mto.product_uom_qty, 10.0) + self.assertEqual(po_line_product.product_uom_qty, 20.0) + + self.env['stock.warehouse.orderpoint'].flush() + self.env['stock.warehouse.orderpoint']._get_orderpoint_action() + orderpoint_product = self.env['stock.warehouse.orderpoint'].search( + [('product_id', '=', product.id)]) + orderpoint_product_mto_buy = self.env['stock.warehouse.orderpoint'].search( + [('product_id', '=', product_buy_mto.id)]) + self.assertFalse(orderpoint_product) + self.assertFalse(orderpoint_product_mto_buy) + + # Create Delivery Order of 10 product and 10 buy + MTO + picking_form = Form(self.env['stock.picking']) + picking_form.partner_id = partner + picking_form.picking_type_id = self.env.ref('stock.picking_type_out') + with picking_form.move_ids_without_package.new() as move: + move.product_id = product + move.product_uom_qty = 10.0 + with picking_form.move_ids_without_package.new() as move: + move.product_id = product_buy_mto + move.product_uom_qty = 10.0 + customer_picking = picking_form.save() + customer_picking.move_lines.filtered(lambda m: m.product_id == product_buy_mto).procure_method = 'make_to_order' + customer_picking.action_confirm() + self.env['stock.warehouse.orderpoint'].flush() + + self.env['stock.warehouse.orderpoint']._get_orderpoint_action() + orderpoint_product = self.env['stock.warehouse.orderpoint'].search( + [('product_id', '=', product.id)]) + orderpoint_product_mto_buy = self.env['stock.warehouse.orderpoint'].search( + [('product_id', '=', product_buy_mto.id)]) + self.assertFalse(orderpoint_product_mto_buy) + self.assertEqual(len(orderpoint_product), 1.0) + self.assertEqual(orderpoint_product.qty_to_order, 10.0) + self.assertEqual(orderpoint_product.trigger, 'manual') + self.assertEqual(orderpoint_product.create_uid.id, SUPERUSER_ID) + def test_procure_not_default_partner(self): """Define a product with 2 vendors. First run a "standard" procurement, default vendor should be used. Then, call a procurement with diff --git a/addons/stock/__manifest__.py b/addons/stock/__manifest__.py index ab6e4492ba8..19dd39be47b 100644 --- a/addons/stock/__manifest__.py +++ b/addons/stock/__manifest__.py @@ -52,6 +52,7 @@ 'wizard/stock_warn_insufficient_qty_views.xml', 'wizard/product_replenish_views.xml', 'wizard/stock_track_confirmation_views.xml', + 'wizard/stock_orderpoint_snooze_views.xml', 'wizard/stock_package_destination_views.xml', 'views/res_partner_views.xml', @@ -81,7 +82,7 @@ 'static/src/xml/inventory_report.xml', 'static/src/xml/inventory_lines.xml', 'static/src/xml/popover_widget.xml', - 'static/src/xml/stock_orderpoint_lead_days_widget.xml', + 'static/src/xml/stock_orderpoint.xml', 'static/src/xml/stock_traceability_report_backend.xml', ], 'installable': True, diff --git a/addons/stock/models/product.py b/addons/stock/models/product.py index a95fe0bb8d2..42f9aff225b 100644 --- a/addons/stock/models/product.py +++ b/addons/stock/models/product.py @@ -2,8 +2,10 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import operator as py_operator +from ast import literal_eval +from collections import defaultdict -from odoo import _, api, fields, models +from odoo import _, api, fields, models, SUPERUSER_ID from odoo.exceptions import UserError from odoo.osv import expression from odoo.tools.float_utils import float_round @@ -424,6 +426,22 @@ class Product(models.Model): res['fields']['qty_available']['string'] = _('Produced Qty') return res + def action_view_orderpoints(self): + action = self.env.ref('stock.action_orderpoint').read()[0] + action['context'] = literal_eval(action.get('context')) + action['context'].pop('search_default_trigger', False) + action['context'].update({ + 'search_default_filter_not_snoozed': True, + }) + if self and len(self) == 1: + action['context'].update({ + 'default_product_id': self.ids[0], + 'search_default_product_id': self.ids[0] + }) + else: + action['domain'] = expression.AND([action.get('domain', []), [('product_id', 'in', self.ids)]]) + return action + def action_view_routes(self): return self.mapped('product_tmpl_id').action_view_routes() @@ -518,6 +536,9 @@ class Product(models.Model): }) return super().write(values) + def _get_quantity_in_progress(self, location_ids=False, warehouse_ids=False): + return defaultdict(float), defaultdict(float) + def _get_rules_from_location(self, location, route_ids=False, seen_rules=False): if not seen_rules: seen_rules = self.env['stock.rule'] @@ -742,14 +763,7 @@ class ProductTemplate(models.Model): return self._get_action_view_related_putaway_rules(domain) def action_view_orderpoints(self): - products = self.mapped('product_variant_ids') - action = self.env.ref('stock.product_open_orderpoint').read()[0] - if products and len(products) == 1: - action['context'] = {'default_product_id': products.ids[0], 'search_default_product_id': products.ids[0]} - else: - action['domain'] = [('product_id', 'in', products.ids)] - action['context'] = {} - return action + return self.product_variant_ids.action_view_orderpoints() def action_view_stock_move_lines(self): self.ensure_one() diff --git a/addons/stock/models/stock_orderpoint.py b/addons/stock/models/stock_orderpoint.py index 44db010abd6..9a927077be9 100644 --- a/addons/stock/models/stock_orderpoint.py +++ b/addons/stock/models/stock_orderpoint.py @@ -5,6 +5,7 @@ import logging from collections import defaultdict from datetime import datetime, time from dateutil import relativedelta +from itertools import groupby from json import dumps from psycopg2 import OperationalError @@ -38,18 +39,22 @@ class StockWarehouseOrderpoint(models.Model): name = fields.Char( 'Name', copy=False, required=True, readonly=True, default=lambda self: self.env['ir.sequence'].next_by_code('stock.orderpoint')) + trigger = fields.Selection([ + ('auto', 'Auto'), ('manual', 'Manual')], string='Trigger', default='auto', required=True) active = fields.Boolean( 'Active', default=True, help="If the active field is set to False, it will allow you to hide the orderpoint without removing it.") + snoozed_until = fields.Date('Snoozed', help="Hidden until next scheduler.") warehouse_id = fields.Many2one( 'stock.warehouse', 'Warehouse', check_company=True, ondelete="cascade", required=True) location_id = fields.Many2one( - 'stock.location', 'Location', + 'stock.location', 'Location', index=True, ondelete="cascade", required=True, check_company=True) product_id = fields.Many2one( - 'product.product', 'Product', + 'product.product', 'Product', index=True, domain="[('type', '=', 'product'), '|', ('company_id', '=', False), ('company_id', '=', company_id)]", ondelete='cascade', required=True, check_company=True) + product_category_id = fields.Many2one('product.category', name='Product Category', related='product_id.categ_id', store=True) product_uom = fields.Many2one( 'uom.uom', 'Unit of Measure', related='product_id.uom_id') product_uom_name = fields.Char(string='Product unit of measure label', related='product_uom.display_name', readonly=True) @@ -73,8 +78,15 @@ class StockWarehouseOrderpoint(models.Model): default=lambda self: self.env.company) allowed_location_ids = fields.One2many(comodel_name='stock.location', compute='_compute_allowed_location_ids') - json_lead_days_popover = fields.Char(compute='_compute_lead_days') + rule_ids = fields.Many2many('stock.rule', string='Rules used', compute='_compute_rules') + json_lead_days_popover = fields.Char(compute='_compute_json_popover') lead_days_date = fields.Date(compute='_compute_lead_days') + allowed_route_ids = fields.Many2many('stock.location.route', compute='_compute_allowed_route_ids') + route_id = fields.Many2one( + 'stock.location.route', string='Preferred Route', domain="[('id', 'in', allowed_route_ids)]") + qty_on_hand = fields.Float('On Hand', readonly=True, compute='_compute_qty') + qty_forecast = fields.Float('Forecast', readonly=True, compute='_compute_qty') + qty_to_order = fields.Float('To Order', compute='_compute_qty_to_order', store=True, readonly=False) _sql_constraints = [ ('qty_multiple_check', 'CHECK( qty_multiple >= 0 )', 'Qty Multiple must be greater than or equal to zero.'), @@ -93,44 +105,45 @@ class StockWarehouseOrderpoint(models.Model): loc_domain = expression.AND([loc_domain, ['|', ('company_id', '=', False), ('company_id', '=', orderpoint.company_id.id)]]) orderpoint.allowed_location_ids = self.env['stock.location'].search(loc_domain) - @api.depends('product_id', 'location_id', 'company_id', 'warehouse_id', - 'product_id.seller_ids', 'product_id.seller_ids.delay') - def _compute_lead_days(self): + @api.depends('warehouse_id', 'location_id') + def _compute_allowed_route_ids(self): + route_by_product = self.env['stock.location.route'].search([ + ('product_selectable', '=', True), + ]) + self.allowed_route_ids = route_by_product.ids + + @api.depends('rule_ids', 'product_id.seller_ids', 'product_id.seller_ids.delay') + def _compute_json_popover(self): for orderpoint in self: - rules = orderpoint.product_id._get_rules_from_location(orderpoint.location_id) - lead_days, lead_days_description = rules._get_lead_days(orderpoint.product_id) - lead_days_date = fields.Date.today() + relativedelta.relativedelta(days=lead_days) + if not orderpoint.product_id or not orderpoint.location_id: + orderpoint.json_lead_days_popover = False + continue + dummy, lead_days_description = orderpoint.rule_ids._get_lead_days(orderpoint.product_id) orderpoint.json_lead_days_popover = dumps({ 'title': _('Replenishment'), 'popoverTemplate': 'stock.leadDaysPopOver', - 'lead_days_date': fields.Date.to_string(lead_days_date), + 'lead_days_date': fields.Date.to_string(orderpoint.lead_days_date), 'lead_days_description': lead_days_description, 'today': fields.Date.to_string(fields.Date.today()), }) - orderpoint.lead_days_date = lead_days_date - def _compute_qty(self): - orderpoints_contexts = defaultdict(lambda: self.env['stock.warehouse.orderpoint']) + @api.depends('rule_ids', 'product_id.seller_ids', 'product_id.seller_ids.delay') + def _compute_lead_days(self): for orderpoint in self: - orderpoint_context = orderpoint._get_product_context() - product_context = frozendict({**self.env.context, **orderpoint_context}) - orderpoints_contexts[product_context] |= orderpoint - for orderpoint_context, orderpoints_by_context in orderpoints_contexts.items(): - products_qty = orderpoints_by_context.product_id.with_context(orderpoint_context)._product_available() - products_qty_in_progress = orderpoints_by_context._quantity_in_progress() - for orderpoint in orderpoints_by_context: - orderpoint.qty_on_hand = products_qty[orderpoint.product_id.id]['qty_available'] - orderpoint.qty_forecast = products_qty[orderpoint.product_id.id]['virtual_available'] + products_qty_in_progress[orderpoint.id] - - qty_to_order = 0.0 - rounding = orderpoint.product_uom.rounding - if float_compare(orderpoint.qty_forecast, orderpoint.product_min_qty, precision_rounding=rounding) < 0: - qty_to_order = max(orderpoint.product_min_qty, orderpoint.product_max_qty) - orderpoint.qty_forecast + if not orderpoint.product_id or not orderpoint.location_id: + orderpoint.lead_days_date = False + continue + lead_days, dummy = orderpoint.rule_ids._get_lead_days(orderpoint.product_id) + lead_days_date = fields.Date.today() + relativedelta.relativedelta(days=lead_days) + orderpoint.lead_days_date = lead_days_date - remainder = orderpoint.qty_multiple > 0 and qty_to_order % orderpoint.qty_multiple or 0.0 - if float_compare(remainder, 0.0, precision_rounding=rounding) > 0: - qty_to_order += orderpoint.qty_multiple - remainder - orderpoint.qty_to_order = qty_to_order + @api.depends('route_id', 'product_id', 'location_id', 'company_id', 'warehouse_id', 'product_id.route_ids') + def _compute_rules(self): + for orderpoint in self: + if not orderpoint.product_id or not orderpoint.location_id: + orderpoint.rule_ids = False + continue + orderpoint.rule_ids = orderpoint.product_id._get_rules_from_location(orderpoint.location_id, route_ids=orderpoint.route_id) @api.constrains('product_id') def _check_product_uom(self): @@ -138,11 +151,19 @@ class StockWarehouseOrderpoint(models.Model): if any(orderpoint.product_id.uom_id.category_id != orderpoint.product_uom.category_id for orderpoint in self): raise ValidationError(_('You have to select a product unit of measure that is in the same category than the default unit of measure of the product')) + @api.onchange('location_id') + def _onchange_location_id(self): + warehouse = self.location_id.get_warehouse().id + if warehouse: + self.warehouse_id = warehouse + @api.onchange('warehouse_id') def _onchange_warehouse_id(self): """ Finds location id for changed warehouse. """ if self.warehouse_id: self.location_id = self.warehouse_id.lot_stock_id.id + else: + self.location_id = False @api.onchange('product_id') def _onchange_product_id(self): @@ -163,6 +184,58 @@ class StockWarehouseOrderpoint(models.Model): raise UserError(_("Changing the company of this record is forbidden at this point, you should rather archive it and create a new one.")) return super().write(vals) + @api.model + def action_open_orderpoints(self): + return self._get_orderpoint_action() + + def action_replenish(self): + self._procure_orderpoint_confirm(company_id=self.env.company) + notification = False + if len(self) == 1: + notification = self._get_replenishment_order_notification() + # Forced to call compute quantity because we don't have a link. + self._compute_qty() + self.filtered(lambda o: o.create_uid.id == SUPERUSER_ID and o.qty_to_order <= 0.0 and o.trigger == 'manual').unlink() + return notification + + def action_replenish_auto(self): + self.trigger = 'auto' + return self.action_replenish() + + @api.depends('product_id', 'location_id', 'product_id.stock_move_ids', 'product_id.stock_move_ids.state') + def _compute_qty(self): + orderpoints_contexts = defaultdict(lambda: self.env['stock.warehouse.orderpoint']) + for orderpoint in self: + if not orderpoint.product_id or not orderpoint.location_id: + orderpoint.qty_on_hand = False + orderpoint.qty_forecast = False + continue + orderpoint_context = orderpoint._get_product_context() + product_context = frozendict({**self.env.context, **orderpoint_context}) + orderpoints_contexts[product_context] |= orderpoint + for orderpoint_context, orderpoints_by_context in orderpoints_contexts.items(): + products_qty = orderpoints_by_context.product_id.with_context(orderpoint_context)._product_available() + products_qty_in_progress = orderpoints_by_context._quantity_in_progress() + for orderpoint in orderpoints_by_context: + orderpoint.qty_on_hand = products_qty[orderpoint.product_id.id]['qty_available'] + orderpoint.qty_forecast = products_qty[orderpoint.product_id.id]['virtual_available'] + products_qty_in_progress[orderpoint.id] + + @api.depends('qty_multiple', 'qty_forecast', 'product_min_qty', 'product_max_qty') + def _compute_qty_to_order(self): + for orderpoint in self: + if not orderpoint.product_id or not orderpoint.location_id: + orderpoint.qty_to_order = False + continue + qty_to_order = 0.0 + rounding = orderpoint.product_uom.rounding + if float_compare(orderpoint.qty_forecast, orderpoint.product_min_qty, precision_rounding=rounding) < 0: + qty_to_order = max(orderpoint.product_min_qty, orderpoint.product_max_qty) - orderpoint.qty_forecast + + remainder = orderpoint.qty_multiple > 0 and qty_to_order % orderpoint.qty_multiple or 0.0 + if float_compare(remainder, 0.0, precision_rounding=rounding) > 0: + qty_to_order += orderpoint.qty_multiple - remainder + orderpoint.qty_to_order = qty_to_order + def _get_product_context(self): """Used to call `virtual_available` when running an orderpoint.""" self.ensure_one() @@ -171,6 +244,102 @@ class StockWarehouseOrderpoint(models.Model): 'to_date': datetime.combine(self.lead_days_date, time.max) } + def _get_orderpoint_action(self): + """Create manual orderpoints for missing product in each warehouses. It also removes + orderpoints that have been replenish. In order to do it: + - It uses the report.stock.quantity to find missing quantity per product/warehouse + - It checks if orderpoint already exist to refill this location. + - It checks if it exists other sources (e.g RFQ) tha refill the warehouse. + - It creates the orderpoints for missing quantity that were not refill by an upper option. + + return replenish report ir.actions.act_window + """ + action = self.env.ref('stock.action_orderpoint_replenish').read()[0] + action['context'] = self.env.context + orderpoints = self.env['stock.warehouse.orderpoint'].search([]) + # Remove previous automatically created orderpoint that has been refilled. + to_remove = orderpoints.filtered(lambda o: o.create_uid.id == SUPERUSER_ID and o.qty_to_order <= 0.0 and o.trigger == 'manual') + to_remove.unlink() + orderpoints = orderpoints - to_remove + to_refill = defaultdict(float) + qty_by_product_warehouse = self.env['report.stock.quantity'].read_group( + [('date', '=', fields.date.today()), ('state', '=', 'forecast')], + ['product_id', 'product_qty', 'warehouse_id'], + ['product_id', 'warehouse_id'], lazy=False) + for group in qty_by_product_warehouse: + warehouse_id = group.get('warehouse_id') and group['warehouse_id'][0] + if group['product_qty'] >= 0.0 or not warehouse_id: + continue + to_refill[(group['product_id'][0], warehouse_id)] = group['product_qty'] + if not to_refill: + return action + + # Remove incoming quantity from other otigin than moves (e.g RFQ) + product_ids, warehouse_ids = zip(*to_refill) + # lot_stock_ids = [lot_stock_id_by_warehouse[w] for w in warehouse_ids] + dummy, qty_by_product_wh = self.env['product.product'].browse(product_ids)._get_quantity_in_progress(warehouse_ids=warehouse_ids) + rounding = self.env['decimal.precision'].precision_get('Product Unit of Measure') + for (product, warehouse), product_qty in to_refill.items(): + qty_in_progress = qty_by_product_wh.get((product, warehouse)) or 0.0 + qty_in_progress += sum(orderpoints.filtered( + lambda o: o.product_id.id == product and o.warehouse_id.id == warehouse + ).mapped('qty_to_order')) + # Add qty to order for other orderpoint under this warehouse. + if not qty_in_progress: + continue + to_refill[(product, warehouse)] = product_qty + qty_in_progress + to_refill = {k: v for k, v in to_refill.items() if float_compare( + v, 0.0, precision_digits=rounding) < 0.0} + + lot_stock_id_by_warehouse = self.env['stock.warehouse'].search_read([ + ('id', 'in', [g[1] for g in to_refill.keys()]) + ], ['lot_stock_id']) + lot_stock_id_by_warehouse = {w['id']: w['lot_stock_id'][0] for w in lot_stock_id_by_warehouse} + + product_qty_available = {} + for warehouse, group in groupby(sorted(to_refill, key=lambda p_w: p_w[1]), key=lambda p_w: p_w[1]): + products = self.env['product.product'].browse([p for p, w in group]) + products_qty_available_list = products.with_context(location=lot_stock_id_by_warehouse[warehouse]).mapped('qty_available') + product_qty_available.update({(p.id, warehouse): q for p, q in zip(products, products_qty_available_list)}) + + orderpoint_values_list = [] + for (product, warehouse), product_qty in to_refill.items(): + lot_stock_id = lot_stock_id_by_warehouse[warehouse] + orderpoint = self.filtered(lambda o: o.product_id == product and o.location_id == lot_stock_id) + if orderpoint: + orderpoint[0].qty_forecast += product_qty + else: + orderpoint_values = self.env['stock.warehouse.orderpoint']._get_orderpoint_values(product, lot_stock_id) + orderpoint_values.update({ + 'name': _('Replenishment Report'), + 'warehouse_id': warehouse, + 'company_id': self.env['stock.warehouse'].browse(warehouse).company_id.id, + }) + orderpoint_values_list.append(orderpoint_values) + + orderpoints = self.env['stock.warehouse.orderpoint'].with_user(SUPERUSER_ID).create(orderpoint_values_list) + for orderpoint in orderpoints: + orderpoint.route_id = orderpoint._get_default_route_id() + return action + + @api.model + def _get_orderpoint_values(self, product, location): + return { + 'product_id': product, + 'location_id': location, + 'product_max_qty': 0.0, + 'product_min_qty': 0.0, + 'trigger': 'manual', + } + + def _get_replenishment_order_notification(self): + return False + + def _quantity_in_progress(self): + """Return Quantities that are not yet in virtual stock but should be deduced from orderpoint rule + (example: purchases created from orderpoints)""" + return dict(self.mapped(lambda x: (x.id, 0.0))) + def _prepare_procurement_values(self, date=False, group=False): """ Prepare specific key for moves or other components that will be created from a stock rule comming from an orderpoint. This method could be override in order to add other custom key that could @@ -178,13 +347,14 @@ class StockWarehouseOrderpoint(models.Model): """ date_planned = date or fields.Date.today() return { + 'route_ids': self.route_id, 'date_planned': date_planned, 'warehouse_id': self.warehouse_id, 'orderpoint_id': self, 'group_id': group or self.group_id, } - def _procure_orderpoint_confirm(self, use_new_cursor=False, company_id=None): + def _procure_orderpoint_confirm(self, use_new_cursor=False, company_id=None, raise_user_error=True): """ Create procurements based on orderpoints. :param bool use_new_cursor: if set, use a dedicated cursor and auto-commit after processing 1000 orderpoints. @@ -213,7 +383,7 @@ class StockWarehouseOrderpoint(models.Model): try: with self.env.cr.savepoint(): - self.env['procurement.group'].with_context(from_orderpoint=True).run(procurements, raise_user_error=False) + self.env['procurement.group'].with_context(from_orderpoint=True).run(procurements, raise_user_error=raise_user_error) except ProcurementException as errors: for procurement, error_msg in errors.procurement_exceptions: orderpoints_exceptions += [(procurement.values.get('orderpoint_id'), error_msg)] @@ -254,8 +424,3 @@ class StockWarehouseOrderpoint(models.Model): def _post_process_scheduler(self): return True - - def _quantity_in_progress(self): - """Return Quantities that are not yet in virtual stock but should be deduced from orderpoint rule - (example: purchases created from orderpoints)""" - return dict(self.mapped(lambda x: (x.id, 0.0))) diff --git a/addons/stock/models/stock_rule.py b/addons/stock/models/stock_rule.py index b9b0bf0e754..721de863fbe 100644 --- a/addons/stock/models/stock_rule.py +++ b/addons/stock/models/stock_rule.py @@ -6,7 +6,7 @@ from collections import defaultdict, namedtuple from dateutil.relativedelta import relativedelta -from odoo import _, api, fields, models, registry +from odoo import SUPERUSER_ID, _, api, fields, models, registry from odoo.exceptions import UserError from odoo.osv import expression from odoo.tools import float_compare, float_is_zero, html_escape @@ -502,7 +502,7 @@ class ProcurementGroup(models.Model): # Minimum stock rules domain = self._get_orderpoint_domain(company_id=company_id) orderpoints = self.env['stock.warehouse.orderpoint'].search(domain) - orderpoints.sudo()._procure_orderpoint_confirm(use_new_cursor=use_new_cursor, company_id=company_id) + orderpoints.sudo()._procure_orderpoint_confirm(use_new_cursor=use_new_cursor, company_id=company_id, raise_user_error=False) # Search all confirmed stock_moves and try to assign them domain = self._get_moves_to_assign_domain() @@ -518,6 +518,7 @@ class ProcurementGroup(models.Model): # Merge duplicated quants self.env['stock.quant']._quant_tasks() + if use_new_cursor: self._cr.commit() @@ -542,6 +543,7 @@ class ProcurementGroup(models.Model): @api.model def _get_orderpoint_domain(self, company_id=False): - domain = [('company_id', '=', company_id)] if company_id else [] - domain += [('product_id.active', '=', True)] + domain = [('trigger', '=', 'auto'), ('product_id.active', '=', True)] + if company_id: + domain += [('company_id', '=', company_id)] return domain diff --git a/addons/stock/security/ir.model.access.csv b/addons/stock/security/ir.model.access.csv index decaa3475ea..ea20a1f51c5 100644 --- a/addons/stock/security/ir.model.access.csv +++ b/addons/stock/security/ir.model.access.csv @@ -81,3 +81,4 @@ access_product_replenish,access.product.replenish,model_product_replenish,stock. access_stock_track_confirmation,access.stock.track.confirmation,model_stock_track_confirmation,stock.group_stock_user,1,1,1,0 access_stock_track_line,access.stock.track.line,model_stock_track_line,stock.group_stock_user,1,1,1,0 access_stock_package_destination,access.stock.package.destination,model_stock_package_destination,stock.group_stock_user,1,1,1,0 +access_stock_orderpoint_snooze,access_stock_orderpoint_snooze,model_stock_orderpoint_snooze,stock.group_stock_user,1,1,1,1 diff --git a/addons/stock/static/src/js/stock_orderpoint_list_controller.js b/addons/stock/static/src/js/stock_orderpoint_list_controller.js new file mode 100644 index 00000000000..4ad0750815c --- /dev/null +++ b/addons/stock/static/src/js/stock_orderpoint_list_controller.js @@ -0,0 +1,74 @@ +odoo.define('stock.StockOrderpointListController', function (require) { +"use strict"; + +var core = require('web.core'); +var ListController = require('web.ListController'); + +var qweb = core.qweb; + + +var StockOrderpointListController = ListController.extend({ + + // ------------------------------------------------------------------------- + // Public + // ------------------------------------------------------------------------- + + /** + * @override + */ + renderButtons: function () { + this._super.apply(this, arguments); + this.$buttons.find('.o_button_import').addClass('d-none'); + this.$buttons.find('.o_list_export_xlsx').addClass('d-none'); + this.$buttons.find('.o_list_button_add').removeClass('btn-primary').addClass('btn-secondary'); + var $buttons = $(qweb.render('StockOrderpoint.Buttons')); + var $buttonOrder = $buttons.find('.o_button_order'); + var $buttonSnooze = $buttons.find('.o_button_snooze'); + $buttonOrder.on('click', this._onReplenish.bind(this)); + $buttonSnooze.on('click', this._onSnooze.bind(this)); + $buttons.prependTo(this.$buttons); + }, + + // ------------------------------------------------------------------------- + // Handlers + // ------------------------------------------------------------------------- + + _onButtonClicked: function (ev) { + if (ev.data.attrs.class.split(' ').includes('o_replenish_buttons')) { + ev.stopPropagation(); + var self = this; + this._callButtonAction(ev.data.attrs, ev.data.record).then(function () { + self.reload(); + }); + } else { + this._super.apply(this, arguments); + } + }, + + _onReplenish: function () { + var records = this.getSelectedRecords(); + this.model.replenish(records); + }, + + _onSelectionChanged: function (ev) { + this._super(ev); + var $buttonOrder = this.$el.find('.o_button_order'); + var $buttonSnooze = this.$el.find('.o_button_snooze'); + if (this.getSelectedIds().length === 0){ + $buttonOrder.addClass('d-none'); + $buttonSnooze.addClass('d-none'); + } else { + $buttonOrder.removeClass('d-none'); + $buttonSnooze.removeClass('d-none'); + } + }, + + _onSnooze: function () { + var records = this.getSelectedRecords(); + this.model.snooze(records); + }, +}); + +return StockOrderpointListController; + +}); diff --git a/addons/stock/static/src/js/stock_orderpoint_list_model.js b/addons/stock/static/src/js/stock_orderpoint_list_model.js new file mode 100644 index 00000000000..c9f0acb9012 --- /dev/null +++ b/addons/stock/static/src/js/stock_orderpoint_list_model.js @@ -0,0 +1,46 @@ +odoo.define('stock.StockOrderpointListModel', function (require) { +"use strict"; + +var core = require('web.core'); +var ListModel = require('web.ListModel'); + +var qweb = core.qweb; + + +var StockOrderpointListModel = ListModel.extend({ + + // ------------------------------------------------------------------------- + // Public + // ------------------------------------------------------------------------- + /** + */ + replenish: function (records) { + var self = this; + var model = records[0].model; + var recordResIds = _.pluck(records, 'res_id'); + var context = records[0].getContext(); + return this._rpc({ + model: model, + method: 'action_replenish', + args: [recordResIds], + context: context, + }).then(function () { + return self.do_action('stock.action_replenishment'); + }); + }, + + snooze: function (records) { + var recordResIds = _.pluck(records, 'res_id'); + var self = this; + return this.do_action('stock.action_orderpoint_snooze', { + additional_context: { + default_orderpoint_ids: recordResIds + }, + on_close: () => self.do_action('stock.action_replenishment') + }); + }, +}); + +return StockOrderpointListModel; + +}); diff --git a/addons/stock/static/src/js/stock_orderpoint_list_view.js b/addons/stock/static/src/js/stock_orderpoint_list_view.js new file mode 100644 index 00000000000..d893ba9e5da --- /dev/null +++ b/addons/stock/static/src/js/stock_orderpoint_list_view.js @@ -0,0 +1,21 @@ +odoo.define('stock.StockOrderpointListView', function (require) { +"use strict"; + +var ListView = require('web.ListView'); +var StockOrderpointListController = require('stock.StockOrderpointListController'); +var StockOrderpointListModel = require('stock.StockOrderpointListModel'); +var viewRegistry = require('web.view_registry'); + + +var StockOrderpointListView = ListView.extend({ + config: _.extend({}, ListView.prototype.config, { + Controller: StockOrderpointListController, + Model: StockOrderpointListModel, + }), +}); + +viewRegistry.add('stock_orderpoint_list', StockOrderpointListView); + +return StockOrderpointListView; + +}); diff --git a/addons/stock/static/src/xml/stock_orderpoint_lead_days_widget.xml b/addons/stock/static/src/xml/stock_orderpoint.xml index 51214708a24..6661303d5a6 100644 --- a/addons/stock/static/src/xml/stock_orderpoint_lead_days_widget.xml +++ b/addons/stock/static/src/xml/stock_orderpoint.xml @@ -26,4 +26,16 @@ </tbody> </table> </div> + + <t t-name="StockOrderpoint.Buttons"> + <span> + <button type="button" class="btn d-none btn-primary o_button_order"> + Order + </button> + <button type="button" class="btn d-none btn-primary o_button_snooze"> + Snooze + </button> + </span> + </t> + </templates> diff --git a/addons/stock/tests/test_proc_rule.py b/addons/stock/tests/test_proc_rule.py index 2ea7b6c0331..cababaebeef 100644 --- a/addons/stock/tests/test_proc_rule.py +++ b/addons/stock/tests/test_proc_rule.py @@ -174,7 +174,7 @@ class TestProcRule(TransactionCase): orderpoint_form.location_id = warehouse.lot_stock_id orderpoint_form.product_min_qty = 0.0 orderpoint_form.product_max_qty = 5.0 - orderpoint_form.save() + orderpoint = orderpoint_form.save() self.env['stock.rule'].create({ 'name': 'Rule Supplier', @@ -197,6 +197,7 @@ class TestProcRule(TransactionCase): 'location_dest_id': self.ref('stock.stock_location_customers'), }) delivery_move._action_confirm() + orderpoint._compute_qty() self.env['procurement.group'].run_scheduler() receipt_move = self.env['stock.move'].search([ diff --git a/addons/stock/views/product_views.xml b/addons/stock/views/product_views.xml index 4d3b55cf039..592e1deab5d 100644 --- a/addons/stock/views/product_views.xml +++ b/addons/stock/views/product_views.xml @@ -23,12 +23,6 @@ </field> </record> - <record model="ir.actions.act_window" id="product_open_orderpoint"> - <field name="context">{'default_product_id': active_id, 'search_default_product_id': active_id}</field> - <field name="name">Reordering Rules</field> - <field name="res_model">stock.warehouse.orderpoint</field> - </record> - <record id="view_stock_product_tree" model="ir.ui.view"> <field name="name">product.stock.tree.inherit</field> <field name="model">product.product</field> @@ -242,7 +236,7 @@ attrs="{'invisible':[('type', 'not in', ['product', 'consu'])]}" class="oe_stat_button" icon="fa-exchange" groups="stock.group_stock_user"/> - <button name="%(product_open_orderpoint)d" type="action" + <button name="action_view_orderpoints" type="object" attrs="{'invisible':['|',('type', 'not in', ['product', 'consu']),('nbr_reordering_rules', '!=', 1)]}" class="oe_stat_button" icon="fa-refresh"> <div class="o_field_widget o_stat_info mr4"> @@ -254,8 +248,8 @@ <span class="o_stat_value"><field name="reordering_max_qty"/></span> </div> </button> - <button type="action" - name="%(product_open_orderpoint)d" + <button type="object" + name="action_view_orderpoints" attrs="{'invisible':['|',('type', '!=', 'product'),('nbr_reordering_rules', '==', 1)]}" class="oe_stat_button" icon="fa-refresh"> <field name="nbr_reordering_rules" widget="statinfo"/> diff --git a/addons/stock/views/stock_orderpoint_views.xml b/addons/stock/views/stock_orderpoint_views.xml index b6f2b8a322a..44bde0eed4c 100644 --- a/addons/stock/views/stock_orderpoint_views.xml +++ b/addons/stock/views/stock_orderpoint_views.xml @@ -1,6 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <odoo> - <!-- Order Point --> <record id="view_stock_warehouse_orderpoint_kanban" model="ir.ui.view"> <field name="name">stock.warehouse.orderpoint.kanban</field> <field name="model">stock.warehouse.orderpoint</field> @@ -8,6 +7,7 @@ <kanban class="o_kanban_mobile"> <field name="name"/> <field name="product_id"/> + <field name="trigger"/> <field name="product_min_qty"/> <field name="product_max_qty"/> <templates> @@ -34,24 +34,88 @@ </field> </record> - <record id="view_warehouse_orderpoint_tree" model="ir.ui.view"> - <field name="name">stock.warehouse.orderpoint.tree</field> + <record id="view_warehouse_orderpoint_tree_editable" model="ir.ui.view"> + <field name="name">stock.warehouse.orderpoint.tree.editable</field> <field name="model">stock.warehouse.orderpoint</field> <field name="arch" type="xml"> - <tree string="Reordering Rules" multi_edit="1"> - <field name="name"/> + <tree string="Reordering Rules" editable="bottom" js_class="stock_orderpoint_list"> <field name="active" invisible="1"/> - <field name="warehouse_id" readonly="1" groups="stock.group_stock_multi_locations"/> + <field name="allowed_route_ids" invisible="1"/> + <field name="product_category_id" invisible="1"/> + <field name="warehouse_id" invisible="1"/> + <field name="product_tmpl_id" invisible="1"/> + <field name="product_id" attrs="{'readonly': [('product_id', '!=', False)]}" invisible="context.get('default_product_id')" force_save="1"/> <field name="location_id" options="{'no_create': True}" groups="stock.group_stock_multi_locations"/> - <field name="product_id" readonly="1"/> - <field name="company_id" readonly="1" groups="base.group_multi_company"/> - <field name="product_min_qty"/> - <field name="product_max_qty"/> - <field name="product_uom" options="{'no_open': True, 'no_create': True}" groups="uom.group_uom"/> + <field name="warehouse_id" options="{'no_create': True}" groups="stock.group_stock_multi_warehouses" optional="hide"/> + <field name="route_id"/> + <field name="trigger" optional="hide"/> + <field name="product_min_qty" optional="show"/> + <field name="product_max_qty" optional="show"/> + <field name="qty_multiple" optional="hide"/> + <field name="qty_on_hand" force_save="1"/> + <field name="qty_forecast" force_save="1"/> + <field name="json_lead_days_popover" optional="show" string=" " widget="popover_widget" attrs="{'invisible': [('id', '=', False)]}"/> + <field name="qty_to_order"/> + <field name="product_uom_name" string="UoM" groups="uom.group_uom"/> + <field name="company_id" optional="hide" readonly="1" groups="base.group_multi_company"/> + <button name="action_replenish" string="Order" type="object" class="btn btn-sm btn-primary pt-0 pb-0 o_replenish_buttons" + attrs="{'invisible': [('qty_to_order', '<', 1.0)]}"/> + <button name="action_replenish_auto" string="Order Always" type="object" class="btn btn-sm btn-primary pt-0 pb-0 o_replenish_buttons" + attrs="{'invisible': ['|', ('qty_to_order', '<', 1.0), ('trigger', '=', 'auto')]}"/> + <button name="%(action_orderpoint_snooze)d" string="Snooze" type="action" class="btn btn-sm btn-secondary pt-0 pb-0" + attrs="{'invisible': [('trigger', '!=', 'manual')]}" context="{'default_orderpoint_ids': [id]}"/> + </tree> + </field> + </record> + + <record id="view_warehouse_orderpoint_tree_editable_config" model="ir.ui.view"> + <field name="name">stock.warehouse.orderpoint.tree.editable.config</field> + <field name="model">stock.warehouse.orderpoint</field> + <field name="arch" type="xml"> + <tree string="Reordering Rules" editable="bottom"> + <field name="active" invisible="1"/> + <field name="allowed_route_ids" invisible="1"/> + <field name="warehouse_id" invisible="1"/> + <field name="product_tmpl_id" invisible="1"/> + <field name="product_id" invisible="context.get('default_product_id')" force_save="1"/> + <field name="location_id" options="{'no_create': True}" groups="stock.group_stock_multi_locations"/> + <field name="route_id" optional="hide"/> + <field name="trigger" optional="hide"/> + <field name="product_min_qty" optional="show"/> + <field name="product_max_qty" optional="show"/> + <field name="qty_multiple" optional="show"/> + <field name="json_lead_days_popover" optional="show" string=" " widget="popover_widget" attrs="{'invisible': [('id', '=', False)]}"/> + <field name="product_uom_name" string="UoM" groups="uom.group_uom"/> + <field name="company_id" optional="hide" readonly="1" groups="base.group_multi_company"/> </tree> </field> </record> + <record model="ir.ui.view" id="stock_reorder_report_search"> + <field name="name">stock.warehouse.orderpoint.reorder.search</field> + <field name="model">stock.warehouse.orderpoint</field> + <field name="arch" type="xml"> + <search string="Replenishment Report Search"> + <field name="name" string="Reordering Rule"/> + <field name="product_id"/> + <field name="trigger"/> + <field name="product_category_id"/> + <field name="warehouse_id" groups="stock.group_stock_multi_warehouses"/> + <field name="location_id" groups="stock.group_stock_multi_locations"/> + <filter string="To Reorder" name="filter_to_reorder" domain="[('qty_to_order', '>', 0.0)]"/> + <separator/> + <filter string="Not Snoozed" name="filter_not_snoozed" domain="['|', ('snoozed_until', '=', False), ('snoozed_until', '<=', datetime.date.today().strftime('%Y-%m-%d'))]"/> + <group expand="0" string="Group By"> + <filter string="Warehouse" name="groupby_warehouse" domain="[]" context="{'group_by': 'warehouse_id'}" groups="stock.group_stock_multi_warehouses"/> + <filter string="Location" name="groupby_location" domain="[]" context="{'group_by': 'location_id'}" groups="stock.group_stock_multi_locations"/> + <filter string="Product" name="groupby_product" domain="[]" context="{'group_by': 'product_id'}"/> + <filter string="Category" name="groupby_category" domain="[]" context="{'group_by': 'product_category_id'}"/> + </group> + </search> + </field> + </record> + + <record model="ir.ui.view" id="warehouse_orderpoint_search"> <field name="name">stock.warehouse.orderpoint.search</field> <field name="model">stock.warehouse.orderpoint</field> @@ -59,6 +123,7 @@ <search string="Reordering Rules Search"> <field name="name" string="Reordering Rule"/> <field name="product_id"/> + <field name="trigger"/> <field name="warehouse_id" groups="stock.group_stock_multi_warehouses"/> <field name="location_id" groups="stock.group_stock_multi_locations"/> <filter string="Archived" name="inactive" domain="[('active', '=', False)]"/> @@ -119,13 +184,30 @@ </field> </record> - <record id="action_orderpoint_form" model="ir.actions.act_window"> + <record id="action_orderpoint_replenish" model="ir.actions.act_window"> + <field name="name">Replenishment</field> + <field name="res_model">stock.warehouse.orderpoint</field> + <field name="type">ir.actions.act_window</field> + <field name="view_mode">tree</field> + <field name="view_id" ref="view_warehouse_orderpoint_tree_editable"/> + <field name="search_view_id" ref="stock_reorder_report_search"/> + <field name="help" type="html"> + <p class="o_view_nocontent_smiling_face"> + Create a reordering rule + </p><p> + Define a minimum stock rule so that Odoo creates automatically requests for quotations or draft manufacturing orders to resupply your stock. + </p> + </field> + </record> + + <record id="action_orderpoint" model="ir.actions.act_window"> <field name="name">Reordering Rules</field> <field name="res_model">stock.warehouse.orderpoint</field> <field name="type">ir.actions.act_window</field> <field name="view_mode">tree,kanban,form</field> - <field name="view_id" ref="view_warehouse_orderpoint_tree"/> - <field name="search_view_id" ref="warehouse_orderpoint_search" /> + <field name="view_id" ref="view_warehouse_orderpoint_tree_editable_config"/> + <field name="search_view_id" ref="warehouse_orderpoint_search"/> + <field name="context">{'search_default_trigger': 'auto'}</field> <field name="help" type="html"> <p class="o_view_nocontent_smiling_face"> Create a reordering rule @@ -135,8 +217,27 @@ </field> </record> + <record model="ir.actions.server" id="action_replenishment"> + <field name="name">Replenishment</field> + <field name="model_id" ref="model_stock_warehouse_orderpoint"/> + <field name="state">code</field> + <field name="code"> + action = model.with_context( + search_default_trigger='manual', + search_default_filter_to_reorder=True, + search_default_filter_not_snoozed=True, + default_trigger='manual' + ).action_open_orderpoints() + </field> + </record> + + + <menuitem + id="menu_reordering_rules_replenish" + action="action_replenishment" + name="Replenishment" parent="menu_stock_warehouse_mgmt" sequence="10"/> <menuitem id="menu_reordering_rules_config" - action="action_orderpoint_form" - name="Reordering Rules" parent="menu_stock_inventory_control" sequence="10"/> + action="action_orderpoint" + name="Reordering Rules" parent="menu_product_in_config_stock" sequence="10"/> </odoo> diff --git a/addons/stock/views/stock_template.xml b/addons/stock/views/stock_template.xml index d41b7eab162..92c47e7ccf4 100644 --- a/addons/stock/views/stock_template.xml +++ b/addons/stock/views/stock_template.xml @@ -9,6 +9,9 @@ <script type="text/javascript" src="/stock/static/src/js/inventory_singleton_list_view.js"></script> <script type="text/javascript" src="/stock/static/src/js/inventory_validate_button_controller.js"></script> <script type="text/javascript" src="/stock/static/src/js/inventory_validate_button_view.js"></script> + <script type="text/javascript" src="/stock/static/src/js/stock_orderpoint_list_controller.js"></script> + <script type="text/javascript" src="/stock/static/src/js/stock_orderpoint_list_model.js"></script> + <script type="text/javascript" src="/stock/static/src/js/stock_orderpoint_list_view.js"></script> <script type="text/javascript" src="/stock/static/src/js/stock_traceability_report_backend.js"></script> <script type="text/javascript" src="/stock/static/src/js/stock_traceability_report_widgets.js"></script> <script type="text/javascript" src="/stock/static/src/js/popover_widget.js"></script> diff --git a/addons/stock/wizard/__init__.py b/addons/stock/wizard/__init__.py index 682b7fff5a7..fac4035407d 100644 --- a/addons/stock/wizard/__init__.py +++ b/addons/stock/wizard/__init__.py @@ -13,3 +13,4 @@ from . import stock_warn_insufficient_qty from . import product_replenish from . import stock_track_confirmation from . import stock_package_destination +from . import stock_orderpoint_snooze diff --git a/addons/stock/wizard/stock_orderpoint_snooze.py b/addons/stock/wizard/stock_orderpoint_snooze.py new file mode 100644 index 00000000000..00808dbb182 --- /dev/null +++ b/addons/stock/wizard/stock_orderpoint_snooze.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models +from odoo.tools.date_utils import add + + +class StockOrderpointSnooze(models.TransientModel): + _name = 'stock.orderpoint.snooze' + _description = 'Snooze Orderpoint' + + orderpoint_ids = fields.Many2many('stock.warehouse.orderpoint') + predefined_date = fields.Selection([ + ('day', '1 Day'), + ('week', '1 Week'), + ('month', '1 Month'), + ('custom', 'Custom') + ], string='Snooze for', default='day') + snoozed_until = fields.Date('Snooze Date') + + @api.onchange('predefined_date') + def _onchange_predefined_date(self): + today = fields.Date.today() + if self.predefined_date == 'day': + self.snoozed_until = add(today, days=1) + elif self.predefined_date == 'week': + self.snoozed_until = add(today, weeks=1) + elif self.predefined_date == 'month': + self.snoozed_until = add(today, months=1) + + def action_snooze(self): + self.orderpoint_ids.write({ + 'snoozed_until': self.snoozed_until + }) diff --git a/addons/stock/wizard/stock_orderpoint_snooze_views.xml b/addons/stock/wizard/stock_orderpoint_snooze_views.xml new file mode 100644 index 00000000000..d3b2d357ad4 --- /dev/null +++ b/addons/stock/wizard/stock_orderpoint_snooze_views.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="UTF-8"?> +<odoo> + <record id="view_stock_orderpoint_snooze" model="ir.ui.view"> + <field name="name">Stock Orderpoint Snooze</field> + <field name="model">stock.orderpoint.snooze</field> + <field name="arch" type="xml"> + <form string="Snooze"> + <group> + <field name="orderpoint_ids" invisible="1"/> + <field name="predefined_date" widget="radio"/> + <field name="snoozed_until" attrs="{'readonly': [('predefined_date', '!=', 'custom')]}" force_save="1"/> + </group> + <footer> + <button string="Snooze" name="action_snooze" type="object" class="btn-primary"/> + <button string="Discard" name="cancel_button" class="btn-secondary" special="cancel"/> + </footer> + </form> + </field> + </record> + + <record id="action_orderpoint_snooze" model="ir.actions.act_window"> + <field name="name">Snooze</field> + <field name="res_model">stock.orderpoint.snooze</field> + <field name="view_mode">form</field> + <field name="target">new</field> + </record> +</odoo> |
