diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index 00b2752f8a3..483be6c2e2f 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -728,9 +728,9 @@ class MrpProduction(models.Model): @api.onchange('lot_producing_id') def _onchange_lot_producing(self): if self.product_id.tracking == 'serial' and self.lot_producing_id: - message, dummy = self.env['stock.quant']._check_serial_number(self.product_id, - self.lot_producing_id, - self.company_id) + message, dummy = self.env['stock.quant'].sudo()._check_serial_number(self.product_id, + self.lot_producing_id, + self.company_id) if message: return {'warning': {'title': _('Warning'), 'message': message}} @@ -782,8 +782,7 @@ class MrpProduction(models.Model): finished_move_lines.write({'lot_id': vals.get('lot_producing_id')}) if 'qty_producing' in vals: finished_move_lines.write({'qty_done': vals.get('qty_producing')}) - - if not production.workorder_ids.operation_id and vals.get('date_planned_start') and not vals.get('date_planned_finished'): + if self._has_workorders() and not production.workorder_ids.operation_id and vals.get('date_planned_start') and not vals.get('date_planned_finished'): new_date_planned_start = fields.Datetime.to_datetime(vals.get('date_planned_start')) if not production.date_planned_finished or new_date_planned_start >= production.date_planned_finished: production.date_planned_finished = new_date_planned_start + datetime.timedelta(hours=1) @@ -2036,6 +2035,9 @@ class MrpProduction(models.Model): 'target': 'main', } + def _has_workorders(self): + return self.workorder_ids + @api.model def _prepare_procurement_group_vals(self, values): return {'name': values['name']} diff --git a/addons/mrp/models/stock_move.py b/addons/mrp/models/stock_move.py index 6e299685e37..b314e9ab5c4 100644 --- a/addons/mrp/models/stock_move.py +++ b/addons/mrp/models/stock_move.py @@ -132,7 +132,7 @@ class StockMove(models.Model): 'Done', compute='_compute_is_done', store=True, help='Technical Field to order moves') - order_finished_lot_ids = fields.Many2many('stock.lot', string="Finished Lot/Serial Number", compute='_compute_order_finished_lot_ids') + order_finished_lot_id = fields.Many2one('stock.lot', string="Finished Lot/Serial Number", related="raw_material_production_id.lot_producing_id", store=True) should_consume_qty = fields.Float('Quantity To Consume', compute='_compute_should_consume_qty', digits='Product Unit of Measure') cost_share = fields.Float( "Cost Share (%)", digits=(5, 2), # decimal = 2 is important for rounding calculations!! @@ -180,11 +180,6 @@ class StockMove(models.Model): if move.raw_material_production_id or move.production_id: move.picking_type_id = (move.raw_material_production_id or move.production_id).picking_type_id - @api.depends('raw_material_production_id.lot_producing_id') - def _compute_order_finished_lot_ids(self): - for move in self: - move.order_finished_lot_ids = move.raw_material_production_id.lot_producing_id - @api.depends('raw_material_production_id.is_locked', 'production_id.is_locked') def _compute_is_locked(self): super(StockMove, self)._compute_is_locked() diff --git a/addons/mrp_subcontracting/__init__.py b/addons/mrp_subcontracting/__init__.py index ae586949745..dfbd96001d5 100644 --- a/addons/mrp_subcontracting/__init__.py +++ b/addons/mrp_subcontracting/__init__.py @@ -3,6 +3,7 @@ from . import models from . import report from . import wizard +from . import controllers from odoo import SUPERUSER_ID, api diff --git a/addons/mrp_subcontracting/__manifest__.py b/addons/mrp_subcontracting/__manifest__.py index 34a473c4b60..55bb8dcf534 100644 --- a/addons/mrp_subcontracting/__manifest__.py +++ b/addons/mrp_subcontracting/__manifest__.py @@ -11,6 +11,8 @@ 'depends': ['mrp'], 'data': [ 'data/mrp_subcontracting_data.xml', + 'security/mrp_subcontracting_security.xml', + 'security/ir.model.access.csv', 'views/mrp_bom_views.xml', 'views/res_partner_views.xml', 'views/stock_warehouse_views.xml', @@ -20,12 +22,33 @@ 'views/supplier_info_views.xml', 'views/product_views.xml', 'views/mrp_production_views.xml', + 'views/subcontracting_portal_views.xml', + 'views/subcontracting_portal_templates.xml', 'wizard/stock_picking_return_views.xml', 'report/mrp_report_bom_structure.xml', ], 'demo': [ 'data/mrp_subcontracting_demo.xml', ], + 'assets': { + 'web.assets_tests': [ + 'mrp_subcontracting/static/tests/tours/subcontracting_portal_tour.js', + ], + 'mrp_subcontracting.assets_qweb': [ + ('include', 'web.assets_qweb'), + 'mrp_subcontracting/static/src/subcontracting_portal/subcontracting_portal.xml', + ], + 'mrp_subcontracting.webclient': [ + ('include', 'web.assets_backend'), + ('remove', 'web/static/src/webclient/menus/*.js'), + 'mrp_subcontracting/static/src/subcontracting_portal/main.js', + 'mrp_subcontracting/static/src/subcontracting_portal/subcontracting_portal.js', + 'mrp_subcontracting/static/src/subcontracting_portal/picking_form_controller.js', + 'mrp_subcontracting/static/src/subcontracting_portal/move_list_view.js', + 'web/static/src/start.js', + 'web/static/src/legacy/legacy_setup.js', + ], + }, 'uninstall_hook': 'uninstall_hook', 'license': 'LGPL-3', } diff --git a/addons/mrp_subcontracting/controllers/__init__.py b/addons/mrp_subcontracting/controllers/__init__.py new file mode 100644 index 00000000000..903b755e71e --- /dev/null +++ b/addons/mrp_subcontracting/controllers/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import portal diff --git a/addons/mrp_subcontracting/controllers/portal.py b/addons/mrp_subcontracting/controllers/portal.py new file mode 100644 index 00000000000..a24b41c6401 --- /dev/null +++ b/addons/mrp_subcontracting/controllers/portal.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import werkzeug +from collections import OrderedDict + +from odoo import conf, http, _ +from odoo.http import request +from odoo.exceptions import AccessError, MissingError +from odoo.addons.portal.controllers import portal +from odoo.addons.portal.controllers.portal import pager as portal_pager +from odoo.addons.web.controllers.main import HomeStaticTemplateHelpers + + +class CustomerPortal(portal.CustomerPortal): + + def _prepare_home_portal_values(self, counters): + values = super()._prepare_home_portal_values(counters) + if 'production_count' in counters: + commercial_partner = request.env.user.partner_id.commercial_partner_id + values['production_count'] = request.env['stock.picking'].search_count([('partner_id.commercial_partner_id', '=', commercial_partner.id), ('move_ids.is_subcontract', '=', True)]) + return values + + @http.route(['/my/productions', '/my/productions/page/'], type='http', auth="user", website=True) + def portal_my_productions(self, page=1, date_begin=None, date_end=None, sortby='date', filterby='all'): + commercial_partner = request.env.user.partner_id.commercial_partner_id + StockPicking = request.env['stock.picking'] + domain = [('partner_id.commercial_partner_id', '=', commercial_partner.id), ('move_ids.is_subcontract', '=', True)] + + if date_begin and date_end: + domain += [('create_date', '>', date_begin), ('create_date', '<=', date_end)] + + searchbar_filters = { + 'all': {'label': _('All'), 'domain': []}, + 'done': {'label': _('Done'), 'domain': [('state', '=', 'done')]}, + 'ready': {'label': _('Ready'), 'domain': [('state', '=', 'assigned')]}, + } + domain += searchbar_filters[filterby]['domain'] + + searchbar_sortings = { + 'date': {'label': _('Newest'), 'order': 'create_date desc, id desc'}, + 'name': {'label': _('Name'), 'order': 'name asc, id asc'}, + } + order = searchbar_sortings[sortby]['order'] + # count for pager + count = StockPicking.search_count(domain) + # make pager + pager = portal_pager( + url='/my/productions', + url_args={'date_begin': date_begin, 'date_end': date_end, 'sortby': sortby}, + total=count, + page=page, + step=self._items_per_page + ) + # search the pickings to display, according to the pager data + pickings = StockPicking.search( + domain, + order=order, + limit=self._items_per_page, + offset=pager['offset'] + ) + + values = { + 'date': date_begin, + 'pickings': pickings, + 'page_name': 'production', + 'pager': pager, + 'searchbar_sortings': searchbar_sortings, + 'sortby': sortby, + 'searchbar_filters': OrderedDict(sorted(searchbar_filters.items())), + 'filterby': filterby, + 'default_url': '/my/productions', + } + + return http.request.render("mrp_subcontracting.portal_my_productions", values) + + @http.route("/my/productions/", type="http", auth="user", methods=['GET'], website=True) + def portal_my_production(self, picking_id): + try: + self._document_check_access('stock.picking', picking_id) + except (AccessError, MissingError): + raise werkzeug.exceptions.NotFound + return request.render("mrp_subcontracting.subcontracting_portal", {'picking_id': picking_id}) + + @http.route("/my/productions//subcontracting_portal", type="http", auth="user", methods=['GET']) + def render_production_backend_view(self, picking_id): + try: + picking = self._document_check_access('stock.picking', picking_id) + except (AccessError, MissingError): + raise werkzeug.exceptions.NotFound + session_info = request.env['ir.http'].session_info() + user_context = dict(request.env.context) if request.session.uid else {} + mods = conf.server_wide_modules or [] + qweb_checksum = HomeStaticTemplateHelpers.get_qweb_templates_checksum(debug=request.session.debug, bundle="mrp_subcontracting.assets_qweb") + lang = user_context.get("lang") + translation_hash = request.env['ir.translation'].get_web_translations_hash(mods, lang) + cache_hashes = { + "qweb": qweb_checksum, + "translations": translation_hash, + } + production_company = picking.company_id + session_info.update( + cache_hashes=cache_hashes, + action_name='mrp_subcontracting.subcontracting_portal_view_production_action', + picking_id=picking.id, + user_companies={ + 'current_company': production_company.id, + 'allowed_companies': { + production_company.id: { + 'id': production_company.id, + 'name': production_company.name, + }, + }, + }) + + return request.render( + 'mrp_subcontracting.subcontracting_portal_embed', + {'session_info': session_info}, + ) diff --git a/addons/mrp_subcontracting/data/mrp_subcontracting_demo.xml b/addons/mrp_subcontracting/data/mrp_subcontracting_demo.xml index fc5f25a9b12..ca6a885ecf8 100644 --- a/addons/mrp_subcontracting/data/mrp_subcontracting_demo.xml +++ b/addons/mrp_subcontracting/data/mrp_subcontracting_demo.xml @@ -5,7 +5,7 @@ subcontract - + @@ -30,4 +30,4 @@ - \ No newline at end of file + diff --git a/addons/mrp_subcontracting/models/__init__.py b/addons/mrp_subcontracting/models/__init__.py index fc944c6b955..33434a1c1f6 100644 --- a/addons/mrp_subcontracting/models/__init__.py +++ b/addons/mrp_subcontracting/models/__init__.py @@ -4,6 +4,7 @@ from . import mrp_bom from . import product from . import res_company from . import res_partner +from . import stock_location from . import stock_move from . import stock_move_line from . import stock_picking diff --git a/addons/mrp_subcontracting/models/mrp_production.py b/addons/mrp_subcontracting/models/mrp_production.py index 6ddafcd1674..45720decf3c 100644 --- a/addons/mrp_subcontracting/models/mrp_production.py +++ b/addons/mrp_subcontracting/models/mrp_production.py @@ -3,7 +3,7 @@ from collections import defaultdict from odoo import fields, models, _, api -from odoo.exceptions import UserError, ValidationError +from odoo.exceptions import UserError, ValidationError, AccessError from odoo.tools.float_utils import float_compare, float_is_zero @@ -16,6 +16,8 @@ class MrpProduction(models.Model): inverse='_inverse_move_line_raw_ids', compute='_compute_move_line_raw_ids' ) subcontracting_has_been_recorded = fields.Boolean("Has been recorded?", copy=False) + subcontractor_id = fields.Many2one('res.partner', string="Subcontractor", help="Used to restrict access to the portal user through Record Rules") + bom_product_ids = fields.Many2many('product.product', compute="_compute_bom_product_ids", help="List of Products used in the BoM, used to filter the list of products in the subcontracting portal view") incoming_picking = fields.Many2one(related='move_finished_ids.move_dest_ids.picking_id') @@ -31,6 +33,10 @@ class MrpProduction(models.Model): for production in self: production.move_line_raw_ids = production.move_raw_ids.move_line_ids + def _compute_bom_product_ids(self): + for production in self: + production.bom_product_ids = production.bom_id.bom_line_ids.product_id + def _inverse_move_line_raw_ids(self): for production in self: line_by_product = defaultdict(lambda: self.env['stock.move.line']) @@ -45,6 +51,13 @@ class MrpProduction(models.Model): production.move_raw_ids = [(0, 0, move)] production.move_raw_ids.filtered(lambda m: m.product_id == product_id)[:1].move_line_ids = lines + def write(self, vals): + if self.env.user.has_group('base.group_portal') and not self.env.su: + unauthorized_fields = set(vals.keys()) - set(self._get_writeable_fields_portal_user()) + if unauthorized_fields: + raise AccessError(_("You cannot write on fields %s in mrp.production.", ', '.join(unauthorized_fields))) + return super().write(vals) + def action_merge(self): if any(production._get_subcontract_move() for production in self): raise ValidationError(_("Subcontracted manufacturing orders cannot be merged.")) @@ -72,7 +85,7 @@ class MrpProduction(models.Model): quantity_issues = self._get_quantity_produced_issues() if quantity_issues: - backorder = self._split_productions()[1:] + backorder = self.sudo()._split_productions()[1:] # No qty to consume to avoid propagate additional move # TODO avoid : stock move created in backorder with 0 as qty backorder.move_raw_ids.filtered(lambda m: m.additional).product_uom_qty = 0.0 @@ -175,5 +188,14 @@ class MrpProduction(models.Model): def _has_tracked_component(self): return any(m.has_tracking != 'none' for m in self.move_raw_ids) + def _has_workorders(self): + if self.subcontractor_id: + return False + else: + return super()._has_workorders() + def _get_subcontract_move(self): return self.move_finished_ids.move_dest_ids.filtered(lambda m: m.is_subcontract) + + def _get_writeable_fields_portal_user(self): + return ['move_line_raw_ids', 'lot_producing_id', 'subcontracting_has_been_recorded', 'qty_producing', 'product_qty'] diff --git a/addons/mrp_subcontracting/models/res_partner.py b/addons/mrp_subcontracting/models/res_partner.py index 67756a80175..e2aa3f1278f 100644 --- a/addons/mrp_subcontracting/models/res_partner.py +++ b/addons/mrp_subcontracting/models/res_partner.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 api, fields, models class ResPartner(models.Model): @@ -12,7 +12,37 @@ class ResPartner(models.Model): help="The stock location used as source and destination when sending\ goods to this contact during a subcontracting process.") is_subcontractor = fields.Boolean( - string="Subcontractor", store=False, search="_search_is_subcontractor") + string="Subcontractor", store=False, search="_search_is_subcontractor", compute="_compute_is_subcontractor") + bom_ids = fields.Many2many('mrp.bom', compute='_compute_bom_ids', string="BoMs for which the Partner is one of the subcontractors") + production_ids = fields.Many2many('mrp.production', compute='_compute_production_ids', string="MRP Productions for which the Partner is the subcontractor") + picking_ids = fields.Many2many('stock.picking', compute='_compute_picking_ids', string="Stock Pickings for which the Partner is the subcontractor") + + def _compute_bom_ids(self): + results = self.env['mrp.bom'].read_group([('subcontractor_ids.commercial_partner_id', 'in', self.commercial_partner_id.ids)], ['ids:array_agg(id)', 'subcontractor_ids'], ['subcontractor_ids']) + for partner in self: + bom_ids = [] + for res in results: + if partner.id == res['subcontractor_ids'][0] or res['subcontractor_ids'][0] in partner.child_ids.ids: + bom_ids += res['ids'] + partner.bom_ids = bom_ids + + def _compute_production_ids(self): + results = self.env['mrp.production'].read_group([('subcontractor_id.commercial_partner_id', 'in', self.commercial_partner_id.ids)], ['ids:array_agg(id)'], ['subcontractor_id']) + for partner in self: + production_ids = [] + for res in results: + if partner.id == res['subcontractor_id'][0] or res['subcontractor_id'][0] in partner.child_ids.ids: + production_ids += res['ids'] + partner.production_ids = production_ids + + def _compute_picking_ids(self): + results = self.env['stock.picking'].read_group([('partner_id.commercial_partner_id', 'in', self.commercial_partner_id.ids)], ['ids:array_agg(id)'], ['partner_id']) + for partner in self: + picking_ids = [] + for res in results: + if partner.id == res['partner_id'][0] or res['partner_id'][0] in partner.child_ids.ids: + picking_ids += res['ids'] + partner.picking_ids = picking_ids def _search_is_subcontractor(self, operator, value): assert operator in ('=', '!=', '<>') and value in (True, False), 'Operation not supported' @@ -23,3 +53,13 @@ class ResPartner(models.Model): else: search_operator = 'not in' return [('id', search_operator, subcontractor_ids)] + + @api.depends_context('uid') + def _compute_is_subcontractor(self): + """ Check if the user is a subcontractor before giving sudo access + """ + for partner in self: + partner.is_subcontractor = (partner.user_has_groups('base.group_portal') and partner.env['mrp.bom'].search_count([ + ('type', '=', 'subcontract'), + ('subcontractor_ids', 'in', (partner.env.user.partner_id | partner.env.user.partner_id.commercial_partner_id).ids), + ])) diff --git a/addons/mrp_subcontracting/models/stock_location.py b/addons/mrp_subcontracting/models/stock_location.py new file mode 100644 index 00000000000..c012885b9a0 --- /dev/null +++ b/addons/mrp_subcontracting/models/stock_location.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + + +class StockLocation(models.Model): + _inherit = 'stock.location' + + def _check_access_putaway(self): + """ Use sudo mode for subcontractor """ + if self.env.user.partner_id.is_subcontractor: + return self.sudo() + else: + return super()._check_access_putaway() diff --git a/addons/mrp_subcontracting/models/stock_move.py b/addons/mrp_subcontracting/models/stock_move.py index 6adff2b907f..c8e1ff04535 100644 --- a/addons/mrp_subcontracting/models/stock_move.py +++ b/addons/mrp_subcontracting/models/stock_move.py @@ -3,8 +3,8 @@ from collections import defaultdict -from odoo import fields, models, _ -from odoo.exceptions import UserError +from odoo import fields, models, api, _ +from odoo.exceptions import UserError, AccessError from odoo.tools.float_utils import float_compare, float_is_zero @@ -37,6 +37,9 @@ class StockMove(models.Model): for move in self: if not move.is_subcontract: continue + if self.env.user.has_group('base.group_portal'): + move.show_details_visible = any(not p._has_been_recorded() for p in move._get_subcontract_production()) + continue if not move._get_subcontract_production()._has_tracked_component(): continue move.show_details_visible = True @@ -55,6 +58,7 @@ class StockMove(models.Model): """ If the initial demand is updated then also update the linked subcontract order to the new quantity. """ + self._check_access_if_subcontractor(values) if 'product_uom_qty' in values and self.env.context.get('cancel_backorder') is not False: self.filtered(lambda m: m.is_subcontract and m.state not in ['draft', 'cancel', 'done'])._update_subcontract_order_qty(values['product_uom_qty']) res = super().write(values) @@ -68,6 +72,12 @@ class StockMove(models.Model): }) return res + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + self._check_access_if_subcontractor(vals) + return super().create(vals_list) + def action_show_details(self): """ Open the produce wizard in order to register tracked components for subcontracted product. Otherwise use standard behavior. @@ -82,6 +92,11 @@ class StockMove(models.Model): 'show_lots_m2o': self.has_tracking != 'none', 'show_lots_text': False, }) + elif self.env.user.has_group('base.group_portal'): + if self.picking_type_id.show_reserved: + action['views'] = [(self.env.ref('mrp_subcontracting.mrp_subcontracting_view_stock_move_operations').id, 'form')] + else: + action['views'] = [(self.env.ref('mrp_subcontracting.mrp_subcontracting_view_stock_move_nosuggest_operations').id, 'form')] return action def action_show_subcontract_details(self): @@ -90,6 +105,9 @@ class StockMove(models.Model): tree_view = self.env.ref('mrp_subcontracting.mrp_subcontracting_move_tree_view') form_view = self.env.ref('mrp_subcontracting.mrp_subcontracting_move_form_view') ctx = dict(self._context, search_default_by_product=True) + if self.env.user.has_group('base.group_portal'): + form_view = self.env.ref('mrp_subcontracting.mrp_subcontracting_portal_move_form_view') + ctx.update(no_breadcrumbs=False) return { 'name': _('Raw Materials for %s') % (self.product_id.display_name), 'type': 'ir.actions.act_window', @@ -147,6 +165,8 @@ class StockMove(models.Model): self.ensure_one() production = self._get_subcontract_production()[-1:] view = self.env.ref('mrp_subcontracting.mrp_production_subcontracting_form_view') + if self.env.user.has_group('base.group_portal'): + view = self.env.ref('mrp_subcontracting.mrp_production_subcontracting_portal_form_view') return { 'name': _('Subcontract'), 'type': 'ir.actions.act_window', @@ -221,3 +241,8 @@ class StockMove(models.Model): 'mo_id': production.id, 'product_qty': production.product_uom_qty - quantity_to_remove }).change_prod_qty() + + def _check_access_if_subcontractor(self, vals): + if self.env.user.has_group('base.group_portal') and not self.env.su: + if vals.get('state') == 'done': + raise AccessError(_("Portal users cannot create a stock move with a state 'Done' or change the current state to 'Done'.")) diff --git a/addons/mrp_subcontracting/models/stock_picking.py b/addons/mrp_subcontracting/models/stock_picking.py index c1f0803b43c..4662125086a 100644 --- a/addons/mrp_subcontracting/models/stock_picking.py +++ b/addons/mrp_subcontracting/models/stock_picking.py @@ -10,6 +10,7 @@ from dateutil.relativedelta import relativedelta class StockPicking(models.Model): + _name = 'stock.picking' _inherit = 'stock.picking' display_action_record_components = fields.Selection( @@ -123,6 +124,8 @@ class StockPicking(models.Model): vals = { 'company_id': subcontract_move.company_id.id, 'procurement_group_id': group.id, + 'subcontractor_id': subcontract_move.picking_id.partner_id.commercial_partner_id.id, + 'picking_ids': [subcontract_move.picking_id.id], 'product_id': product.id, 'product_uom_id': subcontract_move.product_uom.id, 'bom_id': bom.id, diff --git a/addons/mrp_subcontracting/security/ir.model.access.csv b/addons/mrp_subcontracting/security/ir.model.access.csv new file mode 100644 index 00000000000..41409c2b5ee --- /dev/null +++ b/addons/mrp_subcontracting/security/ir.model.access.csv @@ -0,0 +1,17 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_subcontracting_portal_stock_picking,subcontracting.portal.picking,stock.model_stock_picking,base.group_portal,1,0,0,0 +access_subcontracting_portal_picking_type,subcontracting.portal.picking.type,stock.model_stock_picking_type,base.group_portal,1,0,0,0 +access_subcontracting_portal_stock_move,subcontracting.portal.move,stock.model_stock_move,base.group_portal,1,1,1,0 +access_subcontracting_portal_stock_move_line,subcontracting.portal.move.line,stock.model_stock_move_line,base.group_portal,1,1,1,1 +access_subcontracting_portal_warehouse,subcontracting.portal.warehouse,stock.model_stock_warehouse,base.group_portal,1,0,0,0 +access_subcontracting_portal_lot,subcontracting.portal.lot,stock.model_stock_lot,base.group_portal,1,0,1,0 +access_subcontracting_portal_location,subcontracting.portal.location,stock.model_stock_location,base.group_portal,1,0,0,0 +access_subcontracting_portal_production,subcontracting.portal.production,mrp.model_mrp_production,base.group_portal,1,1,0,0 +access_subcontracting_portal_bom,subcontracting.portal.bom,mrp.model_mrp_bom,base.group_portal,1,0,0,0 +access_subcontracting_portal_bom_line,subcontracting.portal.bom.line,mrp.model_mrp_bom_line,base.group_portal,1,0,0,0 +access_subcontracting_portal_consumption_warning,subcontracting.portal.consumption.warning,mrp.model_mrp_consumption_warning,base.group_portal,1,1,1,0 +access_subcontracting_portal_consumption_warning_line,subcontracting.portal.consumption.warning.line,mrp.model_mrp_consumption_warning_line,base.group_portal,1,1,1,0 +access_subcontracting_portal_product,subcontracting.portal.product,product.model_product_product,base.group_portal,1,0,0,0 +access_subcontracting_portal_product_template,subcontracting.portal.product.template,product.model_product_template,base.group_portal,1,0,0,0 +access_subcontracting_portal_uom,subcontracting.portal.uom,uom.model_uom_uom,base.group_portal,1,0,0,0 +access_subcontracting_portal_barcode_nomenclature_stock_user,subcontracting.portal.barcode.nomenclature,barcodes.model_barcode_nomenclature,base.group_portal,1,0,0,0 diff --git a/addons/mrp_subcontracting/security/mrp_subcontracting_security.xml b/addons/mrp_subcontracting/security/mrp_subcontracting_security.xml new file mode 100644 index 00000000000..86fbf33e8ee --- /dev/null +++ b/addons/mrp_subcontracting/security/mrp_subcontracting_security.xml @@ -0,0 +1,146 @@ + + + + + MRP Productions Subcontractor + + [('subcontractor_id', '=', user.partner_id.commercial_partner_id.id)] + + + + + MRP BoMs Subcontractor + + [('id', 'in', user.partner_id.commercial_partner_id.bom_ids.ids)] + + + + + MRP BoM Lines Subcontractor + + [('id', 'in', user.partner_id.commercial_partner_id.bom_ids.bom_line_ids.ids)] + + + + + MRP Consumption Warnings Subcontractor + + [('mrp_production_ids', 'in', user.partner_id.commercial_partner_id.production_ids.ids)] + + + + + MRP Consumption Warning Lines Subcontractor + + [('mrp_production_id', 'in', user.partner_id.commercial_partner_id.production_ids.ids)] + + + + + Stock Moves Subcontractor + + [ + '|', + '|', + ('production_id.subcontractor_id', '=', user.partner_id.commercial_partner_id.id), + ('move_orig_ids.production_id.subcontractor_id', 'in', user.partner_id.commercial_partner_id.ids), + ('raw_material_production_id.subcontractor_id', 'in', user.partner_id.commercial_partner_id.ids) + ] + + + + + Stock Move Lines Subcontractor + + [ + '|', + '|', + ('move_id.production_id.subcontractor_id', '=', user.partner_id.commercial_partner_id.id), + ('move_id.move_orig_ids.production_id.subcontractor_id', 'in', user.partner_id.commercial_partner_id.ids), + ('move_id.raw_material_production_id.subcontractor_id', 'in', user.partner_id.commercial_partner_id.ids), + ] + + + + + Stock Pickings Subcontractor + + [('partner_id.commercial_partner_id', '=', user.partner_id.commercial_partner_id.id)] + + + + + Stock Picking Types Subcontractor + + ['|', ('id', 'in', user.partner_id.commercial_partner_id.picking_ids.picking_type_id.ids), ('id', 'in', user.partner_id.commercial_partner_id.production_ids.picking_type_id.ids)] + + + + + Stock Locations Subcontractor + + [ + '|', + '|', + '|', + '|', + ('child_ids', 'in', user.partner_id.commercial_partner_id.picking_ids.location_id.ids), + ('child_ids', 'in', user.partner_id.commercial_partner_id.picking_ids.location_dest_id.ids), + '|', + ('id', 'in', user.partner_id.commercial_partner_id.picking_ids.location_id.ids), + ('id', 'in', user.partner_id.commercial_partner_id.picking_ids.location_dest_id.ids), + '|', + ('id', 'in', user.partner_id.commercial_partner_id.picking_ids.picking_type_id.warehouse_id.view_location_id.ids), + ('id', 'in', user.partner_id.commercial_partner_id.production_ids.production_location_id.ids), + ('id', 'in', user.partner_id.commercial_partner_id.production_ids.move_finished_ids.move_dest_ids.location_id.ids), + ] + + + + + Warehouses Subcontractor + + [('id', 'in', user.partner_id.commercial_partner_id.picking_ids.picking_type_id.warehouse_id.ids)] + + + + + Stock Lot Subcontractor + + [ + '|', + '|', + ('product_id', 'in', user.partner_id.commercial_partner_id.bom_ids.product_id.ids), + ('product_id', 'in', user.partner_id.commercial_partner_id.bom_ids.product_tmpl_id.product_variant_ids.ids), + ('product_id', 'in', user.partner_id.commercial_partner_id.bom_ids.bom_line_ids.product_id.ids), + ] + + + + + Product Template Subcontractor + + [ + '|', + '|', + ('id', 'in', user.partner_id.commercial_partner_id.bom_ids.product_id.product_tmpl_id.ids), + ('id', 'in', user.partner_id.commercial_partner_id.bom_ids.product_tmpl_id.ids), + ('id', 'in', user.partner_id.commercial_partner_id.bom_ids.bom_line_ids.product_id.product_tmpl_id.ids), + ] + + + + + UoM Subcontractor + + [ + '|', + '|', + ('category_id', 'in', user.partner_id.commercial_partner_id.bom_ids.product_id.product_tmpl_id.uom_id.category_id.ids), + ('category_id', 'in', user.partner_id.commercial_partner_id.bom_ids.product_tmpl_id.uom_id.category_id.ids), + ('category_id', 'in', user.partner_id.commercial_partner_id.bom_ids.bom_line_ids.product_id.product_tmpl_id.uom_id.category_id.ids), + ] + + + + diff --git a/addons/mrp_subcontracting/static/src/subcontracting_portal/main.js b/addons/mrp_subcontracting/static/src/subcontracting_portal/main.js new file mode 100644 index 00000000000..bf2fe254fbd --- /dev/null +++ b/addons/mrp_subcontracting/static/src/subcontracting_portal/main.js @@ -0,0 +1,5 @@ +/** @odoo-module **/ +import { startWebClient } from '@web/start'; +import { SubcontractingPortalWebClient } from './subcontracting_portal'; + +startWebClient(SubcontractingPortalWebClient); diff --git a/addons/mrp_subcontracting/static/src/subcontracting_portal/move_list_view.js b/addons/mrp_subcontracting/static/src/subcontracting_portal/move_list_view.js new file mode 100644 index 00000000000..f87cdb7b166 --- /dev/null +++ b/addons/mrp_subcontracting/static/src/subcontracting_portal/move_list_view.js @@ -0,0 +1,12 @@ +/** @odoo-module**/ + +import ListView from 'web.ListView'; +import viewRegistry from 'web.view_registry'; + +const MoveListView = ListView.extend({ + searchMenuTypes: [], +}); + +viewRegistry.add('subcontracting_portal_move_list_view', MoveListView); + +export default MoveListView; diff --git a/addons/mrp_subcontracting/static/src/subcontracting_portal/picking_form_controller.js b/addons/mrp_subcontracting/static/src/subcontracting_portal/picking_form_controller.js new file mode 100644 index 00000000000..41ac21ce3e7 --- /dev/null +++ b/addons/mrp_subcontracting/static/src/subcontracting_portal/picking_form_controller.js @@ -0,0 +1,29 @@ +/** @odoo-module**/ + +import FormController from 'web.FormController'; +import FormView from 'web.FormView'; +import viewRegistry from 'web.view_registry'; + + +const PickingFormController = FormController.extend({ + /** + * @override + */ + init: function () { + this._super(...arguments); + this.hasActionMenus = false; + }, +}); + +const PickingFormView = FormView.extend({ + config: Object.assign({}, FormView.prototype.config, { + Controller: PickingFormController, + }), +}); + +viewRegistry.add('subcontracting_portal_picking_form_view', PickingFormView); + +export default { + PickingFormView: PickingFormView, + PickingFormController: PickingFormController +}; diff --git a/addons/mrp_subcontracting/static/src/subcontracting_portal/subcontracting_portal.js b/addons/mrp_subcontracting/static/src/subcontracting_portal/subcontracting_portal.js new file mode 100644 index 00000000000..ec1ad25fc98 --- /dev/null +++ b/addons/mrp_subcontracting/static/src/subcontracting_portal/subcontracting_portal.js @@ -0,0 +1,58 @@ +/** @odoo-module **/ + +import { useService } from '@web/core/utils/hooks'; +import { ActionContainer } from '@web/webclient/actions/action_container'; +import { MainComponentsContainer } from "@web/core/main_components_container"; +import { useOwnDebugContext } from "@web/core/debug/debug_context"; +import { ErrorHandler } from "@web/core/utils/components"; +import { session } from '@web/session'; +import { LegacyComponent } from "@web/legacy/legacy_component"; + +const { useEffect} = owl; + +export class SubcontractingPortalWebClient extends LegacyComponent { + setup() { + window.parent.document.body.style.margin = "0"; // remove the margin in the parent body + this.actionService = useService('action'); + this.user = useService("user"); + useService("legacy_service_provider"); + useOwnDebugContext({ categories: ["default"] }); + useEffect( + () => { + this._showView(); + }, + () => [] + ); + } + + handleComponentError(error, C) { + // remove the faulty component + this.Components.splice(this.Components.indexOf(C), 1); + /** + * we rethrow the error to notify the user something bad happened. + * We do it after a tick to make sure owl can properly finish its + * rendering + */ + Promise.resolve().then(() => { + throw error; + }); + } + + async _showView() { + const { action_name, picking_id } = session; + await this.actionService.doAction( + action_name, + { + props: { + resId: picking_id, + }, + additionalContext: { + no_breadcrumbs: true, + } + } + ); + } +} + +SubcontractingPortalWebClient.components = { ActionContainer, ErrorHandler, MainComponentsContainer }; +SubcontractingPortalWebClient.template = 'mrp_subcontracting.SubcontractingPortalWebClient'; diff --git a/addons/mrp_subcontracting/static/src/subcontracting_portal/subcontracting_portal.xml b/addons/mrp_subcontracting/static/src/subcontracting_portal/subcontracting_portal.xml new file mode 100644 index 00000000000..783c0d0419f --- /dev/null +++ b/addons/mrp_subcontracting/static/src/subcontracting_portal/subcontracting_portal.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/addons/mrp_subcontracting/static/tests/tours/subcontracting_portal_tour.js b/addons/mrp_subcontracting/static/tests/tours/subcontracting_portal_tour.js new file mode 100644 index 00000000000..8574fad4005 --- /dev/null +++ b/addons/mrp_subcontracting/static/tests/tours/subcontracting_portal_tour.js @@ -0,0 +1,21 @@ +/** @odoo-module **/ + +import tour from 'web_tour.tour'; + +const subcontractingPortalSteps = [{ + trigger: 'table > tbody > tr a:has(span:contains(WH/IN/00))', + content: 'Select the picking to open the backend view.', + run: 'click', +},{ + trigger: '.o_subcontracting_portal', + content: 'Wait the subcontracting portal to be loaded.', + run: function () {}, +}, { + trigger: 'button[name="action_show_details"]', + run: 'click', +}]; + +tour.register('subcontracting_portal_tour', { + test: true, + url: '/my/productions', +}, subcontractingPortalSteps); diff --git a/addons/mrp_subcontracting/tests/__init__.py b/addons/mrp_subcontracting/tests/__init__.py index 7cc46c985ec..ec12b1b7ca3 100644 --- a/addons/mrp_subcontracting/tests/__init__.py +++ b/addons/mrp_subcontracting/tests/__init__.py @@ -1,3 +1,4 @@ # -*- coding: utf-8 -*- from . import test_subcontracting +from . import test_subcontracting_portal_ui diff --git a/addons/mrp_subcontracting/tests/test_subcontracting.py b/addons/mrp_subcontracting/tests/test_subcontracting.py index 36494fc4b0d..a3144cd0f6f 100644 --- a/addons/mrp_subcontracting/tests/test_subcontracting.py +++ b/addons/mrp_subcontracting/tests/test_subcontracting.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import Command +from odoo.exceptions import AccessError, UserError from odoo.tests import Form from odoo.tests.common import TransactionCase from odoo.addons.mrp_subcontracting.tests.common import TestMrpSubcontractingCommon @@ -957,3 +958,146 @@ class TestSubcontractingTracking(TransactionCase): wizard.process() self.assertEqual(picking_receipt.state, 'done') + + +class TestSubcontractingPortal(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # 1: Create a subcontracting partner + main_partner = cls.env['res.partner'].create({'name': 'main_partner'}) + cls.subcontractor_partner1 = cls.env['res.partner'].create({ + 'name': 'subcontractor_partner', + 'parent_id': main_partner.id, + 'company_id': cls.env.ref('base.main_company').id, + }) + # Make the subcontracting partner a portal user + cls.portal_user = cls.env['res.users'].create({ + 'name': 'portal user (subcontractor)', + 'partner_id': cls.subcontractor_partner1.id, + 'login': 'subcontractor', + 'password': 'subcontractor', + 'email': 'subcontractor@subcontracting.portal', + 'groups_id': [(6, 0, [cls.env.ref('base.group_portal').id])] + }) + + # 2. Create a BOM of subcontracting type + # 2.1. Comp1 has tracking by lot + cls.comp1_sn = cls.env['product.product'].create({ + 'name': 'Component1', + 'type': 'product', + 'categ_id': cls.env.ref('product.product_category_all').id, + 'tracking': 'serial' + }) + cls.comp2 = cls.env['product.product'].create({ + 'name': 'Component2', + 'type': 'product', + 'categ_id': cls.env.ref('product.product_category_all').id, + }) + cls.product_not_in_bom = cls.env['product.product'].create({ + 'name': 'Product not in the BoM', + 'type': 'product', + }) + + # 2.2. Finished prodcut has tracking by serial number + cls.finished_product = cls.env['product.product'].create({ + 'name': 'finished', + 'type': 'product', + 'categ_id': cls.env.ref('product.product_category_all').id, + 'tracking': 'lot' + }) + bom_form = Form(cls.env['mrp.bom']) + bom_form.type = 'subcontract' + bom_form.consumption = 'warning' + bom_form.subcontractor_ids.add(cls.subcontractor_partner1) + bom_form.product_tmpl_id = cls.finished_product.product_tmpl_id + with bom_form.bom_line_ids.new() as bom_line: + bom_line.product_id = cls.comp1_sn + bom_line.product_qty = 1 + with bom_form.bom_line_ids.new() as bom_line: + bom_line.product_id = cls.comp2 + bom_line.product_qty = 1 + cls.bom_tracked = bom_form.save() + + def test_flow_subcontracting_portal(self): + # Create a receipt picking from the subcontractor + picking_form = Form(self.env['stock.picking']) + picking_form.picking_type_id = self.env.ref('stock.picking_type_in') + picking_form.partner_id = self.subcontractor_partner1 + with picking_form.move_ids_without_package.new() as move: + move.product_id = self.finished_product + move.product_uom_qty = 2 + picking_receipt = picking_form.save() + picking_receipt.action_confirm() + + # Using the subcontractor (portal user) + lot1 = self.env['stock.lot'].with_user(self.portal_user).create({ + 'name': 'lot1', + 'product_id': self.finished_product.id, + 'company_id': self.env.company.id, + }) + lot2 = self.env['stock.lot'].with_user(self.portal_user).create({ + 'name': 'lot2', + 'product_id': self.finished_product.id, + 'company_id': self.env.company.id, + }) + serial1 = self.env['stock.lot'].with_user(self.portal_user).create({ + 'name': 'lot1', + 'product_id': self.comp1_sn.id, + 'company_id': self.env.company.id, + }) + serial2 = self.env['stock.lot'].with_user(self.portal_user).create({ + 'name': 'lot2', + 'product_id': self.comp1_sn.id, + 'company_id': self.env.company.id, + }) + serial3 = self.env['stock.lot'].with_user(self.portal_user).create({ + 'name': 'lot3', + 'product_id': self.comp1_sn.id, + 'company_id': self.env.company.id, + }) + action = picking_receipt.with_user(self.portal_user).with_context({'is_subcontracting_portal': 1}).move_ids.action_show_details() + mo = self.env['mrp.production'].with_user(self.portal_user).browse(action['res_id']) + mo_form = Form(mo.with_context(action['context']), view=action['view_id']) + # Registering components for the first manufactured product + mo_form.qty_producing = 1 + mo_form.lot_producing_id = lot1 + with mo_form.move_line_raw_ids.edit(0) as ml: + ml.lot_id = serial1 + mo = mo_form.save() + mo.subcontracting_record_component() + mo_form = Form(mo.with_context(action['context']), view=action['view_id']) + # Registering components for the second manufactured product with over-consumption, which leads to a warning + mo_form.qty_producing = 1 + mo_form.lot_producing_id = lot2 + with mo_form.move_line_raw_ids.edit(0) as ml: + ml.lot_id = serial2 + with mo_form.move_line_raw_ids.new() as ml: + ml.product_id = self.comp1_sn + ml.lot_id = serial3 + with mo_form.move_line_raw_ids.edit(1) as ml: + ml.qty_done = 2 + # The portal user should not be able to add a product not in the BoM + with self.assertRaises(AccessError): + with mo_form.move_line_raw_ids.new() as ml: + ml.product_id = self.product_not_in_bom + mo = mo_form.save() + action_warning = mo.subcontracting_record_component() + warning = Form(self.env['mrp.consumption.warning'].with_context(**action_warning['context'])) + warning = warning.save() + warning.action_confirm() + + # Attempt to validate from the portal user should give an error + with self.assertRaises(UserError): + picking_receipt.with_user(self.portal_user).button_validate() + + # Validation from the backend user + picking_receipt.button_validate() + self.assertEqual(mo.state, 'done') + self.assertEqual(mo.move_line_raw_ids[0].qty_done, 1) + self.assertEqual(mo.move_line_raw_ids[0].lot_id, serial2) + self.assertEqual(mo.move_line_raw_ids[1].qty_done, 1) + self.assertEqual(mo.move_line_raw_ids[1].lot_id, serial3) + self.assertEqual(mo.move_line_raw_ids[2].qty_done, 2) diff --git a/addons/mrp_subcontracting/tests/test_subcontracting_portal_ui.py b/addons/mrp_subcontracting/tests/test_subcontracting_portal_ui.py new file mode 100644 index 00000000000..5f84565d15e --- /dev/null +++ b/addons/mrp_subcontracting/tests/test_subcontracting_portal_ui.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- + +from odoo import Command +from odoo.tests import Form, HttpCase, tagged + + +@tagged('post_install', '-at_install') +class TestSubcontractingPortalUi(HttpCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + # 1. Create portal user + user = cls.env['res.users'].with_context({'no_reset_password': True, 'mail_create_nolog': True}).create({ + 'name': 'Georges', + 'login': 'georges1', + 'password': 'georges1', + 'email': 'georges@project.portal', + 'signature': 'SignGeorges', + 'notification_type': 'email', + 'groups_id': [Command.set([cls.env.ref('base.group_portal').id])], + }) + + cls.partner_portal = cls.env['res.partner'].with_context({'mail_create_nolog': True}).create({ + 'name': 'Georges', + 'email': 'georges@project.portal', + 'company_id': False, + 'user_ids': [user.id], + }) + # 2. Create a BOM of subcontracting type + cls.comp = cls.env['product.product'].create({ + 'name': 'Component', + 'type': 'product', + }) + + cls.finished_product = cls.env['product.product'].create({ + 'name': 'Finished', + 'type': 'product', + }) + bom_form = Form(cls.env['mrp.bom']) + bom_form.type = 'subcontract' + bom_form.consumption = 'warning' + bom_form.subcontractor_ids.add(cls.partner_portal) + bom_form.product_tmpl_id = cls.finished_product.product_tmpl_id + with bom_form.bom_line_ids.new() as bom_line: + bom_line.product_id = cls.comp + bom_line.product_qty = 1 + cls.bom_tracked = bom_form.save() + + def test_subcontrating_portal(self): + # Create a receipt picking from the subcontractor + picking_form = Form(self.env['stock.picking']) + picking_form.picking_type_id = self.env.ref('stock.picking_type_in') + picking_form.partner_id = self.partner_portal + with picking_form.move_ids_without_package.new() as move: + move.product_id = self.finished_product + move.product_uom_qty = 2 + picking_receipt = picking_form.save() + picking_receipt.action_confirm() + + self.start_tour("/my/productions", 'subcontracting_portal_tour', login="georges1") diff --git a/addons/mrp_subcontracting/views/mrp_production_views.xml b/addons/mrp_subcontracting/views/mrp_production_views.xml index 3377755ae05..ee68e2a3e39 100644 --- a/addons/mrp_subcontracting/views/mrp_production_views.xml +++ b/addons/mrp_subcontracting/views/mrp_production_views.xml @@ -30,19 +30,16 @@ 1 - - 1 - + + 1 - - 1 - 1 - - + + + @@ -52,6 +49,27 @@