diff --git a/rma/__init__.py b/rma/__init__.py new file mode 100644 index 00000000..e4f4917a --- /dev/null +++ b/rma/__init__.py @@ -0,0 +1,3 @@ +from . import controllers +from . import models +from . import wizard diff --git a/rma/__manifest__.py b/rma/__manifest__.py new file mode 100644 index 00000000..00222223 --- /dev/null +++ b/rma/__manifest__.py @@ -0,0 +1,27 @@ +# © 2018 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + 'name': 'Hibou RMAs', + 'version': '11.0.1.0.0', + 'category': 'Warehouse', + 'author': "Hibou Corp.", + 'license': 'AGPL-3', + 'website': 'https://hibou.io/', + 'depends': [ + 'stock', + 'delivery', + ], + 'data': [ + 'data/ir_sequence_data.xml', + 'security/ir.model.access.csv', + 'views/rma_views.xml', + 'views/stock_picking_views.xml', + 'wizard/rma_lines_views.xml', + ], + 'demo': [ + 'data/rma_demo.xml', + ], + 'installable': True, + 'application': True, + } diff --git a/rma/controllers/__init__.py b/rma/controllers/__init__.py new file mode 100644 index 00000000..12a7e529 --- /dev/null +++ b/rma/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/rma/controllers/main.py b/rma/controllers/main.py new file mode 100644 index 00000000..98ff451c --- /dev/null +++ b/rma/controllers/main.py @@ -0,0 +1,49 @@ +from odoo import http, exceptions +from base64 import b64decode +import hmac +from hashlib import sha256 +from datetime import datetime +from time import mktime + + +def create_hmac(secret, a_attchment_id, e_expires): + return hmac.new(secret.encode(), str(str(a_attchment_id) + str(e_expires)).encode(), sha256).hexdigest() + + +def check_hmac(secret, hash_, a_attachment_id, e_expires): + myh = hmac.new(secret.encode(), str(str(a_attachment_id) + str(e_expires)).encode(), sha256) + return hmac.compare_digest(str(hash_), myh.hexdigest()) + + +class RMAController(http.Controller): + + @http.route(['/rma_label'], type='http', auth='public', website=True) + def index(self, *args, **request): + a_attachment_id = request.get('a') + e_expires = request.get('e') + hash = request.get('h') + + if not all([a_attachment_id, e_expires, hash]): + return http.Response('Invalid Request', status=400) + + now = datetime.utcnow() + now = int(mktime(now.timetuple())) + + config = http.request.env['ir.config_parameter'].sudo() + secret = str(config.search([('key', '=', 'database.secret')], limit=1).value) + + if not check_hmac(secret, hash, a_attachment_id, e_expires): + return http.Response('Invalid Request', status=400) + + if now > int(e_expires): + return http.Response('Expired', status=404) + + attachment = http.request.env['ir.attachment'].sudo().search([('id', '=', int(a_attachment_id))], limit=1) + if attachment: + data = attachment.datas + filename = attachment.name + mimetype = attachment.mimetype + return http.request.make_response(b64decode(data), [ + ('Content-Type', mimetype), + ('Content-Disposition', 'attachment; filename="' + filename + '"')]) + return http.Response('Invalid Attachment', status=404) diff --git a/rma/data/ir_sequence_data.xml b/rma/data/ir_sequence_data.xml new file mode 100644 index 00000000..b021bdee --- /dev/null +++ b/rma/data/ir_sequence_data.xml @@ -0,0 +1,14 @@ + + + + + + RMA + rma.rma + RMA + 3 + + + + + diff --git a/rma/data/rma_demo.xml b/rma/data/rma_demo.xml new file mode 100644 index 00000000..c7fecc6a --- /dev/null +++ b/rma/data/rma_demo.xml @@ -0,0 +1,44 @@ + + + + Missing Item + + + + + + make_to_stock + + + + RMA Returns + standard + WH/RMA/ + + + + + + RMA Receipts + + + incoming + + + + + + + + + Picking Return + stock_picking + + + + + + make_to_stock + + + \ No newline at end of file diff --git a/rma/models/__init__.py b/rma/models/__init__.py new file mode 100644 index 00000000..17729278 --- /dev/null +++ b/rma/models/__init__.py @@ -0,0 +1,2 @@ +from . import rma +from . import stock_picking diff --git a/rma/models/rma.py b/rma/models/rma.py new file mode 100644 index 00000000..15616885 --- /dev/null +++ b/rma/models/rma.py @@ -0,0 +1,457 @@ +from odoo import api, fields, models, _ +from odoo.exceptions import UserError +from ..controllers.main import create_hmac +from datetime import timedelta, datetime +from time import mktime + + +class RMATemplate(models.Model): + _name = 'rma.template' + + name = fields.Char(string='Name') + usage = fields.Selection([ + ('stock_picking', 'Stock Picking'), + ], string='Applies To') + description = fields.Html(string='Internal Instructions') + customer_description = fields.Html(string='Customer Instructions') + valid_days = fields.Integer(string='Expire in Days') + + create_in_picking = fields.Boolean(string='Create Inbound Picking') + create_out_picking = fields.Boolean(string='Create Outbound Picking') + + in_type_id = fields.Many2one('stock.picking.type', string='Inbound Picking Type') + out_type_id = fields.Many2one('stock.picking.type', string='Outbound Picking Type') + + in_location_id = fields.Many2one('stock.location', string='Inbound Source Location') + in_location_dest_id = fields.Many2one('stock.location', string='Inbound Destination Location') + in_carrier_id = fields.Many2one('delivery.carrier', string='Inbound Carrier') + in_require_return = fields.Boolean(string='Inbound Require return of picking') + in_procure_method = fields.Selection([ + ('make_to_stock', 'Take from Stock'), + ('make_to_order', 'Apply Procurements') + ], string="Inbound Procurement Method", default='make_to_stock') + in_to_refund = fields.Boolean(string='Inbound Mark Refund', oldname='in_to_refund_so') + + out_location_id = fields.Many2one('stock.location', string='Outbound Source Location') + out_location_dest_id = fields.Many2one('stock.location', string='Outbound Destination Location') + out_carrier_id = fields.Many2one('delivery.carrier', string='Outbound Carrier') + out_require_return = fields.Boolean(string='Outbound Require picking to duplicate') + out_procure_method = fields.Selection([ + ('make_to_stock', 'Take from Stock'), + ('make_to_order', 'Apply Procurements') + ], string="Outbound Procurement Method", default='make_to_stock') + out_to_refund = fields.Boolean(string='Outbound Mark Refund') + + def _values_for_in_picking(self, rma): + return { + 'origin': rma.name, + 'partner_id': rma.partner_shipping_id.id, + 'picking_type_id': self.in_type_id.id, + 'location_id': self.in_location_id.id, + 'location_dest_id': self.in_location_dest_id.id, + 'carrier_id': self.in_carrier_id.id if self.in_carrier_id else False, + 'move_lines': [(0, None, { + 'name': rma.name + ' IN: ' + l.product_id.name_get()[0][1], + 'product_id': l.product_id.id, + 'product_uom_qty': l.product_uom_qty, + 'product_uom': l.product_uom_id.id, + 'procure_method': self.in_procure_method, + 'to_refund': self.in_to_refund, + }) for l in rma.lines], + } + + def _values_for_out_picking(self, rma): + return { + 'origin': rma.name, + 'partner_id': rma.partner_shipping_id.id, + 'picking_type_id': self.out_type_id.id, + 'location_id': self.out_location_id.id, + 'location_dest_id': self.out_location_dest_id.id, + 'carrier_id': self.out_carrier_id.id if self.out_carrier_id else False, + 'move_lines': [(0, None, { + 'name': rma.name + ' OUT: ' + l.product_id.name_get()[0][1], + 'product_id': l.product_id.id, + 'product_uom_qty': l.product_uom_qty, + 'product_uom': l.product_uom_id.id, + 'procure_method': self.out_procure_method, + 'to_refund': self.out_to_refund, + }) for l in rma.lines], + } + + +class RMATag(models.Model): + _name = "rma.tag" + _description = "RMA Tag" + + name = fields.Char('Tag Name', required=True) + color = fields.Integer('Color Index') + + _sql_constraints = [ + ('name_uniq', 'unique (name)', "Tag name already exists !"), + ] + + +class RMA(models.Model): + _name = 'rma.rma' + _inherit = ['mail.thread', 'mail.activity.mixin'] + _description = 'RMA' + _order = 'id desc' + + name = fields.Char(string='Number', copy=False) + state = fields.Selection([ + ('draft', 'New'), + ('confirmed', 'Confirmed'), + ('done', 'Done'), + ('cancel', 'Cancelled'), + ], string='State', default='draft', copy=False) + company_id = fields.Many2one('res.company', 'Company') + template_id = fields.Many2one('rma.template', string='Type', required=True) + stock_picking_id = fields.Many2one('stock.picking', string='Stock Picking') + stock_picking_rma_count = fields.Integer('Number of RMAs for this Picking', compute='_compute_stock_picking_rma_count') + partner_id = fields.Many2one('res.partner', string='Partner') + partner_shipping_id = fields.Many2one('res.partner', string='Shipping') + lines = fields.One2many('rma.line', 'rma_id', string='Lines') + tag_ids = fields.Many2many('rma.tag', 'rma_tags_rel', 'rma_id', 'tag_id', string='Tags') + description = fields.Html(string='Internal Instructions', related='template_id.description') + customer_description = fields.Html(string='Customer Instructions', related='template_id.customer_description') + template_usage = fields.Selection(string='Template Usage', related='template_id.usage') + validity_date = fields.Datetime(string='Expiration Date') + + in_picking_id = fields.Many2one('stock.picking', string='Inbound Picking', copy=False) + out_picking_id = fields.Many2one('stock.picking', string='Outbound Picking', copy=False) + + in_picking_state = fields.Selection(string='In Picking State', related='in_picking_id.state') + out_picking_state = fields.Selection(string='Out Picking State', related='out_picking_id.state') + + in_picking_carrier_id = fields.Many2one('delivery.carrier', related='in_picking_id.carrier_id') + out_picking_carrier_id = fields.Many2one('delivery.carrier', related='out_picking_id.carrier_id') + + in_carrier_tracking_ref = fields.Char(related='in_picking_id.carrier_tracking_ref') + in_label_url = fields.Char(compute='_compute_in_label_url') + out_carrier_tracking_ref = fields.Char(related='out_picking_id.carrier_tracking_ref') + + + @api.onchange('template_usage') + @api.multi + def _onchange_template_usage(self): + now = datetime.now() + for rma in self: + if rma.template_id.valid_days: + rma.validity_date = now + timedelta(days=rma.template_id.valid_days) + if rma.template_usage != 'stock_picking': + rma.stock_picking_id = False + + @api.onchange('stock_picking_id') + @api.multi + def _onchange_stock_picking_id(self): + for rma in self.filtered(lambda rma: rma.stock_picking_id): + rma.partner_id = rma.stock_picking_id.partner_id + rma.partner_shipping_id = rma.stock_picking_id.partner_id + + @api.onchange('in_carrier_tracking_ref', 'validity_date') + @api.multi + def _compute_in_label_url(self): + config = self.env['ir.config_parameter'].sudo() + secret = config.search([('key', '=', 'database.secret')], limit=1) + secret = str(secret.value) if secret else '' + base_url = config.search([('key', '=', 'web.base.url')], limit=1) + base_url = str(base_url.value) if base_url else '' + for rma in self: + if not rma.in_picking_id: + rma.in_label_url = '' + continue + if rma.validity_date: + e_expires = int(mktime(fields.Datetime.from_string(rma.validity_date).timetuple())) + else: + year = datetime.now() + timedelta(days=365) + e_expires = int(mktime(year.timetuple())) + attachment = self.env['ir.attachment'].search([ + ('res_model', '=', 'stock.picking'), + ('res_id', '=', rma.in_picking_id.id), + ('name', 'like', 'Label%')], limit=1) + if not attachment: + rma.in_label_url = '' + continue + rma.in_label_url = base_url + '/rma_label?a=' + \ + str(attachment.id) + '&e=' + str(e_expires) + \ + '&h=' + create_hmac(secret, attachment.id, e_expires) + + @api.multi + @api.depends('stock_picking_id') + def _compute_stock_picking_rma_count(self): + for rma in self: + if rma.stock_picking_id: + rma_data = self.read_group([('stock_picking_id', '=', rma.stock_picking_id.id), ('state', '!=', 'cancel')], + ['stock_picking_id'], ['stock_picking_id']) + if rma_data: + rma.stock_picking_rma_count = rma_data[0]['stock_picking_id_count'] + else: + rma.stock_picking_rma_count = 0.0 + + + @api.multi + def open_stock_picking_rmas(self): + return { + 'type': 'ir.actions.act_window', + 'name': _('Picking RMAs'), + 'res_model': 'rma.rma', + 'view_mode': 'tree,form', + 'context': {'search_default_stock_picking_id': self[0].stock_picking_id.id} + } + + @api.model + def create(self, vals): + if vals.get('name', _('New')) == _('New'): + if 'company_id' in vals: + vals['name'] = self.env['ir.sequence'].with_context(force_company=vals['company_id']).next_by_code('rma.rma') or _('New') + else: + vals['name'] = self.env['ir.sequence'].next_by_code('rma.rma') or _('New') + + result = super(RMA, self).create(vals) + return result + + @api.multi + def action_confirm(self): + for rma in self: + in_picking_id = False + out_picking_id = False + if any((not rma.template_id, not rma.lines, not rma.partner_id, not rma.partner_shipping_id)): + raise UserError(_('You can only confirm RMAs with lines, and partner information.')) + if rma.template_id.create_in_picking: + in_picking_id = rma._create_in_picking() + if in_picking_id: + in_picking_id.action_confirm() + in_picking_id.action_assign() + if rma.template_id.create_out_picking: + out_picking_id = rma._create_out_picking() + if out_picking_id: + out_picking_id.action_confirm() + out_picking_id.action_assign() + rma.write({'state': 'confirmed', + 'in_picking_id': in_picking_id.id if in_picking_id else False, + 'out_picking_id': out_picking_id.id if out_picking_id else False}) + + @api.multi + def action_done(self): + for rma in self: + if rma.in_picking_id and rma.in_picking_id.state not in ('done', 'cancel'): + raise UserError(_('Inbound picking not complete or cancelled.')) + if rma.out_picking_id and rma.out_picking_id.state not in ('done', 'cancel'): + raise UserError(_('Outbound picking not complete or cancelled.')) + self.write({'state': 'done'}) + + @api.multi + def action_cancel(self): + for rma in self: + rma.in_picking_id.action_cancel() + rma.out_picking_id.action_cancel() + self.write({'state': 'cancel'}) + + @api.multi + def action_draft(self): + self.filtered(lambda l: l.state == 'cancel').write({ + 'state': 'draft', 'in_picking_id': False, 'out_picking_id': False}) + + @api.multi + def _create_in_picking(self): + if self.template_usage and hasattr(self, '_create_in_picking_' + self.template_usage): + return getattr(self, '_create_in_picking_' + self.template_usage)() + values = self.template_id._values_for_in_picking(self) + return self.env['stock.picking'].sudo().create(values) + + def _create_out_picking(self): + if self.template_usage and hasattr(self, '_create_out_picking_' + self.template_usage): + return getattr(self, '_create_out_picking_' + self.template_usage)() + values = self.template_id._values_for_out_picking(self) + return self.env['stock.picking'].sudo().create(values) + + def _find_candidate_return_picking(self, product_ids, pickings, location_id): + done_pickings = pickings.filtered(lambda p: p.state == 'done' and p.location_dest_id.id == location_id) + for p in done_pickings: + p_product_ids = p.move_lines.filtered(lambda l: l.state == 'done').mapped('product_id.id') + if set(product_ids) & set(p_product_ids) == set(product_ids): + return p + return None + + @api.multi + def action_in_picking_send_to_shipper(self): + for rma in self: + if rma.in_picking_id and rma.in_picking_carrier_id: + rma.in_picking_id.send_to_shipper() + + @api.multi + def action_add_picking_lines(self): + make_line_obj = self.env['rma.picking.make.lines'] + for rma in self: + lines = make_line_obj.create({ + 'rma_id': rma.id, + }) + action = self.env.ref('rma.action_rma_add_lines').read()[0] + action['res_id'] = lines.id + return action + + @api.multi + def unlink(self): + for rma in self: + if rma.state not in ('draft'): + raise UserError(_('You can not delete a non-draft RMA.')) + return super(RMA, self).unlink() + + def _picking_from_values(self, values, values_update, move_line_values_update): + values.update(values_update) + move_lines = [] + for l1, l2, vals in values['move_lines']: + vals.update(move_line_values_update) + move_lines.append((l1, l2, vals)) + values['move_lines'] = move_lines + return self.env['stock.picking'].sudo().create(values) + + def _new_in_picking(self, old_picking): + new_picking = old_picking.copy({ + 'move_lines': [], + 'picking_type_id': self.template_id.in_type_id.id, + 'state': 'draft', + 'origin': old_picking.name + ' ' + self.name, + 'location_id': self.template_id.in_location_id.id, + 'location_dest_id': self.template_id.in_location_dest_id.id, + 'carrier_id': self.template_id.in_carrier_id.id if self.template_id.in_carrier_id else 0, + 'carrier_tracking_ref': False, + 'carrier_price': False + }) + new_picking.message_post_with_view('mail.message_origin_link', + values={'self': new_picking, 'origin': self}, + subtype_id=self.env.ref('mail.mt_note').id) + return new_picking + + def _new_in_move_vals(self, rma_line, new_picking, old_move): + return { + 'name': self.name + ' IN: ' + rma_line.product_id.name_get()[0][1], + 'product_id': rma_line.product_id.id, + 'product_uom_qty': rma_line.product_uom_qty, + 'product_uom': rma_line.product_uom_id.id, + 'picking_id': new_picking.id, + 'state': 'draft', + 'location_id': old_move.location_dest_id.id, + 'location_dest_id': self.template_id.in_location_dest_id.id, + 'picking_type_id': new_picking.picking_type_id.id, + 'warehouse_id': new_picking.picking_type_id.warehouse_id.id, + 'origin_returned_move_id': old_move.id, + 'procure_method': self.template_id.in_procure_method, + 'to_refund': self.template_id.in_to_refund, + } + + def _new_in_moves(self, old_picking, new_picking, move_update): + lines = self.lines.filtered(lambda l: l.product_uom_qty >= 1) + if not lines: + raise UserError(_('You have no lines with positive quantity.')) + + moves = self.env['stock.move'] + for l in lines: + return_move = old_picking.move_lines.filtered(lambda ol: ol.state == 'done' and ol.product_id.id == l.product_id.id)[0] + copy_vals = self._new_in_move_vals(l, new_picking, return_move) + copy_vals.update(move_update) + r = return_move.copy(copy_vals) + vals = {} + # +--------------------------------------------------------------------------------------------------------+ + # | picking_pick <--Move Orig-- picking_pack --Move Dest--> picking_ship + # | | returned_move_ids ↑ | returned_move_ids + # | ↓ | return_line.move_id ↓ + # | return pick(Add as dest) return toLink return ship(Add as orig) + # +--------------------------------------------------------------------------------------------------------+ + move_orig_to_link = return_move.move_dest_ids.mapped('returned_move_ids') + move_dest_to_link = return_move.move_orig_ids.mapped('returned_move_ids') + vals['move_orig_ids'] = [(4, m.id) for m in move_orig_to_link | return_move] + vals['move_dest_ids'] = [(4, m.id) for m in move_dest_to_link] + r.write(vals) + moves += r + return moves + + def _new_out_picking(self, old_picking): + new_picking = old_picking.copy({ + 'move_lines': [], + 'picking_type_id': self.template_id.out_type_id.id, + 'state': 'draft', + 'origin': old_picking.name + ' ' + self.name, + 'location_id': self.template_id.out_location_id.id, + 'location_dest_id': self.template_id.out_location_dest_id.id, + 'carrier_id': self.template_id.out_carrier_id.id if self.template_id.out_carrier_id else 0, + 'carrier_tracking_ref': False, + 'carrier_price': False + }) + new_picking.message_post_with_view('mail.message_origin_link', + values={'self': new_picking, 'origin': self}, + subtype_id=self.env.ref('mail.mt_note').id) + return new_picking + + def _new_out_move_vals(self, rma_line, new_picking, old_move): + return { + 'name': self.name + ' OUT: ' + rma_line.product_id.name_get()[0][1], + 'product_id': rma_line.product_id.id, + 'product_uom_qty': rma_line.product_uom_qty, + 'picking_id': new_picking.id, + 'state': 'draft', + 'location_id': self.template_id.out_location_id.id, + 'location_dest_id': self.template_id.out_location_dest_id.id, + 'picking_type_id': new_picking.picking_type_id.id, + 'warehouse_id': new_picking.picking_type_id.warehouse_id.id, + 'origin_returned_move_id': False, + 'procure_method': self.template_id.out_procure_method, + 'to_refund': self.template_id.out_to_refund, + } + + def _new_out_moves(self, old_picking, new_picking, move_update): + lines = self.lines.filtered(lambda l: l.product_uom_qty >= 1) + if not lines: + raise UserError(_('You have no lines with positive quantity.')) + moves = self.env['stock.move'] + for l in lines: + return_move = old_picking.move_lines.filtered(lambda ol: ol.state == 'done' and ol.product_id.id == l.product_id.id)[0] + copy_vals = self._new_out_move_vals(l, new_picking, return_move) + copy_vals.update(move_update) + moves += return_move.copy(copy_vals) + return moves + + def _create_in_picking_stock_picking(self): + if not self.stock_picking_id or self.stock_picking_id.state != 'done': + raise UserError(_('You must have a completed stock picking for this RMA.')) + if not self.template_id.in_require_return: + group_id = self.stock_picking_id.group_id.id if self.stock_picking_id.group_id else 0 + values = self.template_id._values_for_in_picking(self) + update = {'group_id': group_id} + return self._picking_from_values(values, update, update) + + old_picking = self.stock_picking_id + + new_picking = self._new_in_picking(old_picking) + self._new_in_moves(old_picking, new_picking, {}) + return new_picking + + def _create_out_picking_stock_picking(self): + if not self.stock_picking_id or self.stock_picking_id.state != 'done': + raise UserError(_('You must have a completed stock picking for this RMA.')) + if not self.template_id.out_require_return: + group_id = self.stock_picking_id.group_id.id if self.stock_picking_id.group_id else 0 + values = self.template_id._values_for_out_picking(self) + update = {'group_id': group_id} + return self._picking_from_values(values, update, update) + + old_picking = self.stock_picking_id + new_picking = self._new_out_picking(old_picking) + self._new_out_moves(old_picking, new_picking, {}) + return new_picking + + +class RMALine(models.Model): + _name = 'rma.line' + + rma_id = fields.Many2one('rma.rma', string='RMA') + product_id = fields.Many2one('product.product', 'Product') + product_uom_id = fields.Many2one('product.uom', 'UOM') + product_uom_qty = fields.Float(string='QTY') + rma_template_usage = fields.Selection(related='rma_id.template_usage') + + @api.onchange('product_id') + @api.multi + def _onchange_product_id(self): + for line in self: + line.product_uom_id = line.product_id.uom_id diff --git a/rma/models/stock_picking.py b/rma/models/stock_picking.py new file mode 100644 index 00000000..c2fbe651 --- /dev/null +++ b/rma/models/stock_picking.py @@ -0,0 +1,13 @@ +from odoo import api, models, _ +from odoo.exceptions import UserError + + +class StockPicking(models.Model): + _inherit = 'stock.picking' + + @api.multi + def send_to_shipper(self): + res = False + for pick in self.filtered(lambda p: not p.carrier_tracking_ref): + res = super(StockPicking, pick).send_to_shipper() + return res diff --git a/rma/security/ir.model.access.csv b/rma/security/ir.model.access.csv new file mode 100644 index 00000000..0b27a8cf --- /dev/null +++ b/rma/security/ir.model.access.csv @@ -0,0 +1,7 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +"manage_rma stock","manage rma","model_rma_rma","stock.group_stock_user",1,1,1,1 +"manage_rma_line stock","manage rma line","model_rma_line","stock.group_stock_user",1,1,1,1 +"manage_rma_template stock","manage rma template","model_rma_template","stock.group_stock_manager",1,1,1,1 +"manage_rma_tag stock","manage rma tag","model_rma_tag","stock.group_stock_manager",1,1,1,1 +"access_rma_template stock","access rma template","model_rma_template","stock.group_stock_user",1,1,0,0 +"access_rma_tag stock","access rma tag","model_rma_tag","stock.group_stock_user",1,0,0,0 \ No newline at end of file diff --git a/rma/tests/__init__.py b/rma/tests/__init__.py new file mode 100644 index 00000000..c95f5fde --- /dev/null +++ b/rma/tests/__init__.py @@ -0,0 +1 @@ +from . import test_rma diff --git a/rma/tests/test_rma.py b/rma/tests/test_rma.py new file mode 100644 index 00000000..708c3500 --- /dev/null +++ b/rma/tests/test_rma.py @@ -0,0 +1,222 @@ +from odoo.tests import common +from odoo.exceptions import UserError, ValidationError +import logging + + +_logger = logging.getLogger(__name__) + + +class TestRMA(common.TransactionCase): + def setUp(self): + super(TestRMA, self).setUp() + self.product1 = self.env.ref('product.product_product_24') + self.template_missing = self.env.ref('rma.template_missing_item') + self.template_return = self.env.ref('rma.template_picking_return') + self.partner1 = self.env.ref('base.res_partner_2') + + def test_00_basic_rma(self): + self.template_missing.usage = False + rma = self.env['rma.rma'].create({ + 'template_id': self.template_missing.id, + 'partner_id': self.partner1.id, + 'partner_shipping_id': self.partner1.id, + }) + self.assertEqual(rma.state, 'draft') + rma_line = self.env['rma.line'].create({ + 'rma_id': rma.id, + 'product_id': self.product1.id, + 'product_uom_id': self.product1.uom_id.id, + 'product_uom_qty': 2.0, + }) + rma.action_confirm() + # Should have made pickings + self.assertEqual(rma.state, 'confirmed') + # No inbound picking + self.assertFalse(rma.in_picking_id) + # Good outbound picking + self.assertTrue(rma.out_picking_id) + self.assertEqual(rma_line.product_id, rma.out_picking_id.move_lines.product_id) + self.assertEqual(rma_line.product_uom_qty, rma.out_picking_id.move_lines.product_uom_qty) + + with self.assertRaises(UserError): + rma.action_done() + + rma.out_picking_id.move_lines.quantity_done = 2.0 + rma.out_picking_id.action_done() + rma.action_done() + self.assertEqual(rma.state, 'done') + + def test_10_rma_cancel(self): + self.template_missing.usage = False + rma = self.env['rma.rma'].create({ + 'template_id': self.template_missing.id, + 'partner_id': self.partner1.id, + 'partner_shipping_id': self.partner1.id, + }) + self.assertEqual(rma.state, 'draft') + rma_line = self.env['rma.line'].create({ + 'rma_id': rma.id, + 'product_id': self.product1.id, + 'product_uom_id': self.product1.uom_id.id, + 'product_uom_qty': 2.0, + }) + rma.action_confirm() + # Good outbound picking + self.assertEqual(rma.out_picking_id.move_lines.state, 'assigned') + rma.action_cancel() + self.assertEqual(rma.out_picking_id.move_lines.state, 'cancel') + + def test_20_picking_rma(self): + type_out = self.env.ref('stock.picking_type_out') + location = self.env.ref('stock.stock_location_stock') + location_customer = self.env.ref('stock.stock_location_customers') + self.product1.tracking = 'serial' + + # Need to ensure this is the only quant that can be reserved for this move. + adj = self.env['stock.inventory'].create({ + 'name': 'Test', + 'location_id': location.id, + 'filter': 'product', + 'product_id': self.product1.id, + }) + lot = self.env['stock.production.lot'].create({ + 'product_id': self.product1.id, + 'name': 'X100', + 'product_uom_id': self.product1.uom_id.id, + }) + self.assertFalse(lot.quant_ids) + self.assertEqual(lot.product_qty, 0.0) + + adj.action_start() + self.assertTrue(adj.line_ids) + adj.line_ids.write({ + 'product_qty': 0.0, + }) + + self.env['stock.inventory.line'].create({ + 'inventory_id': adj.id, + 'location_id': location.id, + 'product_id': self.product1.id, + 'product_uom_id': self.product1.uom_id.id, + 'prod_lot_id': lot.id, + 'product_qty': 1.0, + }) + + adj.action_done() + self.assertEqual(self.product1.qty_available, 1.0) + self.assertTrue(lot.quant_ids) + self.assertEqual(lot.product_qty, 1.0) + + # Create initial picking that will be returned by RMA + picking_out = self.env['stock.picking'].create({ + 'partner_id': self.partner1.id, + 'name': 'testpicking', + 'picking_type_id': type_out.id, + 'location_id': location.id, + 'location_dest_id': location_customer.id, + }) + self.env['stock.move'].create({ + 'name': self.product1.name, + 'product_id': self.product1.id, + 'product_uom_qty': 1.0, + 'product_uom': self.product1.uom_id.id, + 'picking_id': picking_out.id, + 'location_id': location.id, + 'location_dest_id': location_customer.id, + }) + picking_out.with_context(planned_picking=True).action_confirm() + + # Try to RMA item not delivered yet + rma = self.env['rma.rma'].create({ + 'template_id': self.template_return.id, + 'partner_id': self.partner1.id, + 'partner_shipping_id': self.partner1.id, + 'stock_picking_id': picking_out.id, + }) + self.assertEqual(rma.state, 'draft') + wizard = self.env['rma.picking.make.lines'].create({ + 'rma_id': rma.id, + }) + wizard.line_ids.product_uom_qty = 1.0 + wizard.add_lines() + self.assertEqual(len(rma.lines), 1) + + # Make sure that we cannot 'return' if we cannot 'reverse' a stock move + # (this is what `in_require_return` and `out_require_return` do on `rma.template`) + with self.assertRaises(UserError): + rma.action_confirm() + + # Finish our original picking + picking_out.action_assign() + self.assertEqual(picking_out.state, 'assigned') + + # The only lot should be reserved, so we shouldn't get an exception finishing the transfer. + picking_out.move_line_ids.write({ + 'qty_done': 1.0, + }) + picking_out.do_transfer() + self.assertEqual(picking_out.state, 'done') + + # Now we can 'return' that picking + rma.action_confirm() + self.assertEqual(rma.in_picking_id.state, 'assigned') + pack_opt = rma.in_picking_id.move_line_ids[0] + self.assertTrue(pack_opt) + + # We cannot check this directly anymore. Instead just try to return the same lot and make sure you can. + # self.assertEqual(pack_opt.lot_id, lot) + + with self.assertRaises(UserError): + rma.action_done() + + pack_opt.qty_done = 1.0 + with self.assertRaises(UserError): + # require a lot + rma.in_picking_id.do_transfer() + + pack_opt.lot_id = lot + rma.in_picking_id.do_transfer() + rma.action_done() + + # Ensure that the same lot was in fact returned into our destination inventory + quant = self.env['stock.quant'].search([('product_id', '=', self.product1.id), ('location_id', '=', location.id)]) + self.assertEqual(len(quant), 1) + self.assertEqual(quant.lot_id, lot) + + # Make another RMA for the same picking + rma2 = self.env['rma.rma'].create({ + 'template_id': self.template_return.id, + 'partner_id': self.partner1.id, + 'partner_shipping_id': self.partner1.id, + 'stock_picking_id': picking_out.id, + }) + wizard = self.env['rma.picking.make.lines'].create({ + 'rma_id': rma2.id, + }) + wizard.line_ids.product_uom_qty = 1.0 + wizard.add_lines() + self.assertEqual(len(rma2.lines), 1) + + rma2.action_confirm() + + # In Odoo 10, this would not have been able to reserve. + # In Odoo 11, reservation can still happen, but at least we can't move the same lot twice! + # self.assertEqual(rma2.in_picking_id.state, 'confirmed') + + # Requires Lot + with self.assertRaises(UserError): + rma2.in_picking_id.move_line_ids.write({'qty_done': 1.0}) + rma2.in_picking_id.do_transfer() + + # Assign existing lot + rma2.in_picking_id.move_line_ids.write({ + 'lot_id': lot.id + }) + + # Existing lot cannot be re-used. + with self.assertRaises(ValidationError): + rma2.in_picking_id.action_done() + + # RMA cannot be completed because the inbound picking state is confirmed + with self.assertRaises(UserError): + rma2.action_done() diff --git a/rma/views/rma_views.xml b/rma/views/rma_views.xml new file mode 100644 index 00000000..08ff002c --- /dev/null +++ b/rma/views/rma_views.xml @@ -0,0 +1,275 @@ + + + + rma.rma.form + rma.rma + +
+
+
+ +
+ +
+
+

+ +

+
+ + + + + +
+