diff --git a/stock_request/README.rst b/stock_request/README.rst new file mode 100644 index 000000000..f3d0cca06 --- /dev/null +++ b/stock_request/README.rst @@ -0,0 +1,88 @@ +.. image:: https://img.shields.io/badge/licence-LGPL--3-blue.svg + :target: https://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 + +============= +Stock Request +============= + +This module was written to allow users to request products that are +frequently stocked by the company, to be transferred to their chosen location. + + +Configuration +============= + +Users should be assigned to the groups 'Stock Request / User' or 'Stock +Request / Manager'. + +Group Stock Request / User +-------------------------- + +* Can see her/his own Stock Requests, and others that she/he's been granted + permission to follow. + +* Can create/update only her/his Stock Requests. + +Group Stock Request / Manager +----------------------------- + +* Can fully manage all Stock Requests + + +Usage +===== + +Creation +-------- +* Go to 'Stock Requests / Stock Requests' and create a new Request. +* Indicate a product, quantity and location. +* Press 'Confirm'. + +Upon confirmation the request will be evaluated using the procurement rules +for the selected location. + +In case that transfers are created, the user will be able to access to them +from the button 'Transfers' available in the Stock Request. + +Cancel +------ +When the user cancels a Stock Request, the related pending stock moves will be +also cancelled. + + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/153/11.0 + + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues +`_. In case of +trouble, please check there if your issue has already been reported. If you +spotted it first, help us smash it by providing detailed and welcomed feedback. + +Credits +======= + +Contributors +------------ + +* Jordi Ballester (EFICENT) . + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +To contribute to this module, please visit https://odoo-community.org. diff --git a/stock_request/__init__.py b/stock_request/__init__.py new file mode 100644 index 000000000..a9e337226 --- /dev/null +++ b/stock_request/__init__.py @@ -0,0 +1,2 @@ + +from . import models diff --git a/stock_request/__manifest__.py b/stock_request/__manifest__.py new file mode 100644 index 000000000..309374eb5 --- /dev/null +++ b/stock_request/__manifest__.py @@ -0,0 +1,26 @@ +# Copyright 2017 Eficent Business and IT Consulting Services, S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). + +{ + "name": "Stock Request", + "summary": "Internal request for stock", + "version": "11.0.1.0.0", + "license": "LGPL-3", + "website": "https://github.com/stock-logistics-warehouse", + "author": "Eficent, " + "Odoo Community Association (OCA)", + "category": "Warehouse Management", + "depends": [ + "stock", + ], + "data": [ + "security/stock_request_security.xml", + "security/ir.model.access.csv", + "views/stock_request_views.xml", + "views/stock_request_allocation_views.xml", + "views/stock_move_views.xml", + "views/stock_picking_views.xml", + "data/stock_request_sequence_data.xml" + ], + "installable": True, +} diff --git a/stock_request/data/stock_request_sequence_data.xml b/stock_request/data/stock_request_sequence_data.xml new file mode 100644 index 000000000..3f2f9fd8a --- /dev/null +++ b/stock_request/data/stock_request_sequence_data.xml @@ -0,0 +1,14 @@ + + + + + + Stock Request + stock.request + SR/ + 5 + + + + + diff --git a/stock_request/models/__init__.py b/stock_request/models/__init__.py new file mode 100644 index 000000000..a7e453e0c --- /dev/null +++ b/stock_request/models/__init__.py @@ -0,0 +1,7 @@ + +from . import stock_request +from . import stock_request_allocation +from . import stock_move +from . import stock_picking +from . import procurement_rule +from . import stock_move_line diff --git a/stock_request/models/procurement_rule.py b/stock_request/models/procurement_rule.py new file mode 100644 index 000000000..3d811ec3e --- /dev/null +++ b/stock_request/models/procurement_rule.py @@ -0,0 +1,20 @@ +# Copyright 2017 Eficent Business and IT Consulting Services, S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). + +from odoo import models + + +class ProcurementRule(models.Model): + _inherit = 'procurement.rule' + + def _get_stock_move_values(self, product_id, product_qty, product_uom, + location_id, name, origin, values, group_id): + result = super(ProcurementRule, self)._get_stock_move_values( + product_id, product_qty, product_uom, location_id, name, origin, + values, group_id) + if values.get('stock_request_id', False): + result['allocation_ids'] = [(0, 0, { + 'stock_request_id': values['stock_request_id'], + 'requested_product_uom_qty': product_qty, + })] + return result diff --git a/stock_request/models/stock_move.py b/stock_request/models/stock_move.py new file mode 100644 index 000000000..2beddd42f --- /dev/null +++ b/stock_request/models/stock_move.py @@ -0,0 +1,28 @@ +# Copyright 2017 Eficent Business and IT Consulting Services, S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). + +from odoo import api, fields, models + + +class StockMove(models.Model): + _inherit = 'stock.move' + + allocation_ids = fields.One2many(comodel_name='stock.request.allocation', + inverse_name='stock_move_id', + string='Stock Request Allocation') + + stock_request_ids = fields.One2many(comodel_name='stock.request', + string='Stock Requests', + compute='_compute_stock_request_ids') + + @api.depends('allocation_ids') + def _compute_stock_request_ids(self): + for rec in self: + rec.stock_request_ids = rec.allocation_ids.mapped( + 'stock_request_id') + + def _merge_moves_fields(self): + res = super(StockMove, self)._merge_moves_fields() + res['allocation_ids'] = [(4, m.id) for m in + self.mapped('allocation_ids')] + return res diff --git a/stock_request/models/stock_move_line.py b/stock_request/models/stock_move_line.py new file mode 100644 index 000000000..13f67a87d --- /dev/null +++ b/stock_request/models/stock_move_line.py @@ -0,0 +1,64 @@ +# Copyright 2017 Eficent Business and IT Consulting Services S.L. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl-3.0). + +from odoo import _, api, models + + +class StockMoveLine(models.Model): + _inherit = "stock.move.line" + + @api.model + def _stock_request_confirm_done_message_content(self, message_data): + title = _('Receipt confirmation %s for your Request %s') % ( + message_data['picking_name'], message_data['request_name']) + message = '

%s

' % title + message += _('The following requested items from Stock Request %s ' + 'have now been received in %s using Picking %s:') % ( + message_data['request_name'], message_data['location_name'], + message_data['picking_name']) + message += '' + return message + + def _prepare_message_data(self, ml, request, allocated_qty): + return { + 'request_name': request.name, + 'picking_name': ml.picking_id.name, + 'product_name': ml.product_id.name_get()[0][1], + 'product_qty': allocated_qty, + 'product_uom': ml.product_uom_id.name, + 'location_name': ml.location_dest_id.name_get()[0][1], + } + + def _action_done(self): + res = super(StockMoveLine, self)._action_done() + for ml in self.filtered( + lambda m: m.move_id.allocation_ids): + qty_done = ml.product_uom_id._compute_quantity( + ml.qty_done, ml.product_id.uom_id) + + # We do sudo because potentially the user that completes the move + # may not have permissions for stock.request. + to_allocate_qty = ml.qty_done + for allocation in ml.move_id.allocation_ids.sudo(): + allocated_qty = 0.0 + if allocation.open_product_qty: + allocated_qty = min( + allocation.open_product_qty, qty_done) + allocation.allocated_product_qty += allocated_qty + to_allocate_qty -= allocated_qty + request = allocation.stock_request_id + message_data = self._prepare_message_data(ml, request, + allocated_qty) + message = \ + self._stock_request_confirm_done_message_content( + message_data) + request.message_post(body=message, subtype='mail.mt_comment') + request.check_done() + return res diff --git a/stock_request/models/stock_picking.py b/stock_request/models/stock_picking.py new file mode 100644 index 000000000..6f6e77b33 --- /dev/null +++ b/stock_request/models/stock_picking.py @@ -0,0 +1,37 @@ +# Copyright 2017 Eficent Business and IT Consulting Services, S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). + +from odoo import api, fields, models + + +class StockPicking(models.Model): + _inherit = 'stock.picking' + + stock_request_ids = fields.One2many(comodel_name='stock.request', + string='Stock Requests', + compute='_compute_stock_request_ids') + stock_request_count = fields.Integer('Stock Request #', + compute='_compute_stock_request_ids') + + @api.depends('move_lines') + def _compute_stock_request_ids(self): + for rec in self: + rec.stock_request_ids = rec.move_lines.mapped('stock_request_ids') + rec.stock_request_count = len(rec.stock_request_ids) + + def action_view_stock_request(self): + """ + :return dict: dictionary value for created view + """ + action = self.env.ref( + 'stock_request.action_stock_request_form').read()[0] + + requests = self.mapped('stock_request_ids') + if len(requests) > 1: + action['domain'] = [('id', 'in', requests.ids)] + elif requests: + action['views'] = [ + (self.env.ref('stock_request.view_stock_request_form').id, + 'form')] + action['res_id'] = requests.id + return action diff --git a/stock_request/models/stock_request.py b/stock_request/models/stock_request.py new file mode 100644 index 000000000..690bbb2d7 --- /dev/null +++ b/stock_request/models/stock_request.py @@ -0,0 +1,334 @@ +# Copyright 2017 Eficent Business and IT Consulting Services, S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError, ValidationError +from odoo.addons import decimal_precision as dp +from odoo.tools import float_compare + + +REQUEST_STATES = [ + ('draft', 'Draft'), + ('open', 'In progress'), + ('done', 'Done'), + ('cancel', 'Cancelled')] + + +class StockRequest(models.Model): + _name = "stock.request" + _description = "Stock Request" + _inherit = ['mail.thread', 'mail.activity.mixin'] + + @api.model + def default_get(self, fields): + res = super(StockRequest, self).default_get(fields) + warehouse = None + if 'warehouse_id' not in res and res.get('company_id'): + warehouse = self.env['stock.warehouse'].search( + [('company_id', '=', res['company_id'])], limit=1) + if warehouse: + res['warehouse_id'] = warehouse.id + res['location_id'] = warehouse.lot_stock_id.id + return res + + def _get_default_requested_by(self): + return self.env['res.users'].browse(self.env.uid) + + @api.depends('product_id', 'product_uom_id', 'product_uom_qty') + def _compute_product_qty(self): + self.product_qty = self.product_uom_id._compute_quantity( + self.product_uom_qty, self.product_id.uom_id) + + name = fields.Char( + 'Name', copy=False, required=True, readonly=True, + states={'draft': [('readonly', False)]}, + default=lambda self: self.env['ir.sequence'].next_by_code( + 'stock.request')) + state = fields.Selection(selection=REQUEST_STATES, string='Status', + copy=False, default='draft', index=True, + readonly=True, track_visibility='onchange', + ) + requested_by = fields.Many2one( + 'res.users', 'Requested by', required=True, + track_visibility='onchange', + default=lambda s: s._get_default_requested_by(), + ) + warehouse_id = fields.Many2one( + 'stock.warehouse', 'Warehouse', readonly=True, + ondelete="cascade", required=True, + states={'draft': [('readonly', False)]}) + location_id = fields.Many2one( + 'stock.location', 'Location', readonly=True, + domain=[('usage', 'in', ['internal', 'transit'])], + ondelete="cascade", required=True, + states={'draft': [('readonly', False)]}, + ) + product_id = fields.Many2one( + 'product.product', 'Product', readonly=True, + states={'draft': [('readonly', False)]}, + domain=[('type', 'in', ['product', 'consu'])], ondelete='cascade', + required=True, + ) + product_uom_id = fields.Many2one( + 'product.uom', 'Product Unit of Measure', + readonly=True, required=True, + states={'draft': [('readonly', False)]}, + default=lambda self: self._context.get('product_uom_id', False), + ) + product_uom_qty = fields.Float( + 'Quantity', digits=dp.get_precision('Product Unit of Measure'), + states={'draft': [('readonly', False)]}, + readonly=True, required=True, + help="Quantity, specified in the unit of measure indicated in the " + "request.", + ) + product_qty = fields.Float( + 'Real Quantity', compute='_compute_product_qty', + store=True, readonly=True, copy=False, + help='Quantity in the default UoM of the product', + ) + procurement_group_id = fields.Many2one( + 'procurement.group', 'Procurement Group', readonly=True, + states={'draft': [('readonly', False)]}, + help="Moves created through this stock request will be put in this " + "procurement group. If none is given, the moves generated by " + "procurement rules will be grouped into one big picking.", + ) + company_id = fields.Many2one( + 'res.company', 'Company', required=True, readonly=True, + states={'draft': [('readonly', False)]}, + default=lambda self: self.env['res.company']._company_default_get( + 'stock.request'), + ) + expected_date = fields.Datetime( + 'Expected date', default=fields.Datetime.now, index=True, + required=True, readonly=True, + states={'draft': [('readonly', False)]}, + help="Date when you expect to receive the goods.", + ) + picking_policy = fields.Selection([ + ('direct', 'Receive each product when available'), + ('one', 'Receive all products at once')], + string='Shipping Policy', required=True, readonly=True, + states={'draft': [('readonly', False)]}, + default='direct', + ) + move_ids = fields.One2many(comodel_name='stock.move', + compute='_compute_move_ids', + string='Stock Moves', readonly=True, + ) + picking_ids = fields.One2many('stock.picking', + compute='_compute_picking_ids', + string='Pickings', readonly=True, + ) + qty_in_progress = fields.Float( + 'Qty In Progress', digits=dp.get_precision('Product Unit of Measure'), + readonly=True, compute='_compute_qty', store=True, + help="Quantity in progress.", + ) + qty_done = fields.Float( + 'Qty Done', digits=dp.get_precision('Product Unit of Measure'), + readonly=True, compute='_compute_qty', store=True, + help="Quantity completed", + ) + picking_count = fields.Integer(string='Delivery Orders', + compute='_compute_picking_ids', + readonly=True, + ) + route_id = fields.Many2one('stock.location.route', string='Route', + readonly=True, + states={'draft': [('readonly', False)]}, + ondelete='restrict') + + allocation_ids = fields.One2many(comodel_name='stock.request.allocation', + inverse_name='stock_request_id', + string='Stock Request Allocation') + + _sql_constraints = [ + ('name_uniq', 'unique(name, company_id)', + 'Stock Request name must be unique'), + ] + + @api.depends('allocation_ids') + def _compute_move_ids(self): + for request in self.sudo(): + request.move_ids = request.allocation_ids.mapped('stock_move_id') + + @api.depends('allocation_ids') + def _compute_picking_ids(self): + for request in self.sudo(): + request.picking_count = 0 + request.picking_ids = self.env['stock.picking'] + request.picking_ids = request.move_ids.filtered( + lambda m: m.state != 'cancel').mapped('picking_id') + request.picking_count = len(request.picking_ids) + + @api.depends('allocation_ids', 'allocation_ids.stock_move_id.state', + 'allocation_ids.stock_move_id.move_line_ids', + 'allocation_ids.stock_move_id.move_line_ids.qty_done') + def _compute_qty(self): + for request in self.sudo(): + done_qty = sum(request.allocation_ids.mapped( + 'allocated_product_qty')) + open_qty = sum(request.allocation_ids.mapped('open_product_qty')) + request.qty_done = request.product_id.uom_id._compute_quantity( + done_qty, request.product_uom_id) + request.qty_in_progress = \ + request.product_id.uom_id._compute_quantity( + open_qty, request.product_uom_id) + + @api.constrains('product_id') + def _check_product_uom(self): + ''' Check if the UoM has the same category as the + product standard UoM ''' + if any(request.product_id.uom_id.category_id != + request.product_uom_id.category_id for request in self): + raise ValidationError( + _('You have to select a product unit of measure in the ' + 'same category than the default unit ' + 'of measure of the product')) + + @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 + if self.warehouse_id.company_id != self.company_id: + self.company_id = self.warehouse_id.company_id + return {} + + @api.onchange('company_id') + def onchange_company_id(self): + """ Sets a default warehouse when the company is changed and limits + the user selection of warehouses. """ + if self.company_id and ( + not self.warehouse_id or + self.warehouse_id.company_id != self.company_id): + self.warehouse_id = self.env['stock.warehouse'].search( + [('company_id', '=', self.company_id.id)], limit=1) + self.onchange_warehouse_id() + + return { + 'domain': { + 'warehouse_id': [('company_id', '=', self.company_id.id)]}} + + @api.onchange('product_id') + def onchange_product_id(self): + if self.product_id: + self.product_uom_id = self.product_id.uom_id.id + return { + 'domain': { + 'product_uom_id': + [('category_id', '=', + self.product_id.uom_id.category_id.id)]}} + return {'domain': {'product_uom_id': []}} + + @api.multi + def _action_confirm(self): + self._action_launch_procurement_rule() + self.state = 'open' + + @api.multi + def action_confirm(self): + self._action_confirm() + return True + + def action_draft(self): + self.state = 'draft' + return True + + def action_cancel(self): + self.sudo().mapped('move_ids')._action_cancel() + self.state = 'cancel' + return True + + def action_done(self): + self.state = 'done' + return True + + def check_done(self): + precision = self.env['decimal.precision'].precision_get( + 'Product Unit of Measure') + for request in self: + allocated_qty = sum(request.allocation_ids.mapped( + 'allocated_product_qty')) + qty_done = request.product_id.uom_id._compute_quantity( + allocated_qty, request.product_uom_id) + if float_compare(qty_done, request.product_uom_qty, + precision_digits=precision) >= 0: + request.action_done() + return True + + def _prepare_procurement_values(self, group_id=False): + + """ Prepare specific key for moves or other components that + will be created from a procurement rule + coming from a stock request. This method could be override + in order to add other custom key that could be used in + move/po creation. + """ + return { + 'date_planned': self.expected_date, + 'warehouse_id': self.warehouse_id, + 'stock_request_allocation_ids': self.id, + 'group_id': group_id or self.procurement_group_id.id or False, + 'route_ids': self.route_id, + 'stock_request_id': self.id, + } + + @api.multi + def _action_launch_procurement_rule(self): + """ + Launch procurement group run method with required/custom + fields genrated by a + stock request. procurement group will launch '_run_move', + '_run_buy' or '_run_manufacture' + depending on the stock request product rule. + """ + precision = self.env['decimal.precision'].precision_get( + 'Product Unit of Measure') + errors = [] + for request in self: + if ( + request.state != 'draft' or + request.product_id.type not in ('consu', 'product') + ): + continue + qty = 0.0 + for move in request.move_ids.filtered( + lambda r: r.state != 'cancel'): + qty += move.product_qty + + if float_compare(qty, request.product_qty, + precision_digits=precision) >= 0: + continue + + values = request._prepare_procurement_values( + group_id=request.procurement_group_id) + try: + # We launch with sudo because potentially we could create + # objects that the user is not authorized to create, such + # as PO. + self.env['procurement.group'].sudo().run( + request.product_id, request.product_uom_qty, + request.product_uom_id, + request.location_id, request.name, + request.name, values) + except UserError as error: + errors.append(error.name) + if errors: + raise UserError('\n'.join(errors)) + return True + + @api.multi + def action_view_transfer(self): + action = self.env.ref('stock.action_picking_tree_all').read()[0] + + pickings = self.mapped('picking_ids') + if len(pickings) > 1: + action['domain'] = [('id', 'in', pickings.ids)] + elif pickings: + action['views'] = [ + (self.env.ref('stock.view_picking_form').id, 'form')] + action['res_id'] = pickings.id + return action diff --git a/stock_request/models/stock_request_allocation.py b/stock_request/models/stock_request_allocation.py new file mode 100644 index 000000000..fbd6c684b --- /dev/null +++ b/stock_request/models/stock_request_allocation.py @@ -0,0 +1,65 @@ +# Copyright 2017 Eficent Business and IT Consulting Services, S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). + +from odoo import api, fields, models + + +class StockRequestAllocation(models.Model): + _name = 'stock.request.allocation' + _description = 'Stock Request Allocation' + + stock_request_id = fields.Many2one(string='Stock Request', + comodel_name='stock.request', + required=True, ondelete='cascade', + ) + stock_move_id = fields.Many2one(string='Stock Move', + comodel_name='stock.move', + required=True, ondelete='cascade', + ) + product_id = fields.Many2one(string='Product', + comodel_name='product.product', + related='stock_request_id.product_id', + readonly=True, + ) + product_uom_id = fields.Many2one(string='UoM', comodel_name='product.uom', + related='stock_request_id.product_uom_id', + readonly=True, + ) + requested_product_uom_qty = fields.Float( + 'Requested Quantity (UoM)', + help='Quantity of the stock request allocated to the stock move, ' + 'in the UoM of the Stock Request', + ) + requested_product_qty = fields.Float( + 'Requested Quantity', + help='Quantity of the stock request allocated to the stock move, ' + 'in the default UoM of the product', + compute='_compute_requested_product_qty' + ) + allocated_product_qty = fields.Float( + 'Allocated Quantity', + help='Quantity of the stock request allocated to the stock move, ' + 'in the default UoM of the product', + ) + open_product_qty = fields.Float('Open Quantity', + compute='_compute_open_product_qty') + + @api.depends('stock_request_id.product_id', + 'stock_request_id.product_uom_id', + 'requested_product_uom_qty') + def _compute_requested_product_qty(self): + for rec in self: + rec.requested_product_qty = rec.product_uom_id._compute_quantity( + rec.requested_product_uom_qty, rec.product_id.uom_id) + + @api.depends('requested_product_qty', 'allocated_product_qty', + 'stock_move_id', 'stock_move_id.state') + def _compute_open_product_qty(self): + for rec in self: + if rec.stock_move_id.state == 'cancel': + rec.open_product_qty = 0.0 + else: + rec.open_product_qty = \ + rec.requested_product_qty - rec.allocated_product_qty + if rec.open_product_qty < 0.0: + rec.open_product_qty = 0.0 diff --git a/stock_request/security/ir.model.access.csv b/stock_request/security/ir.model.access.csv new file mode 100644 index 000000000..ca66a1a53 --- /dev/null +++ b/stock_request/security/ir.model.access.csv @@ -0,0 +1,13 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_stock_request_user,stock request user,model_stock_request,group_stock_request_user,1,1,1, +access_stock_request_manager,stock request manager,model_stock_request,group_stock_request_manager,1,1,1,1 +access_stock_request_stock_user,stock.request stock user,model_stock_request,stock.group_stock_user,1,0,0,0 +access_stock_request_allocation_user,stock request allocation user,model_stock_request_allocation,group_stock_request_user,1,1,1,1 +access_stock_request_allocation_manager,stock request allocation manager,model_stock_request_allocation,group_stock_request_manager,1,1,1,1 +access_stock_request_allocation_stock_user,stock.request.allocation stock user,model_stock_request_allocation,stock.group_stock_user,1,0,0,0 +access_stock_request_manager,stock request manager,model_stock_request,group_stock_request_manager,1,1,1,1 +access_stock_warehouse_user,stock.warehouse.user,stock.model_stock_warehouse,group_stock_request_user,1,0,0,0 +access_stock_location_user,stock.location.user,stock.model_stock_location,group_stock_request_user,1,0,0,0 +access_stock_location_request_manager,stock.location request manager,stock.model_stock_location,group_stock_request_manager,1,0,0,0 +access_procurement_rule_request_manager,procurement_rule request_manager,stock.model_procurement_rule,group_stock_request_manager,1,0,0,0 +access_procurement_rule,procurement.rule.flow,stock.model_procurement_rule,group_stock_request_user,1,0,0,0 diff --git a/stock_request/security/stock_request_security.xml b/stock_request/security/stock_request_security.xml new file mode 100644 index 000000000..ecb96c13e --- /dev/null +++ b/stock_request/security/stock_request_security.xml @@ -0,0 +1,72 @@ + + + + + Stock Request + + 10 + + + + Stock Request User + + + + + + Stock Request Manager + + + + + + + + + + + + stock_request multi-company + + + ['|',('company_id','=',False),('company_id','child_of',[user.company_id.id])] + + + + Follow Stock Request + + + + + + + ['|',('requested_by','=',user.id), + ('message_partner_ids', 'in', [user.partner_id.id])] + + + + Stock Request User + + + + + + + [('requested_by','=',user.id)] + + + + Stock Request Manager + + + + + + + + + + diff --git a/stock_request/static/description/icon.png b/stock_request/static/description/icon.png new file mode 100644 index 000000000..a7c11c7a1 Binary files /dev/null and b/stock_request/static/description/icon.png differ diff --git a/stock_request/tests/__init__.py b/stock_request/tests/__init__.py new file mode 100644 index 000000000..9f108b1d9 --- /dev/null +++ b/stock_request/tests/__init__.py @@ -0,0 +1,2 @@ + +from . import test_stock_request diff --git a/stock_request/tests/test_stock_request.py b/stock_request/tests/test_stock_request.py new file mode 100644 index 000000000..77d184d6d --- /dev/null +++ b/stock_request/tests/test_stock_request.py @@ -0,0 +1,390 @@ +# Copyright 2017 Eficent Business and IT Consulting Services S.L. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl-3.0). + +from odoo.tests import common +from odoo import exceptions + + +class TestStockRequest(common.TransactionCase): + + def setUp(self): + super(TestStockRequest, self).setUp() + + # common models + self.stock_request = self.env['stock.request'] + + # refs + self.stock_request_user_group = \ + self.env.ref('stock_request.group_stock_request_user') + self.stock_request_manager_group = \ + self.env.ref('stock_request.group_stock_request_manager') + self.main_company = self.env.ref('base.main_company') + self.warehouse = self.env.ref('stock.warehouse0') + self.categ_unit = self.env.ref('product.product_uom_categ_unit') + + # common data + self.company_2 = self.env['res.company'].create({ + 'name': 'Comp2', + }) + self.wh2 = self.env['stock.warehouse'].search( + [('company_id', '=', self.company_2.id)], limit=1) + self.stock_request_user = self._create_user( + 'stock_request_user', + [self.stock_request_user_group.id], + [self.main_company.id, self.company_2.id]) + self.stock_request_manager = self._create_user( + 'stock_request_manager', + [self.stock_request_manager_group.id], + [self.main_company.id, self.company_2.id]) + self.product = self._create_product('SH', 'Shoes', False) + + self.ressuply_loc = self.env['stock.location'].create({ + 'name': 'Ressuply', + 'location_id': self.warehouse.view_location_id.id, + }) + + self.route = self.env['stock.location.route'].create({ + 'name': 'Transfer', + 'product_categ_selectable': False, + 'product_selectable': True, + 'company_id': self.main_company.id, + 'sequence': 10, + }) + + self.uom_dozen = self.env['product.uom'].create({ + 'name': 'Test-DozenA', + 'category_id': self.categ_unit.id, + 'factor_inv': 12, + 'uom_type': 'bigger', + 'rounding': 0.001}) + + self.env['procurement.rule'].create({ + 'name': 'Transfer', + 'route_id': self.route.id, + 'location_src_id': self.ressuply_loc.id, + 'location_id': self.warehouse.lot_stock_id.id, + 'action': 'move', + 'picking_type_id': self.warehouse.int_type_id.id, + 'procure_method': 'make_to_stock', + 'warehouse_id': self.warehouse.id, + 'company_id': self.main_company.id, + 'propagate': 'False', + }) + + def _create_user(self, name, group_ids, company_ids): + return self.env['res.users'].with_context( + {'no_reset_password': True}).create( + {'name': name, + 'password': 'demo', + 'login': name, + 'email': '@'.join([name, '@test.com']), + 'groups_id': [(6, 0, group_ids)], + 'company_ids': [(6, 0, company_ids)] + }) + + def _create_product(self, default_code, name, company_id): + return self.env['product.product'].create({ + 'name': name, + 'default_code': default_code, + 'uom_id': self.env.ref('product.product_uom_unit').id, + 'company_id': company_id, + 'type': 'product', + }) + + def test_defaults(self): + + vals = { + 'product_id': self.product.id, + 'product_uom_id': self.product.uom_id.id, + 'product_uom_qty': 5.0, + } + stock_request = self.stock_request.sudo( + self.stock_request_user.id).with_context( + company_id=self.main_company.id).create(vals) + + self.assertEqual( + stock_request.requested_by, self.stock_request_user) + + self.assertEqual( + stock_request.warehouse_id, self.warehouse) + + self.assertEqual( + stock_request.location_id, self.warehouse.lot_stock_id) + + def test_onchanges(self): + vals = { + 'product_id': self.product.id, + 'product_uom_id': self.product.uom_id.id, + 'product_uom_qty': 5.0, + 'company_id': self.main_company.id, + } + stock_request = self.stock_request.sudo( + self.stock_request_user).new(vals) + self.stock_request_user.company_id = self.company_2 + stock_request.default_get(['warehouse_id', 'company_id']) + stock_request.company_id = self.company_2 + stock_request.onchange_company_id() + + self.assertEqual( + stock_request.warehouse_id, self.wh2) + self.assertEqual( + stock_request.location_id, self.wh2.lot_stock_id) + + product = self.env['product.product'].create({ + 'name': 'Wheat', + 'uom_id': self.env.ref('product.product_uom_kgm').id, + 'uom_po_id': self.env.ref('product.product_uom_kgm').id, + }) + + # Test onchange_product_id + stock_request.product_id = product + res = stock_request.onchange_product_id() + + self.assertEqual(res['domain']['product_uom_id'], + [('category_id', '=', + product.uom_id.category_id.id)]) + self.assertEqual( + stock_request.product_uom_id, + self.env.ref('product.product_uom_kgm')) + + stock_request.product_id = self.env['product.product'] + res = stock_request.onchange_product_id() + + self.assertEqual(res['domain']['product_uom_id'], []) + + # Test onchange_warehouse_id + wh2_2 = self.env['stock.warehouse'].with_context( + company_id=self.company_2.id).create({ + 'name': 'C2_2', + 'code': 'C2_2', + 'company_id': self.company_2.id + }) + stock_request.warehouse_id = wh2_2 + stock_request.onchange_warehouse_id() + + self.assertEqual(stock_request.warehouse_id, wh2_2) + + self.stock_request_user.company_id = self.main_company + stock_request.warehouse_id = self.warehouse + stock_request.onchange_warehouse_id() + + self.assertEqual( + stock_request.company_id, self.main_company) + self.assertEqual( + stock_request.location_id, self.warehouse.lot_stock_id) + + def test_stock_request_validations_01(self): + vals = { + 'product_id': self.product.id, + 'product_uom_id': self.env.ref('product.product_uom_kgm').id, + 'product_uom_qty': 5.0, + 'company_id': self.main_company.id, + 'warehouse_id': self.warehouse.id, + 'location_id': self.warehouse.lot_stock_id.id, + } + # Select a UoM that is incompatible with the product's UoM + with self.assertRaises(exceptions.ValidationError): + self.stock_request.sudo( + self.stock_request_user).create(vals) + + def test_stock_request_validations_02(self): + vals = { + 'product_id': self.product.id, + 'product_uom_id': self.product.uom_id.id, + 'product_uom_qty': 5.0, + 'company_id': self.main_company.id, + 'warehouse_id': self.warehouse.id, + 'location_id': self.warehouse.lot_stock_id.id, + } + + stock_request = self.stock_request.sudo( + self.stock_request_user).create(vals) + + # With no route found, should raise an error + with self.assertRaises(exceptions.UserError): + stock_request.action_confirm() + + def test_create_request_01(self): + + vals = { + 'product_id': self.product.id, + 'product_uom_id': self.product.uom_id.id, + 'product_uom_qty': 5.0, + 'company_id': self.main_company.id, + 'warehouse_id': self.warehouse.id, + 'location_id': self.warehouse.lot_stock_id.id, + } + + stock_request = self.stock_request.sudo( + self.stock_request_user).create(vals) + + self.product.route_ids = [(6, 0, self.route.ids)] + stock_request.action_confirm() + self.assertEqual(stock_request.state, 'open') + self.assertEqual(len(stock_request.sudo().picking_ids), 1) + self.assertEqual(len(stock_request.sudo().move_ids), 1) + self.assertEqual(stock_request.sudo().move_ids[0].location_dest_id, + stock_request.location_id) + self.assertEqual(stock_request.qty_in_progress, + stock_request.product_uom_qty) + self.env['stock.quant'].create({ + 'product_id': self.product.id, + 'location_id': self.ressuply_loc.id, + 'quantity': 5.0}) + picking = stock_request.sudo().picking_ids[0] + picking.action_confirm() + self.assertEqual(stock_request.qty_in_progress, 5.0) + self.assertEqual(stock_request.qty_done, 0.0) + picking.action_assign() + packout1 = picking.move_line_ids[0] + packout1.qty_done = 5 + picking.action_done() + self.assertEqual(stock_request.qty_in_progress, 0.0) + self.assertEqual(stock_request.qty_done, + stock_request.product_uom_qty) + self.assertEqual(stock_request.state, 'done') + + def test_create_request_02(self): + """Use different UoM's""" + + vals = { + 'product_id': self.product.id, + 'product_uom_id': self.uom_dozen.id, + 'product_uom_qty': 1.0, + 'company_id': self.main_company.id, + 'warehouse_id': self.warehouse.id, + 'location_id': self.warehouse.lot_stock_id.id, + } + + stock_request = self.stock_request.sudo( + self.stock_request_user).create(vals) + + self.product.route_ids = [(6, 0, self.route.ids)] + stock_request.action_confirm() + self.assertEqual(stock_request.state, 'open') + self.assertEqual(len(stock_request.sudo().picking_ids), 1) + self.assertEqual(len(stock_request.sudo().move_ids), 1) + self.assertEqual(stock_request.sudo().move_ids[0].location_dest_id, + stock_request.location_id) + self.assertEqual(stock_request.qty_in_progress, + stock_request.product_uom_qty) + self.env['stock.quant'].create({ + 'product_id': self.product.id, + 'location_id': self.ressuply_loc.id, + 'quantity': 12.0}) + picking = stock_request.sudo().picking_ids[0] + picking.action_confirm() + self.assertEqual(stock_request.qty_in_progress, 1.0) + self.assertEqual(stock_request.qty_done, 0.0) + picking.action_assign() + packout1 = picking.move_line_ids[0] + packout1.qty_done = 1 + picking.action_done() + self.assertEqual(stock_request.qty_in_progress, 0.0) + self.assertEqual(stock_request.qty_done, + stock_request.product_uom_qty) + self.assertEqual(stock_request.state, 'done') + + def test_create_request_03(self): + """Multiple stock requests""" + vals = { + 'product_id': self.product.id, + 'product_uom_id': self.product.uom_id.id, + 'product_uom_qty': 4.0, + 'company_id': self.main_company.id, + 'warehouse_id': self.warehouse.id, + 'location_id': self.warehouse.lot_stock_id.id, + } + + stock_request_1 = self.env['stock.request'].sudo( + self.stock_request_user).create(vals) + stock_request_2 = self.env['stock.request'].sudo( + self.stock_request_manager).create(vals) + stock_request_2.product_uom_qty = 6.0 + self.product.route_ids = [(6, 0, self.route.ids)] + stock_request_1.action_confirm() + stock_request_2.action_confirm() + self.assertEqual(len(stock_request_1.sudo().picking_ids), 1) + self.assertEqual(stock_request_1.sudo().picking_ids, + stock_request_2.sudo().picking_ids) + self.assertEqual(stock_request_1.sudo().move_ids, + stock_request_2.sudo().move_ids) + self.env['stock.quant'].create({ + 'product_id': self.product.id, + 'location_id': self.ressuply_loc.id, + 'quantity': 10.0}) + picking = stock_request_1.sudo().picking_ids[0] + picking.action_confirm() + picking.action_assign() + packout1 = picking.move_line_ids[0] + packout1.qty_done = 10 + picking.action_done() + + def test_cancel_request(self): + vals = { + 'product_id': self.product.id, + 'product_uom_id': self.product.uom_id.id, + 'product_uom_qty': 5.0, + 'company_id': self.main_company.id, + 'warehouse_id': self.warehouse.id, + 'location_id': self.warehouse.lot_stock_id.id, + } + + stock_request = self.stock_request.sudo( + self.stock_request_user).create(vals) + + self.product.route_ids = [(6, 0, self.route.ids)] + stock_request.action_confirm() + self.assertEqual(len(stock_request.sudo().picking_ids), 1) + self.assertEqual(len(stock_request.sudo().move_ids), 1) + self.assertEqual(stock_request.sudo().move_ids[0].location_dest_id, + stock_request.location_id) + self.assertEqual(stock_request.qty_in_progress, + stock_request.product_uom_qty) + self.env['stock.quant'].create({ + 'product_id': self.product.id, + 'location_id': self.ressuply_loc.id, + 'quantity': 5.0}) + picking = stock_request.sudo().picking_ids[0] + picking.action_confirm() + self.assertEqual(stock_request.qty_in_progress, 5.0) + self.assertEqual(stock_request.qty_done, 0.0) + picking.action_assign() + stock_request.action_cancel() + + self.assertEqual(stock_request.qty_in_progress, 0.0) + self.assertEqual(stock_request.qty_done, 0.0) + self.assertEqual(len(stock_request.sudo().picking_ids), 0) + + # Set the request back to draft + stock_request.action_draft() + + self.assertEqual(stock_request.state, 'draft') + + # Re-confirm. We expect new pickings to be created + stock_request.action_confirm() + self.assertEqual(len(stock_request.sudo().picking_ids), 1) + self.assertEqual(len(stock_request.sudo().move_ids), 2) + + def test_view_actions(self): + vals = { + 'product_id': self.product.id, + 'product_uom_id': self.product.uom_id.id, + 'product_uom_qty': 5.0, + 'company_id': self.main_company.id, + 'warehouse_id': self.warehouse.id, + 'location_id': self.warehouse.lot_stock_id.id, + } + + stock_request = self.stock_request.sudo().create(vals) + self.product.route_ids = [(6, 0, self.route.ids)] + stock_request.action_confirm() + action = stock_request.action_view_transfer() + + self.assertEqual('domain' in action.keys(), True) + self.assertEqual('views' in action.keys(), True) + self.assertEqual(action['res_id'], stock_request.picking_ids[0].id) + + action = stock_request.picking_ids[0].action_view_stock_request() + self.assertEqual(action['type'], 'ir.actions.act_window') + self.assertEqual(action['res_id'], stock_request.id) diff --git a/stock_request/views/stock_move_views.xml b/stock_request/views/stock_move_views.xml new file mode 100644 index 000000000..9a1d1baf9 --- /dev/null +++ b/stock_request/views/stock_move_views.xml @@ -0,0 +1,46 @@ + + + + + stock.move.operations.form + stock.move + + + + + + + + + + + stock.move.form + stock.move + + + + + + + + + + + + + stock.move.form + stock.move + + + + + + + + + + + + diff --git a/stock_request/views/stock_picking_views.xml b/stock_request/views/stock_picking_views.xml new file mode 100644 index 000000000..1ab3802d9 --- /dev/null +++ b/stock_request/views/stock_picking_views.xml @@ -0,0 +1,24 @@ + + + + + stock.picking.form + stock.picking + + + +
+ +
+
+
+ +
diff --git a/stock_request/views/stock_request_allocation_views.xml b/stock_request/views/stock_request_allocation_views.xml new file mode 100644 index 000000000..74afe8913 --- /dev/null +++ b/stock_request/views/stock_request_allocation_views.xml @@ -0,0 +1,51 @@ + + + + + + stock.request.allocation.tree + stock.request.allocation + + + + + + + + + + + + + + + + stock.request.allocation.form + stock.request.allocation + +
+ + + + + + + + + + + + + + + + +
+
+
+ +
diff --git a/stock_request/views/stock_request_views.xml b/stock_request/views/stock_request_views.xml new file mode 100644 index 000000000..9734edc16 --- /dev/null +++ b/stock_request/views/stock_request_views.xml @@ -0,0 +1,143 @@ + + + + + + stock.request.tree + stock.request + + + + + + + + + + + + + + + + + + + stock.request.search + stock.request + + + + + + + + + + + + + + + + + + stock.request.form + stock.request + +
+
+
+ +
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+
+
+
+ + + Stock Requests + stock.request + ir.actions.act_window + form + tree,form + + + +

+ Click to add a Stock Request. +

+
+
+ + + + + +