From 94a3919d09749937e5cbd76d53ff81b503eb183e Mon Sep 17 00:00:00 2001 From: Pierre Masereel Date: Mon, 16 Jul 2018 10:35:16 +0200 Subject: [PATCH] [ADD] stock, sale_stock, purchase_stock, mrp: Routes 2D report We are adding a new report available on products that displays all the routes applicable on the products and dislay them as arrows between the different locations. We also display the putaway rules and reordering rules on locations where they are applied. TASK-46972 --- addons/mrp/__manifest__.py | 1 + addons/mrp/report/__init__.py | 3 +- addons/mrp/report/report_stock_rule.py | 17 ++ addons/mrp/report/report_stock_rule.xml | 32 ++++ addons/purchase_stock/__manifest__.py | 1 + addons/purchase_stock/report/__init__.py | 1 + .../report/report_stock_rule.py | 17 ++ .../report/report_stock_rule.xml | 32 ++++ addons/sale_stock/__init__.py | 1 + addons/sale_stock/__manifest__.py | 1 + addons/sale_stock/report/__init__.py | 1 + addons/sale_stock/report/report_stock_rule.py | 15 ++ addons/sale_stock/wizard/__init__.py | 4 + .../sale_stock/wizard/stock_rules_report.py | 16 ++ .../wizard/stock_rules_report_views.xml | 13 ++ addons/stock/__manifest__.py | 2 + addons/stock/models/product.py | 6 - addons/stock/models/product_strategy.py | 12 +- addons/stock/report/__init__.py | 1 + addons/stock/report/report_stock_rule.py | 135 ++++++++++++++ addons/stock/report/report_stock_rule.xml | 169 ++++++++++++++++++ addons/stock/report/stock_report_views.xml | 9 + .../static/src/scss/report_stock_rule.scss | 110 ++++++++++++ addons/stock/views/product_views.xml | 15 +- addons/stock/wizard/__init__.py | 1 + addons/stock/wizard/stock_rules_report.py | 48 +++++ .../stock/wizard/stock_rules_report_views.xml | 38 ++++ 27 files changed, 685 insertions(+), 16 deletions(-) create mode 100644 addons/mrp/report/report_stock_rule.py create mode 100644 addons/mrp/report/report_stock_rule.xml create mode 100644 addons/purchase_stock/report/report_stock_rule.py create mode 100644 addons/purchase_stock/report/report_stock_rule.xml create mode 100644 addons/sale_stock/report/report_stock_rule.py create mode 100644 addons/sale_stock/wizard/__init__.py create mode 100644 addons/sale_stock/wizard/stock_rules_report.py create mode 100644 addons/sale_stock/wizard/stock_rules_report_views.xml create mode 100644 addons/stock/report/report_stock_rule.py create mode 100644 addons/stock/report/report_stock_rule.xml create mode 100644 addons/stock/static/src/scss/report_stock_rule.scss create mode 100644 addons/stock/wizard/stock_rules_report.py create mode 100644 addons/stock/wizard/stock_rules_report_views.xml 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 @@ - -