diff --git a/rma_sale/__init__.py b/rma_sale/__init__.py
new file mode 100644
index 00000000..9b429614
--- /dev/null
+++ b/rma_sale/__init__.py
@@ -0,0 +1,2 @@
+from . import models
+from . import wizard
diff --git a/rma_sale/__manifest__.py b/rma_sale/__manifest__.py
new file mode 100644
index 00000000..d2a4e3ec
--- /dev/null
+++ b/rma_sale/__manifest__.py
@@ -0,0 +1,24 @@
+# © 2018 Hibou Corp.
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+{
+ 'name': 'Hibou RMAs for Sale Orders',
+ 'version': '11.0.1.0.0',
+ 'category': 'Sale',
+ 'author': "Hibou Corp.",
+ 'license': 'AGPL-3',
+ 'website': 'https://hibou.io/',
+ 'depends': [
+ 'rma',
+ 'sale',
+ 'sales_team',
+ ],
+ 'data': [
+ 'security/ir.model.access.csv',
+ 'data/rma_demo.xml',
+ 'views/rma_views.xml',
+ 'wizard/rma_lines_views.xml',
+ ],
+ 'installable': True,
+ 'application': False,
+ }
diff --git a/rma_sale/data/rma_demo.xml b/rma_sale/data/rma_demo.xml
new file mode 100644
index 00000000..f326f1a4
--- /dev/null
+++ b/rma_sale/data/rma_demo.xml
@@ -0,0 +1,15 @@
+
+
+
+ Sale Return
+ sale_order
+
+
+
+
+
+ make_to_stock
+
+
+
+
\ No newline at end of file
diff --git a/rma_sale/models/__init__.py b/rma_sale/models/__init__.py
new file mode 100644
index 00000000..0031f431
--- /dev/null
+++ b/rma_sale/models/__init__.py
@@ -0,0 +1 @@
+from . import rma
diff --git a/rma_sale/models/rma.py b/rma_sale/models/rma.py
new file mode 100644
index 00000000..6b69da38
--- /dev/null
+++ b/rma_sale/models/rma.py
@@ -0,0 +1,118 @@
+from odoo import api, fields, models, _
+from odoo.exceptions import UserError
+
+
+class RMATemplate(models.Model):
+ _inherit = 'rma.template'
+
+ usage = fields.Selection(selection_add=[('sale_order', 'Sale Order')])
+
+
+class RMA(models.Model):
+ _inherit = 'rma.rma'
+
+ sale_order_id = fields.Many2one('sale.order', string='Sale Order')
+ sale_order_rma_count = fields.Integer('Number of RMAs for this Sale Order', compute='_compute_sale_order_rma_count')
+ company_id = fields.Many2one('res.company', 'Company',
+ default=lambda self: self.env['res.company']._company_default_get('sale.order'))
+
+ @api.multi
+ @api.depends('sale_order_id')
+ def _compute_sale_order_rma_count(self):
+ for rma in self:
+ if rma.sale_order_id:
+ rma_data = self.read_group([('sale_order_id', '=', rma.sale_order_id.id), ('state', '!=', 'cancel')],
+ ['sale_order_id'], ['sale_order_id'])
+ if rma_data:
+ rma.sale_order_rma_count = rma_data[0]['sale_order_id_count']
+ else:
+ rma.sale_order_rma_count = 0.0
+
+
+ @api.multi
+ def open_sale_order_rmas(self):
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Sale Order RMAs'),
+ 'res_model': 'rma.rma',
+ 'view_mode': 'tree,form',
+ 'context': {'search_default_sale_order_id': self[0].sale_order_id.id}
+ }
+
+ @api.onchange('template_usage')
+ @api.multi
+ def _onchange_template_usage(self):
+ res = super(RMA, self)._onchange_template_usage()
+ for rma in self.filtered(lambda rma: rma.template_usage != 'sale_order'):
+ rma.sale_order_id = False
+ return res
+
+ @api.onchange('sale_order_id')
+ @api.multi
+ def _onchange_sale_order_id(self):
+ for rma in self.filtered(lambda rma: rma.sale_order_id):
+ rma.partner_id = rma.sale_order_id.partner_id
+ rma.partner_shipping_id = rma.sale_order_id.partner_shipping_id
+
+
+ @api.multi
+ def action_add_so_lines(self):
+ make_line_obj = self.env['rma.sale.make.lines']
+ for rma in self:
+ lines = make_line_obj.create({
+ 'rma_id': rma.id,
+ })
+ action = self.env.ref('rma_sale.action_rma_add_lines').read()[0]
+ action['res_id'] = lines.id
+ return action
+
+ def _create_in_picking_sale_order(self):
+ if not self.sale_order_id:
+ raise UserError(_('You must have a sale order for this RMA.'))
+ if not self.template_id.in_require_return:
+ group_id = self.sale_order_id.procurement_group_id.id if self.sale_order_id.procurement_group_id else 0
+ sale_id = self.sale_order_id
+ values = self.template_id._values_for_in_picking(self)
+ update = {'sale_id': sale_id, 'group_id': group_id}
+ update_lines = {'group_id': group_id}
+ return self._picking_from_values(values, update, update_lines)
+
+ lines = self.lines.filtered(lambda l: l.product_uom_qty >= 1)
+ if not lines:
+ raise UserError(_('You have no lines with positive quantity.'))
+ product_ids = lines.mapped('product_id.id')
+
+ old_picking = self._find_candidate_return_picking(product_ids, self.sale_order_id.picking_ids, self.template_id.in_location_id.id)
+ if not old_picking:
+ raise UserError('No eligible pickings were found to return (you can only return products from the same initial picking).')
+
+ new_picking = self._new_in_picking(old_picking)
+ self._new_in_moves(old_picking, new_picking, {})
+ return new_picking
+
+ def _create_out_picking_sale_order(self):
+ if not self.sale_order_id:
+ raise UserError(_('You must have a sale order for this RMA.'))
+ if not self.template_id.out_require_return:
+ group_id = self.sale_order_id.procurement_group_id.id if self.sale_order_id.procurement_group_id else 0
+ sale_id = self.sale_order_id
+ values = self.template_id._values_for_out_picking(self)
+ update = {'sale_id': sale_id, 'group_id': group_id}
+ update_lines = {'to_refund_so': self.template_id.in_to_refund_so, 'group_id': group_id}
+ return self._picking_from_values(values, update, update_lines)
+
+ lines = self.lines.filtered(lambda l: l.product_uom_qty >= 1)
+ if not lines:
+ raise UserError(_('You have no lines with positive quantity.'))
+ product_ids = lines.mapped('product_id.id')
+
+ old_picking = self._find_candidate_return_picking(product_ids, self.sale_order_id.picking_ids, self.template_id.out_location_dest_id.id)
+ if not old_picking:
+ raise UserError(
+ 'No eligible pickings were found to duplicate (you can only return products from the same initial picking).')
+
+ new_picking = self._new_out_picking(old_picking)
+ self._new_out_moves(old_picking, new_picking, {})
+ return new_picking
+
+
diff --git a/rma_sale/security/ir.model.access.csv b/rma_sale/security/ir.model.access.csv
new file mode 100644
index 00000000..ccb878eb
--- /dev/null
+++ b/rma_sale/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 sale","manage rma","rma.model_rma_rma","sales_team.group_sale_salesman",1,1,1,1
+"manage_rma_line sale","manage rma line","rma.model_rma_line","sales_team.group_sale_salesman",1,1,1,1
+"manage_rma_template sale","manage rma template","rma.model_rma_template","sales_team.group_sale_manager",1,1,1,1
+"manage_rma_tag sale","manage rma tag","rma.model_rma_tag","sales_team.group_sale_manager",1,1,1,1
+"access_rma_template sale","access rma template","rma.model_rma_template","sales_team.group_sale_salesman",1,1,0,0
+"access_rma_tag sale","access rma tag","rma.model_rma_tag","sales_team.group_sale_salesman",1,0,0,0
\ No newline at end of file
diff --git a/rma_sale/tests/__init__.py b/rma_sale/tests/__init__.py
new file mode 100644
index 00000000..c95f5fde
--- /dev/null
+++ b/rma_sale/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_rma
diff --git a/rma_sale/tests/test_rma.py b/rma_sale/tests/test_rma.py
new file mode 100644
index 00000000..9cb31d0a
--- /dev/null
+++ b/rma_sale/tests/test_rma.py
@@ -0,0 +1,113 @@
+from odoo.addons.rma.tests.test_rma import TestRMA
+from odoo.exceptions import UserError, ValidationError
+
+
+class TestRMASale(TestRMA):
+
+ def setUp(self):
+ super(TestRMASale, self).setUp()
+ self.template_sale_return = self.env.ref('rma_sale.template_sale_return')
+
+ def test_20_sale_return(self):
+ self.product1.tracking = 'serial'
+ order = self.env['sale.order'].create({
+ 'partner_id': self.partner1.id,
+ 'partner_invoice_id': self.partner1.id,
+ 'partner_shipping_id': self.partner1.id,
+ 'order_line': [(0, 0, {
+ 'product_id': self.product1.id,
+ 'product_uom_qty': 1.0,
+ 'product_uom': self.product1.uom_id.id,
+ 'price_unit': 10.0,
+ })]
+ })
+ order.action_confirm()
+ self.assertTrue(order.state in ('sale', 'done'))
+ self.assertEqual(len(order.picking_ids), 1, 'Tests only run with single stage delivery.')
+
+ # Try to RMA item not delivered yet
+ rma = self.env['rma.rma'].create({
+ 'template_id': self.template_sale_return.id,
+ 'partner_id': self.partner1.id,
+ 'partner_shipping_id': self.partner1.id,
+ 'sale_order_id': order.id,
+ })
+ self.assertEqual(rma.state, 'draft')
+ wizard = self.env['rma.sale.make.lines'].create({
+ 'rma_id': rma.id,
+ })
+ self.assertEqual(wizard.line_ids.qty_delivered, 0.0)
+ wizard.line_ids.product_uom_qty = 1.0
+ wizard.add_lines()
+ self.assertEqual(len(rma.lines), 1)
+ with self.assertRaises(UserError):
+ rma.action_confirm()
+
+ order.picking_ids.force_assign()
+ pack_opt = order.picking_ids.move_line_ids[0]
+ lot = self.env['stock.production.lot'].create({
+ 'product_id': self.product1.id,
+ 'name': 'X100',
+ 'product_uom_id': self.product1.uom_id.id,
+ })
+ pack_opt.qty_done = 1.0
+ pack_opt.lot_id = lot
+ order.picking_ids.do_transfer()
+ self.assertEqual(order.picking_ids.state, 'done')
+ wizard = self.env['rma.sale.make.lines'].create({
+ 'rma_id': rma.id,
+ })
+ self.assertEqual(wizard.line_ids.qty_delivered, 1.0)
+
+ # Confirm RMA
+ rma.action_confirm()
+ self.assertEqual(rma.in_picking_id.state, 'assigned')
+ pack_opt = rma.in_picking_id.move_line_ids[0]
+
+ with self.assertRaises(UserError):
+ rma.action_done()
+
+ pack_opt.lot_id = lot
+ pack_opt.qty_done = 1.0
+ rma.in_picking_id.do_transfer()
+ rma.action_done()
+
+ # Make another RMA for the same sale order
+ rma2 = self.env['rma.rma'].create({
+ 'template_id': self.template_sale_return.id,
+ 'partner_id': self.partner1.id,
+ 'partner_shipping_id': self.partner1.id,
+ 'sale_order_id': order.id,
+ })
+ wizard = self.env['rma.sale.make.lines'].create({
+ 'rma_id': rma2.id,
+ })
+ # The First completed RMA will have "un-delivered" it for invoicing purposes.
+ self.assertEqual(wizard.line_ids.qty_delivered, 0.0)
+ 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_sale/views/rma_views.xml b/rma_sale/views/rma_views.xml
new file mode 100644
index 00000000..d3570806
--- /dev/null
+++ b/rma_sale/views/rma_views.xml
@@ -0,0 +1,65 @@
+
+
+
+
+
+ rma.rma.form.sale
+ rma.rma
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ rma.rma.tree.sale
+ rma.rma
+
+
+
+
+
+
+
+
+
+ rma.rma.tree.sale
+ rma.rma
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/rma_sale/wizard/__init__.py b/rma_sale/wizard/__init__.py
new file mode 100644
index 00000000..450b15cd
--- /dev/null
+++ b/rma_sale/wizard/__init__.py
@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+from . import rma_lines
diff --git a/rma_sale/wizard/rma_lines.py b/rma_sale/wizard/rma_lines.py
new file mode 100644
index 00000000..e09c33be
--- /dev/null
+++ b/rma_sale/wizard/rma_lines.py
@@ -0,0 +1,61 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models
+
+
+class RMASaleMakeLines(models.TransientModel):
+ _name = 'rma.sale.make.lines'
+ _description = 'Add SO Lines'
+
+ rma_id = fields.Many2one('rma.rma', string='RMA')
+ line_ids = fields.One2many('rma.sale.make.lines.line', 'rma_make_lines_id', string='Lines')
+
+
+ @api.model
+ def create(self, vals):
+ maker = super(RMASaleMakeLines, self).create(vals)
+ maker._create_lines()
+ return maker
+
+ def _line_values(self, so_line):
+ return {
+ 'rma_make_lines_id': self.id,
+ 'product_id': so_line.product_id.id,
+ 'qty_ordered': so_line.product_uom_qty,
+ 'qty_delivered': so_line.qty_delivered,
+ 'qty_invoiced': so_line.qty_invoiced,
+ 'product_uom_qty': 0.0,
+ 'product_uom_id': so_line.product_uom.id,
+ }
+
+ def _create_lines(self):
+ make_lines_obj = self.env['rma.sale.make.lines.line']
+
+ if self.rma_id.template_usage == 'sale_order' and self.rma_id.sale_order_id:
+ for l in self.rma_id.sale_order_id.order_line:
+ self.line_ids |= make_lines_obj.create(self._line_values(l))
+
+ @api.multi
+ def add_lines(self):
+ rma_line_obj = self.env['rma.line']
+ for o in self:
+ lines = o.line_ids.filtered(lambda l: l.product_uom_qty > 0.0)
+ for l in lines:
+ rma_line_obj.create({
+ 'rma_id': o.rma_id.id,
+ 'product_id': l.product_id.id,
+ 'product_uom_id': l.product_uom_id.id,
+ 'product_uom_qty': l.product_uom_qty,
+ })
+
+
+class RMASOMakeLinesLine(models.TransientModel):
+ _name = 'rma.sale.make.lines.line'
+
+ rma_make_lines_id = fields.Many2one('rma.sale.make.lines')
+ product_id = fields.Many2one('product.product', string="Product")
+ qty_ordered = fields.Float(string='Ordered')
+ qty_invoiced = fields.Float(string='Invoiced')
+ qty_delivered = fields.Float(string='Delivered')
+ product_uom_qty = fields.Float(string='QTY')
+ product_uom_id = fields.Many2one('product.uom', 'UOM')
diff --git a/rma_sale/wizard/rma_lines_views.xml b/rma_sale/wizard/rma_lines_views.xml
new file mode 100644
index 00000000..e6486124
--- /dev/null
+++ b/rma_sale/wizard/rma_lines_views.xml
@@ -0,0 +1,39 @@
+
+
+
+ view.rma.add.lines.form
+ rma.sale.make.lines
+ form
+
+
+
+
+
+ Add RMA Lines
+ rma.sale.make.lines
+ form
+ form
+
+ new
+
+
\ No newline at end of file