diff --git a/addons/mrp/__manifest__.py b/addons/mrp/__manifest__.py index 6206a3a1c91..ef0f7b83442 100644 --- a/addons/mrp/__manifest__.py +++ b/addons/mrp/__manifest__.py @@ -38,6 +38,7 @@ 'report/mrp_report_views_main.xml', 'report/mrp_report_bom_structure.xml', 'report/mrp_production_templates.xml', + 'report/report_stock_rule.xml', ], 'qweb': ['static/src/xml/mrp.xml'], 'demo': [ diff --git a/addons/mrp/report/__init__.py b/addons/mrp/report/__init__.py index 29fee71b20b..c2e895dd1f6 100644 --- a/addons/mrp/report/__init__.py +++ b/addons/mrp/report/__init__.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import mrp_report_bom_structure \ No newline at end of file +from . import mrp_report_bom_structure +from . import report_stock_rule diff --git a/addons/mrp/report/report_stock_rule.py b/addons/mrp/report/report_stock_rule.py new file mode 100644 index 00000000000..d25960d1183 --- /dev/null +++ b/addons/mrp/report/report_stock_rule.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models + + +class ReportStockRule(models.AbstractModel): + _inherit = 'report.stock.report_stock_rule' + + @api.model + def _get_rule_loc(self, rule, product_id): + """ We override this method to handle manufacture rule which do not have a location_src_id. + """ + res = super(ReportStockRule, self)._get_rule_loc(rule, product_id) + if rule.action == 'manufacture': + res['source'] = product_id.property_stock_production or self.env.ref('stock.location_production') + return res diff --git a/addons/mrp/report/report_stock_rule.xml b/addons/mrp/report/report_stock_rule.xml new file mode 100644 index 00000000000..e87c60e9bfe --- /dev/null +++ b/addons/mrp/report/report_stock_rule.xml @@ -0,0 +1,32 @@ + + + + diff --git a/addons/purchase_stock/__manifest__.py b/addons/purchase_stock/__manifest__.py index ec65ec67d45..d3d6c68eee1 100644 --- a/addons/purchase_stock/__manifest__.py +++ b/addons/purchase_stock/__manifest__.py @@ -20,6 +20,7 @@ 'views/stock_production_lot_views.xml', 'report/purchase_report_views.xml', 'report/purchase_report_templates.xml', + 'report/report_stock_rule.xml', ], 'demo': [ 'data/purchase_stock_demo.xml', diff --git a/addons/purchase_stock/report/__init__.py b/addons/purchase_stock/report/__init__.py index b8436ab280e..129d295b8be 100644 --- a/addons/purchase_stock/report/__init__.py +++ b/addons/purchase_stock/report/__init__.py @@ -2,3 +2,4 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import purchase_report +from . import report_stock_rule diff --git a/addons/purchase_stock/report/report_stock_rule.py b/addons/purchase_stock/report/report_stock_rule.py new file mode 100644 index 00000000000..cc92df7ca76 --- /dev/null +++ b/addons/purchase_stock/report/report_stock_rule.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models + + +class ReportStockRule(models.AbstractModel): + _inherit = 'report.stock.report_stock_rule' + + @api.model + def _get_rule_loc(self, rule, product_id): + """ We override this method to handle buy rules which do not have a location_src_id. + """ + res = super(ReportStockRule, self)._get_rule_loc(rule, product_id) + if rule.action == 'buy': + res['source'] = self.env.ref('stock.stock_location_suppliers') + return res diff --git a/addons/purchase_stock/report/report_stock_rule.xml b/addons/purchase_stock/report/report_stock_rule.xml new file mode 100644 index 00000000000..7d524099e06 --- /dev/null +++ b/addons/purchase_stock/report/report_stock_rule.xml @@ -0,0 +1,32 @@ + + + + \ No newline at end of file diff --git a/addons/sale_stock/__init__.py b/addons/sale_stock/__init__.py index 5905766d867..4e9b019bd7a 100644 --- a/addons/sale_stock/__init__.py +++ b/addons/sale_stock/__init__.py @@ -4,3 +4,4 @@ from . import controllers from . import models from . import report +from . import wizard diff --git a/addons/sale_stock/__manifest__.py b/addons/sale_stock/__manifest__.py index a1be951ecbc..79bab5a7f4c 100644 --- a/addons/sale_stock/__manifest__.py +++ b/addons/sale_stock/__manifest__.py @@ -38,6 +38,7 @@ You can choose flexible invoicing methods: 'report/sale_order_report_templates.xml', 'report/stock_report_deliveryslip.xml', 'data/sale_stock_data.xml', + 'wizard/stock_rules_report_views.xml', ], 'demo': ['data/sale_order_demo.xml'], 'installable': True, diff --git a/addons/sale_stock/report/__init__.py b/addons/sale_stock/report/__init__.py index 7b0761d5199..660a07d84e2 100644 --- a/addons/sale_stock/report/__init__.py +++ b/addons/sale_stock/report/__init__.py @@ -2,3 +2,4 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import sale_report +from . import report_stock_rule diff --git a/addons/sale_stock/report/report_stock_rule.py b/addons/sale_stock/report/report_stock_rule.py new file mode 100644 index 00000000000..6feab37901a --- /dev/null +++ b/addons/sale_stock/report/report_stock_rule.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models + + +class ReportStockRule(models.AbstractModel): + _inherit = 'report.stock.report_stock_rule' + + @api.model + def _get_routes(self, data): + res = super(ReportStockRule, self)._get_routes(data) + if data.get('so_route_ids'): + res = self.env['stock.location.route'].browse(data['so_route_ids']) | res + return res diff --git a/addons/sale_stock/wizard/__init__.py b/addons/sale_stock/wizard/__init__.py new file mode 100644 index 00000000000..5b103cfeab6 --- /dev/null +++ b/addons/sale_stock/wizard/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import stock_rules_report diff --git a/addons/sale_stock/wizard/stock_rules_report.py b/addons/sale_stock/wizard/stock_rules_report.py new file mode 100644 index 00000000000..53d052ad0d6 --- /dev/null +++ b/addons/sale_stock/wizard/stock_rules_report.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class StockRulesReport(models.TransientModel): + _inherit = 'stock.rules.report' + + so_route_ids = fields.Many2many('stock.location.route', string='Apply specific rotues', + domain="[('sale_selectable', '=', True)]", help="Choose to apply SO lines specific routes.") + + def _prepare_report_data(self): + data = super(StockRulesReport, self)._prepare_report_data() + data['so_route_ids'] = self.so_route_ids.ids + return data diff --git a/addons/sale_stock/wizard/stock_rules_report_views.xml b/addons/sale_stock/wizard/stock_rules_report_views.xml new file mode 100644 index 00000000000..1fb6594f6cc --- /dev/null +++ b/addons/sale_stock/wizard/stock_rules_report_views.xml @@ -0,0 +1,13 @@ + + + + Stock Rules Report Sale + stock.rules.report + + + + + + + + diff --git a/addons/stock/__manifest__.py b/addons/stock/__manifest__.py index 073a496d66b..2c6032dffe5 100644 --- a/addons/stock/__manifest__.py +++ b/addons/stock/__manifest__.py @@ -34,6 +34,7 @@ 'report/report_stockpicking_operations.xml', 'report/report_deliveryslip.xml', 'report/report_stockinventory.xml', + 'report/report_stock_rule.xml', 'wizard/stock_change_product_qty_views.xml', 'wizard/stock_picking_return_views.xml', @@ -42,6 +43,7 @@ 'wizard/stock_backorder_confirmation_views.xml', 'wizard/stock_overprocessed_transfer_views.xml', 'wizard/stock_quantity_history.xml', + 'wizard/stock_rules_report_views.xml', 'wizard/stock_warn_insufficient_qty_views.xml', 'wizard/product_replenish_views.xml', 'wizard/stock_track_confirmation_views.xml', diff --git a/addons/stock/models/product.py b/addons/stock/models/product.py index 5c91fff6af2..d61e7f37fac 100644 --- a/addons/stock/models/product.py +++ b/addons/stock/models/product.py @@ -526,12 +526,6 @@ class ProductTemplate(models.Model): raise UserError(_("You can not change the type of a product that is currently reserved on a stock move. If you need to change the type, you should first unreserve the stock move.")) return super(ProductTemplate, self).write(vals) - def action_view_routes(self): - routes = self.mapped('route_ids') | self.mapped('categ_id').mapped('total_route_ids') | self.env['stock.location.route'].search([('warehouse_selectable', '=', True)]) - action = self.env.ref('stock.action_routes_form').read()[0] - action['domain'] = [('id', 'in', routes.ids)] - return action - def action_update_quantity_on_hand(self): default_product_id = self.env.context.get('default_product_id', self.product_variant_id.id) if self.env.user.user_has_groups('stock.group_stock_multi_locations') or (self.env.user.user_has_groups('stock.group_production_lot') and self.tracking != 'none'): diff --git a/addons/stock/models/product_strategy.py b/addons/stock/models/product_strategy.py index eedb86afea2..9271e87c431 100644 --- a/addons/stock/models/product_strategy.py +++ b/addons/stock/models/product_strategy.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import fields, models +from odoo import fields, models, api class RemovalStrategy(models.Model): @@ -26,16 +26,22 @@ class PutAwayStrategy(models.Model): 'Fixed Locations Per Product', domain=[('product_id', '!=', False)], copy=True) def putaway_apply(self, product): + put_away = self._get_putaway_rule(product) + if put_away: + return put_away.fixed_location_id + return self.env['stock.location'] + + def _get_putaway_rule(self, product): if self.product_location_ids: put_away = self.product_location_ids.filtered(lambda x: x.product_id == product) if put_away: - return put_away[0].fixed_location_id + return put_away[0] if self.fixed_location_ids: categ = product.categ_id while categ: put_away = self.fixed_location_ids.filtered(lambda x: x.category_id == categ) if put_away: - return put_away[0].fixed_location_id + return put_away[0] categ = categ.parent_id return self.env['stock.location'] diff --git a/addons/stock/report/__init__.py b/addons/stock/report/__init__.py index a8131e94c8c..fd4307f0a92 100644 --- a/addons/stock/report/__init__.py +++ b/addons/stock/report/__init__.py @@ -2,3 +2,4 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import report_stock_forecast +from . import report_stock_rule diff --git a/addons/stock/report/report_stock_rule.py b/addons/stock/report/report_stock_rule.py new file mode 100644 index 00000000000..cb97877cac1 --- /dev/null +++ b/addons/stock/report/report_stock_rule.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models, _ +from odoo.exceptions import UserError + + +class ReportStockRule(models.AbstractModel): + _name = 'report.stock.report_stock_rule' + + @api.model + def _get_report_values(self, docids, data=None): + product = self.env['product.product'].browse(data['product_id']) + warehouses = self.env['stock.warehouse'].browse(data['warehouse_ids']) + + routes = self._get_routes(data) + + # Some routes don't have a warehouse_id but contain rules of different warehouses, + # we filter here the ones we want to display and build for each one a dict containing the rule, + # their source and destination location. + relevant_rules = routes.mapped('rule_ids').filtered(lambda r: not r.warehouse_id or r.warehouse_id in warehouses) + rules_and_loc = [] + for rule in relevant_rules: + rules_and_loc.append(self._get_rule_loc(rule, product)) + + locations = self._sort_locations(rules_and_loc, warehouses) + reordering_rules = self.env['stock.warehouse.orderpoint'].search([('product_id', '=', product.id)]) + locations |= reordering_rules.mapped('location_id').filtered(lambda l: l not in locations) + locations_names = locations.mapped('display_name') + # Here we handle reordering rules and putaway strategies by creating the header_lines dict. This dict is indexed + # by location_id and contains itself another dict with the relevant reordering rules and putaway strategies. + header_lines = {} + for location in locations: + # TODO: group the RR by location_id to avoid a filtered at each loop + rr = reordering_rules.filtered(lambda r: r.location_id.id == location.id) + if location.putaway_strategy_id or rr: + header_lines[location.id] = {'putaway': [], 'orderpoint': []} + if location.putaway_strategy_id: + putaway = location.putaway_strategy_id._get_putaway_rule(product) + header_lines[location.id]['putaway'].append(putaway) + if rr: + for r in rr: + header_lines[location.id]['orderpoint'].append(r) + route_lines = [] + colors = self._get_route_colors() + for color_index, route in enumerate(routes): + rules_to_display = route.rule_ids & relevant_rules + if rules_to_display: + route_color = colors[color_index % len(colors)] + color_index = color_index + 1 + for rule in rules_to_display: + rule_loc = [r for r in rules_and_loc if r['rule'] == rule][0] + res = [] + for x in range(len(locations_names)): + res.append([]) + idx = locations_names.index(rule_loc['destination'].display_name) + tpl = (rule, 'destination', route_color, ) + res[idx] = tpl + idx = locations_names.index(rule_loc['source'].display_name) + tpl = (rule, 'origin', route_color, ) + res[idx] = tpl + route_lines.append(res) + return { + 'docs': product, + 'locations': locations, + 'header_lines': header_lines, + 'route_lines': route_lines, + } + + @api.model + def _get_route_colors(self): + return ['#FFA500', '#800080', '#228B22', '#008B8B', '#4682B4', '#FF0000', '#32CD32'] + + @api.model + def _get_routes(self, data): + """ Extract the routes to display from the wizard's content. + """ + product = self.env['product.product'].browse(data['product_id']) + warehouse_ids = self.env['stock.warehouse'].browse(data['warehouse_ids']) + return product.route_ids | product.categ_id.total_route_ids | warehouse_ids.mapped('route_ids') + + @api.model + def _get_rule_loc(self, rule, product): + rule.ensure_one() + return {'rule': rule, 'source': rule.location_src_id, 'destination': rule.location_id} + + @api.model + def _sort_locations(self, rules_and_loc, warehouses): + """ We order the locations by setting first the locations of type supplier and manufacture, + then we add the locations grouped by warehouse and we finish by the locations of type + customer and the ones that were not added by the sort. + """ + all_src = self.env['stock.location'].concat(*([r['source'] for r in rules_and_loc])) + all_dest = self.env['stock.location'].concat(*([r['destination'] for r in rules_and_loc])) + all_locations = all_src | all_dest + ordered_locations = self.env['stock.location'] + locations = all_locations.filtered(lambda l: l.usage in ('supplier', 'production')) + for warehouse_id in warehouses: + all_warehouse_locations = all_locations.filtered(lambda l: l.get_warehouse() == warehouse_id) + starting_rules = [d for d in rules_and_loc if d['source'] not in all_warehouse_locations] + if starting_rules: + start_locations = self.env['stock.location'].concat(*([r['destination'] for r in starting_rules])) + else: + starting_rules = [d for d in rules_and_loc if d['source'] not in all_dest] + start_locations = self.env['stock.location'].concat(*([r['source'] for r in starting_rules])) + used_rules = self.env['stock.rule'] + locations |= self._sort_locations_by_warehouse(rules_and_loc, used_rules, start_locations, ordered_locations, warehouse_id) + if any(location not in locations for location in all_warehouse_locations): + remaining_locations = self.env['stock.location'].concat(*([r['source'] for r in rules_and_loc])).filtered(lambda l: l not in locations) + locations |= self._sort_locations_by_warehouse(rules_and_loc, used_rules, remaining_locations, ordered_locations, warehouse_id) + locations |= all_locations.filtered(lambda l: l.usage in ('customer')) + locations |= all_locations.filtered(lambda l: l not in locations) + return locations + + @api.model + def _sort_locations_by_warehouse(self, rules_and_loc, used_rules, start_locations, ordered_locations, warehouse_id): + """ We order locations by putting first the locations that are not the destination of others and do it recursively. + """ + start_locations = start_locations.filtered(lambda l: l.get_warehouse() == warehouse_id) + ordered_locations |= start_locations + rules_start = [] + for rule in rules_and_loc: + if rule['source'] in start_locations: + rules_start.append(rule) + used_rules |= rule['rule'] + if rules_start: + rules_start_dest_locations = self.env['stock.location'].concat(*([r['destination'] for r in rules_start])) + remaining_rules = self.env['stock.rule'].concat(*([r['rule'] for r in rules_and_loc])) - used_rules + remaining_rules_location = self.env['stock.location'] + for r in rules_and_loc: + if r['rule'] in remaining_rules: + remaining_rules_location |= r['destination'] + start_locations = rules_start_dest_locations - ordered_locations - remaining_rules_location + ordered_locations = self._sort_locations_by_warehouse(rules_and_loc, used_rules, start_locations, ordered_locations, warehouse_id) + return ordered_locations diff --git a/addons/stock/report/report_stock_rule.xml b/addons/stock/report/report_stock_rule.xml new file mode 100644 index 00000000000..1e8fefc5b44 --- /dev/null +++ b/addons/stock/report/report_stock_rule.xml @@ -0,0 +1,169 @@ + + + + + + + + + + + diff --git a/addons/stock/report/stock_report_views.xml b/addons/stock/report/stock_report_views.xml index 987d34c3db8..8a4c845d188 100644 --- a/addons/stock/report/stock_report_views.xml +++ b/addons/stock/report/stock_report_views.xml @@ -60,5 +60,14 @@ file="stock.report_lot_label" print_report_name="'Lot-Serial - %s' % object.name" /> + diff --git a/addons/stock/static/src/scss/report_stock_rule.scss b/addons/stock/static/src/scss/report_stock_rule.scss new file mode 100644 index 00000000000..86ccafdddbe --- /dev/null +++ b/addons/stock/static/src/scss/report_stock_rule.scss @@ -0,0 +1,110 @@ +.o_report_stock_rule{ + .o_report_stock_rule_rule { + display: flex; + flex-flow: row nowrap; + } + .o_report_stock_rule_legend { + display: flex; + flex-flow: row wrap; + max-width: 1000px; + } + + .o_report_stock_rule_legend_line { + flex: 0 1 auto; + display: flex; + flex-flow: row nowrap; + width: 29%; + margin-right: 20px; + margin-left: 20px; + margin-top: 15px; + min-width: 200px; + >.o_report_stock_rule_legend_label { + flex: 1 1 auto; + width: 30%; + min-width: 100px; + } + >.o_report_stock_rule_legend_symbol { + flex: 1 1 auto; + width: 70%; + } + } + + + .o_report_stock_rule_putaway { + >p { + text-align: center; + color: black; + font-weight: normal; + font-size: 12px + } + } + + .o_report_stock_rule_line { + flex: 1 1 auto; + height: 20px; + >line { + stroke: black; + stroke-width: 1; + } + } + + .o_report_stock_rule_arrow { + flex: 0 0 auto; + height: 20px; + width: 20px; + >svg { + >line { + stroke: black; + stroke-width: 1; + } + >polygon { + fill: black; + fill-opacity: 0.5; + stroke: black; + stroke-width: 1; + } + } + } + + .o_report_stock_rule_rule_name { + text-align: center; + } + + .o_report_stock_rule_symbol_cell { + border: none !important; + >div { + max-width: 200px; + height: 20px; + } + } + + .o_report_stock_rule_rule_main { + height: 100%; + padding-top: 2px; + } + .o_report_stock_rule_location_header { + text-align: center; + >a { + display: block; + &:hover { + text-decoration: none; + cursor: pointer; + background-color: #efefef; + } + >div { + color: black; + } + } + } + .o_report_stock_rule_rule_cell { + padding:0 !important; + >a { + display: block; + &:hover { + text-decoration: none; + cursor: pointer; + background-color: #efefef; + } + } + } +} diff --git a/addons/stock/views/product_views.xml b/addons/stock/views/product_views.xml index c75a33ff095..076120a9c37 100644 --- a/addons/stock/views/product_views.xml +++ b/addons/stock/views/product_views.xml @@ -243,10 +243,12 @@ - -