diff --git a/rma_account/README.rst b/rma_account/README.rst new file mode 100644 index 00000000..c8003352 --- /dev/null +++ b/rma_account/README.rst @@ -0,0 +1,51 @@ +.. image:: https://img.shields.io/badge/licence-LGPL--3-blue.svg + :alt: License LGPL-3 + +=========== +RMA Account +=========== + +This module integrates Return Merchandise Authorizations (RMA) with invoices, +allowing to: + +#. Create complete RMA's using existing invoices as a reference. +#. Create refunds from a RMA. + +Usage +===== + +RMA are accessible though Inventory menu. There's four menus, divided by type. +Users can access to the list of RMA or RMA lines. + +Create an RMA: + +#. Select a partner. Fill the rma lines by selecting an invoice. +#. Request approval and approve. +#. Click on RMA Lines button. +#. Click on more and select an option: "Receive products", "Create Delivery + Order, Create Refund". +#. Go back to the RMA. Set the RMA to done if not further action is required. + +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 smashing it by providing a detailed and welcomed feedback. + +Credits +======= + +Contributors +------------ + +* Jordi Ballester Alomar +* Aaron Henriquez +* Lois Rilo +* Bhavesh Odedra + +Maintainer +---------- + +This module is maintained by Eficent. diff --git a/rma_account/__init__.py b/rma_account/__init__.py new file mode 100644 index 00000000..f3284a96 --- /dev/null +++ b/rma_account/__init__.py @@ -0,0 +1,5 @@ +# © 2017 Eficent Business and IT Consulting Services S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html) + +from . import models +from . import wizards diff --git a/rma_account/__manifest__.py b/rma_account/__manifest__.py new file mode 100644 index 00000000..67ed41f6 --- /dev/null +++ b/rma_account/__manifest__.py @@ -0,0 +1,26 @@ +# © 2017 Eficent Business and IT Consulting Services S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html) + +{ + 'name': 'RMA Account', + 'version': '12.0.1.0.0', + 'license': 'LGPL-3', + 'category': 'RMA', + 'summary': 'Integrates RMA with Invoice Processing', + 'author': "Eficent, Odoo Community Association (OCA)", + 'website': 'http://www.github.com/OCA/rma', + 'depends': ['stock_account', 'rma'], + 'demo': ['data/rma_operation.xml'], + 'data': [ + 'security/ir.model.access.csv', + 'views/rma_order_view.xml', + 'views/rma_operation_view.xml', + 'views/rma_order_line_view.xml', + 'views/invoice_view.xml', + 'views/rma_account_menu.xml', + 'wizards/rma_add_invoice.xml', + 'wizards/rma_refund.xml', + ], + 'installable': True, + 'auto_install': True, +} diff --git a/rma_account/data/rma_operation.xml b/rma_account/data/rma_operation.xml new file mode 100644 index 00000000..f5a84ca0 --- /dev/null +++ b/rma_account/data/rma_operation.xml @@ -0,0 +1,42 @@ + + + + + no + + + + no + + + + Refund after receive + RF-C + received + ordered + no + customer + + + + + + Refund after deliver + RF-S + ordered + no + ordered + supplier + + + + + + no + + + + no + + + diff --git a/rma_account/models/__init__.py b/rma_account/models/__init__.py new file mode 100644 index 00000000..bdd70de6 --- /dev/null +++ b/rma_account/models/__init__.py @@ -0,0 +1,7 @@ +# © 2017 Eficent Business and IT Consulting Services S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html) + +from . import rma_order +from . import rma_order_line +from . import rma_operation +from . import invoice diff --git a/rma_account/models/invoice.py b/rma_account/models/invoice.py new file mode 100644 index 00000000..6a88d0cc --- /dev/null +++ b/rma_account/models/invoice.py @@ -0,0 +1,169 @@ +# © 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.tools.float_utils import float_compare + + +class AccountInvoice(models.Model): + _inherit = "account.invoice" + + @api.depends('invoice_line_ids.rma_line_ids') + def _compute_rma_count(self): + for inv in self: + rmas = self.mapped('invoice_line_ids.rma_line_ids') + inv.rma_count = len(rmas) + + def _prepare_invoice_line_from_rma_line(self, line): + qty = line.qty_to_refund + if float_compare( + qty, 0.0, precision_rounding=line.uom_id.rounding) <= 0: + qty = 0.0 + # Todo fill taxes from somewhere + invoice_line = self.env['account.invoice.line'] + data = { + 'purchase_line_id': line.id, + 'name': line.name + ': '+line.name, + 'origin': line.origin, + 'uom_id': line.uom_id.id, + 'product_id': line.product_id.id, + 'account_id': invoice_line.with_context( + {'journal_id': self.journal_id.id, + 'type': 'in_invoice'})._default_account(), + 'price_unit': line.company_id.currency_id.with_context( + date=self.date_invoice).compute( + line.price_unit, self.currency_id, round=False), + 'quantity': qty, + 'discount': 0.0, + 'rma_line_ids': [(4, line.id)], + } + return data + + @api.onchange('add_rma_line_id') + def on_change_add_rma_line_id(self): + if not self.add_rma_line_id: + return {} + if not self.partner_id: + self.partner_id = self.add_rma_line_id.partner_id.id + + new_line = self.env['account.invoice.line'] + if self.add_rma_line_id not in ( + self.invoice_line_ids.mapped('rma_line_id')): + data = self._prepare_invoice_line_from_rma_line( + self.add_rma_line_id) + new_line = new_line.new(data) + new_line._set_additional_fields(self) + self.invoice_line_ids += new_line + self.add_rma_line_id = False + return {} + + rma_count = fields.Integer( + compute=_compute_rma_count, string='# of RMA') + + add_rma_line_id = fields.Many2one( + comodel_name='rma.order.line', + string="Add from RMA line", + ondelete="set null", + help="Create a refund in based on an existing rma_line") + + @api.multi + def action_view_rma_supplier(self): + action = self.env.ref('rma.action_rma_supplier_lines') + result = action.read()[0] + rma_ids = self.mapped('invoice_line_ids.rma_line_ids').ids + if rma_ids: + # choose the view_mode accordingly + if len(rma_ids) > 1: + result['domain'] = [('id', 'in', rma_ids)] + else: + res = self.env.ref('rma.view_rma_line_supplier_form', False) + result['views'] = [(res and res.id or False, 'form')] + result['res_id'] = rma_ids[0] + return result + + @api.multi + def action_view_rma_customer(self): + action = self.env.ref('rma.action_rma_customer_lines') + result = action.read()[0] + rma_ids = self.mapped('invoice_line_ids.rma_line_ids').ids + if rma_ids: + # choose the view_mode accordingly + if len(rma_ids) > 1: + result['domain'] = [('id', 'in', rma_ids)] + else: + res = self.env.ref('rma.view_rma_line_form', False) + result['views'] = [(res and res.id or False, 'form')] + result['res_id'] = rma_ids[0] + return result + + +class AccountInvoiceLine(models.Model): + _inherit = "account.invoice.line" + + @api.model + def name_search(self, name, args=None, operator='ilike', limit=100): + """Allows to search by Invoice number. This has to be done this way, + as Odoo adds extra args to name_search on _name_search method that + will make impossible to get the desired result.""" + if not args: + args = [] + lines = self.search( + [('invoice_id.number', operator, name)] + args, limit=limit, + ) + res = lines.name_get() + if limit: + limit_rest = limit - len(lines) + else: + # limit can be 0 or None representing infinite + limit_rest = limit + if limit_rest or not limit: + args += [('id', 'not in', lines.ids)] + res += super(AccountInvoiceLine, self).name_search( + name, args=args, operator=operator, limit=limit_rest, + ) + return res + + @api.multi + def name_get(self): + res = [] + if self.env.context.get('rma'): + for inv in self: + if inv.invoice_id.reference: + res.append( + (inv.id, + "INV:%s | REF:%s | ORIG:%s | PART:%s | QTY:%s" % ( + inv.invoice_id.number or '', + inv.origin or '', + inv.invoice_id.reference or "", + inv.product_id.name, inv.quantity))) + elif inv.invoice_id.number: + res.append( + (inv.id, + "INV:%s | ORIG:%s | PART:%s | QTY:%s" % ( + inv.invoice_id.number or '', + inv.origin or '', + inv.product_id.name, inv.quantity))) + else: + res.append(super(AccountInvoiceLine, inv).name_get()[0]) + return res + else: + return super(AccountInvoiceLine, self).name_get() + + @api.multi + def _compute_rma_count(self): + for invl in self: + rma_lines = invl.mapped('rma_line_ids') + invl.rma_line_count = len(rma_lines) + + rma_line_count = fields.Integer( + compute=_compute_rma_count, string='# of RMA') + rma_line_ids = fields.One2many( + comodel_name='rma.order.line', inverse_name='invoice_line_id', + string="RMA", readonly=True, + help="This will contain the RMA lines for the invoice line") + + rma_line_id = fields.Many2one( + comodel_name='rma.order.line', + string="RMA line refund", + ondelete="set null", + help="This will contain the rma line that originated the refund line") diff --git a/rma_account/models/rma_operation.py b/rma_account/models/rma_operation.py new file mode 100644 index 00000000..88085d85 --- /dev/null +++ b/rma_account/models/rma_operation.py @@ -0,0 +1,14 @@ +# © 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 fields, models + + +class RmaOperation(models.Model): + _inherit = 'rma.operation' + + refund_policy = fields.Selection([ + ('no', 'No refund'), ('ordered', 'Based on Ordered Quantities'), + ('delivered', 'Based on Delivered Quantities'), + ('received', 'Based on Received Quantities')], string="Refund Policy", + default='no') diff --git a/rma_account/models/rma_order.py b/rma_account/models/rma_order.py new file mode 100644 index 00000000..20d4f89b --- /dev/null +++ b/rma_account/models/rma_order.py @@ -0,0 +1,123 @@ +# © 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 RmaOrder(models.Model): + _inherit = "rma.order" + + @api.multi + def _compute_invoice_refund_count(self): + for rec in self: + invoices = rec.mapped( + 'rma_line_ids.refund_line_ids.invoice_id') + rec.invoice_refund_count = len(invoices) + + @api.multi + def _compute_invoice_count(self): + for rec in self: + invoices = rec.mapped('rma_line_ids.invoice_id') + rec.invoice_count = len(invoices) + + add_invoice_id = fields.Many2one( + comodel_name='account.invoice', string='Add Invoice', + ondelete='set null', readonly=True, + ) + invoice_refund_count = fields.Integer( + compute=_compute_invoice_refund_count, string='# of Refunds') + invoice_count = fields.Integer( + compute=_compute_invoice_count, string='# of Invoices') + + def _prepare_rma_line_from_inv_line(self, line): + if self.type == 'customer': + operation =\ + self.rma_line_ids.product_id.rma_customer_operation_id or \ + self.rma_line_ids.product_id.categ_id.rma_customer_operation_id + else: + operation =\ + self.rma_line_ids.product_id.rma_supplier_operation_id or \ + self.rma_line_ids.product_id.categ_id.rma_supplier_operation_id + data = { + 'invoice_line_id': line.id, + 'product_id': line.product_id.id, + 'name': line.name, + 'origin': line.invoice_id.number, + 'uom_id': line.uom_id.id, + 'operation_id': operation, + 'product_qty': line.quantity, + 'price_unit': line.invoice_id.currency_id.compute( + line.price_unit, line.currency_id, round=False), + 'rma_id': self.id + } + return data + + @api.onchange('add_invoice_id') + def on_change_invoice(self): + if not self.add_invoice_id: + return {} + if not self.partner_id: + self.partner_id = self.add_invoice_id.partner_id.id + new_lines = self.env['rma.order.line'] + for line in self.add_invoice_id.invoice_line_ids: + # Load a PO line only once + if line in self.rma_line_ids.mapped('invoice_line_id'): + continue + data = self._prepare_rma_line_from_inv_line(line) + new_line = new_lines.new(data) + new_lines += new_line + + self.rma_line_ids += new_lines + self.date_rma = fields.Datetime.now() + self.delivery_address_id = self.add_invoice_id.partner_id.id + self.invoice_address_id = self.add_invoice_id.partner_id.id + self.add_invoice_id = False + return {} + + @api.model + def prepare_rma_line(self, origin_rma, rma_id, line): + line_values = super(RmaOrder, self).prepare_rma_line( + origin_rma, rma_id, line) + line_values['invoice_address_id'] = line.invoice_address_id.id + return line_values + + @api.model + def _prepare_rma_data(self, partner, origin_rma): + res = super(RmaOrder, self)._prepare_rma_data(partner, origin_rma) + res['invoice_address_id'] = partner.id + return res + + @api.multi + def action_view_invoice_refund(self): + action = self.env.ref('account.action_invoice_tree2') + result = action.read()[0] + invoice_ids = self.mapped( + 'rma_line_ids.refund_line_ids.invoice_id').ids + if invoice_ids: + # choose the view_mode accordingly + if len(invoice_ids) > 1: + result['domain'] = [('id', 'in', invoice_ids)] + else: + res = self.env.ref('account.invoice_supplier_form', False) + result['views'] = [(res and res.id or False, 'form')] + result['res_id'] = invoice_ids[0] + return result + + @api.multi + def action_view_invoice(self): + if self.type == "supplier": + action = self.env.ref('account.action_invoice_tree2') + res = self.env.ref('account.invoice_supplier_form', False) + else: + action = self.env.ref('account.action_invoice_tree') + res = self.env.ref('account.invoice_form', False) + result = action.read()[0] + invoice_ids = self.mapped('rma_line_ids.invoice_id').ids + if invoice_ids: + # choose the view_mode accordingly + if len(invoice_ids) > 1: + result['domain'] = [('id', 'in', invoice_ids)] + else: + result['views'] = [(res and res.id or False, 'form')] + result['res_id'] = invoice_ids[0] + return result diff --git a/rma_account/models/rma_order_line.py b/rma_account/models/rma_order_line.py new file mode 100644 index 00000000..95904737 --- /dev/null +++ b/rma_account/models/rma_order_line.py @@ -0,0 +1,235 @@ +# © 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 ValidationError, UserError +from odoo.addons import decimal_precision as dp + + +class RmaOrderLine(models.Model): + _inherit = "rma.order.line" + + @api.model + def _default_invoice_address(self): + partner_id = self.env.context.get('partner_id') + if partner_id: + return self.env['res.partner'].browse(partner_id) + return self.env['res.partner'] + + @api.multi + @api.depends('refund_line_ids', 'refund_line_ids.invoice_id.state', + 'refund_policy', 'type') + def _compute_qty_refunded(self): + for rec in self: + rec.qty_refunded = sum(rec.refund_line_ids.filtered( + lambda i: i.invoice_id.state in ('open', 'paid')).mapped( + 'quantity')) + + @api.depends('refund_line_ids', 'refund_line_ids.invoice_id.state', + 'refund_policy', 'move_ids', 'move_ids.state', 'type') + def _compute_qty_to_refund(self): + qty = 0.0 + for res in self: + if res.refund_policy == 'ordered': + qty = res.product_qty - res.qty_refunded + elif res.refund_policy == 'received': + qty = res.qty_received - res.qty_refunded + res.qty_to_refund = qty + + @api.multi + def _compute_refund_count(self): + for rec in self: + rec.refund_count = len(rec.refund_line_ids.mapped('invoice_id')) + + invoice_address_id = fields.Many2one( + 'res.partner', string='Partner invoice address', + default=_default_invoice_address, + readonly=True, states={'draft': [('readonly', False)]}, + help="Invoice address for current rma order.", + ) + refund_count = fields.Integer( + compute=_compute_refund_count, string='# of Refunds', default=0) + invoice_line_id = fields.Many2one( + comodel_name='account.invoice.line', + string='Originating Invoice Line', + ondelete='restrict', + index=True, + readonly=True, states={'draft': [('readonly', False)]}, + ) + refund_line_ids = fields.One2many( + comodel_name='account.invoice.line', + inverse_name='rma_line_id', string='Refund Lines', + copy=False, index=True, readonly=True, + ) + invoice_id = fields.Many2one('account.invoice', string='Source', + related='invoice_line_id.invoice_id', + index=True, readonly=True) + refund_policy = fields.Selection([ + ('no', 'No refund'), ('ordered', 'Based on Ordered Quantities'), + ('received', 'Based on Received Quantities')], string="Refund Policy", + required=True, default='no', + readonly=True, states={'draft': [('readonly', False)]}, + ) + qty_to_refund = fields.Float( + string='Qty To Refund', copy=False, + digits=dp.get_precision('Product Unit of Measure'), readonly=True, + compute=_compute_qty_to_refund, store=True) + qty_refunded = fields.Float( + string='Qty Refunded', copy=False, + digits=dp.get_precision('Product Unit of Measure'), + readonly=True, compute=_compute_qty_refunded, store=True) + + @api.onchange('product_id') + def _onchange_product_id(self): + res = super(RmaOrderLine, self)._onchange_product_id() + if res.get('domain') and self.product_id: + res['domain']['invoice_line_id'] = [ + ('product_id', '=', self.product_id.id)] + elif res.get('domain') and self.product_id: + res['domain']['invoice_line_id'] = [()] + elif not res.get('domain') and self.product_id: + res['domain'] = { + 'invoice_line_id': [('product_id', '=', self.product_id.id)]} + else: + res['domain'] = {'invoice_line_id': []} + return res + + @api.multi + def _prepare_rma_line_from_inv_line(self, line): + self.ensure_one() + if not self.type: + self.type = self._get_default_type() + if self.type == 'customer': + operation = line.product_id.rma_customer_operation_id or \ + line.product_id.categ_id.rma_customer_operation_id + else: + operation = line.product_id.rma_supplier_operation_id or \ + line.product_id.categ_id.rma_supplier_operation_id + if not operation: + operation = self.env['rma.operation'].search( + [('type', '=', self.type)], limit=1) + if not operation: + raise ValidationError(_("Please define an operation first")) + + if not operation.in_route_id or not operation.out_route_id: + route = self.env['stock.location.route'].search( + [('rma_selectable', '=', True)], limit=1) + if not route: + raise ValidationError(_("Please define an rma route")) + + if not operation.in_warehouse_id or not operation.out_warehouse_id: + warehouse = self.env['stock.warehouse'].search( + [('company_id', '=', self.company_id.id), + ('lot_rma_id', '!=', False)], limit=1) + if not warehouse: + raise ValidationError(_("Please define a warehouse with a" + " default rma location")) + data = { + 'product_id': line.product_id.id, + 'origin': line.invoice_id.number, + 'uom_id': line.uom_id.id, + 'operation_id': operation.id, + 'product_qty': line.quantity, + 'price_unit': line.invoice_id.currency_id.compute( + line.price_unit, line.currency_id, round=False), + 'delivery_address_id': line.invoice_id.partner_id.id, + 'invoice_address_id': line.invoice_id.partner_id.id, + 'receipt_policy': operation.receipt_policy, + 'refund_policy': operation.refund_policy, + 'delivery_policy': operation.delivery_policy, + 'currency_id': line.currency_id.id, + 'in_warehouse_id': operation.in_warehouse_id.id or warehouse.id, + 'out_warehouse_id': operation.out_warehouse_id.id or warehouse.id, + 'in_route_id': operation.in_route_id.id or route.id, + 'out_route_id': operation.out_route_id.id or route.id, + 'location_id': (operation.location_id.id or + operation.in_warehouse_id.lot_rma_id.id or + warehouse.lot_rma_id.id), + } + return data + + @api.onchange('invoice_line_id') + def _onchange_invoice_line_id(self): + if not self.invoice_line_id: + return + data = self._prepare_rma_line_from_inv_line( + self.invoice_line_id) + self.update(data) + self._remove_other_data_origin('invoice_line_id') + + @api.multi + @api.constrains('invoice_line_id', 'partner_id') + def _check_invoice_partner(self): + for rec in self: + if (rec.invoice_line_id and + rec.invoice_line_id.invoice_id.partner_id != + rec.partner_id): + raise ValidationError(_( + "RMA customer and originating invoice line customer " + "doesn't match.")) + + @api.multi + def _remove_other_data_origin(self, exception): + res = super(RmaOrderLine, self)._remove_other_data_origin(exception) + if not exception == 'invoice_line_id': + self.invoice_line_id = False + return res + + @api.onchange('operation_id') + def _onchange_operation_id(self): + result = super(RmaOrderLine, self)._onchange_operation_id() + if self.operation_id: + self.refund_policy = self.operation_id.refund_policy or 'no' + return result + + @api.multi + @api.constrains('invoice_line_id') + def _check_duplicated_lines(self): + for line in self: + matching_inv_lines = self.env['account.invoice.line'].search([( + 'id', '=', line.invoice_line_id.id)]) + if len(matching_inv_lines) > 1: + raise UserError( + _("There's an rma for the invoice line %s " + "and invoice %s" % + (line.invoice_line_id, + line.invoice_line_id.invoice_id))) + return {} + + @api.multi + def action_view_invoice(self): + action = self.env.ref('account.action_invoice_tree') + result = action.read()[0] + res = self.env.ref('account.invoice_form', False) + result['views'] = [(res and res.id or False, 'form')] + result['view_id'] = res and res.id or False + result['res_id'] = self.invoice_line_id.invoice_id.id + return result + + @api.multi + def action_view_refunds(self): + action = self.env.ref('account.action_invoice_tree2') + result = action.read()[0] + invoice_ids = self.mapped('refund_line_ids.invoice_id').ids + if invoice_ids: + # choose the view_mode accordingly + if len(invoice_ids) > 1: + result['domain'] = [('id', 'in', invoice_ids)] + else: + res = self.env.ref('account.invoice_supplier_form', False) + result['views'] = [(res and res.id or False, 'form')] + result['res_id'] = invoice_ids[0] + return result + + @api.multi + def name_get(self): + res = [] + if self.env.context.get('rma'): + for rma in self: + res.append((rma.id, "%s %s qty:%s" % ( + rma.name, + rma.product_id.name, + rma.product_qty))) + return res + else: + return super(RmaOrderLine, self).name_get() diff --git a/rma_account/security/ir.model.access.csv b/rma_account/security/ir.model.access.csv new file mode 100755 index 00000000..b6fa0d93 --- /dev/null +++ b/rma_account/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_account_invoice_customer_user,access_account_invoice,account.model_account_invoice,rma.group_rma_customer_user,1,0,0,0 +access_account_invoice_supplier_user,access_account_invoice,account.model_account_invoice,rma.group_rma_supplier_user,1,0,0,0 diff --git a/rma_account/tests/__init__.py b/rma_account/tests/__init__.py new file mode 100644 index 00000000..77af78d7 --- /dev/null +++ b/rma_account/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2018 Eficent Business and IT Consulting Services S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html) + +from . import test_rma_account diff --git a/rma_account/tests/test_rma_account.py b/rma_account/tests/test_rma_account.py new file mode 100644 index 00000000..2f53a503 --- /dev/null +++ b/rma_account/tests/test_rma_account.py @@ -0,0 +1,211 @@ +# Copyright 2017-18 Eficent Business and IT Consulting Services S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html) + +from odoo.tests import common + + +class TestRmaAccount(common.SingleTransactionCase): + + @classmethod + def setUpClass(cls): + super(TestRmaAccount, cls).setUpClass() + + cls.rma_obj = cls.env['rma.order'] + cls.rma_line_obj = cls.env['rma.order.line'] + cls.rma_op_obj = cls.env['rma.operation'] + cls.rma_add_invoice_wiz = cls.env['rma_add_invoice'] + cls.rma_refund_wiz = cls.env['rma.refund'] + cls.acc_obj = cls.env['account.account'] + cls.inv_obj = cls.env['account.invoice'] + cls.invl_obj = cls.env['account.invoice.line'] + cls.product_obj = cls.env['product.product'] + cls.partner_obj = cls.env['res.partner'] + + cls.rma_route_cust = cls.env.ref('rma.route_rma_customer') + receivable_type = cls.env.ref('account.data_account_type_receivable') + payable_type = cls.env.ref('account.data_account_type_payable') + cls.cust_refund_op = cls.env.ref( + 'rma_account.rma_operation_customer_refund') + + # Create partners + customer1 = cls.partner_obj.create({'name': 'Customer 1'}) + supplier1 = cls.partner_obj.create({'name': 'Supplier 1'}) + + # Create RMA group and operation: + cls.rma_group_customer = cls.rma_obj.create({ + 'partner_id': customer1.id, + 'type': 'customer', + }) + cls.rma_group_supplier = cls.rma_obj.create({ + 'partner_id': supplier1.id, + 'type': 'supplier', + }) + cls.operation_1 = cls.rma_op_obj.create({ + 'code': 'TEST', + 'name': 'Refund and receive', + 'type': 'customer', + 'receipt_policy': 'ordered', + 'refund_policy': 'ordered', + 'in_route_id': cls.rma_route_cust.id, + 'out_route_id': cls.rma_route_cust.id, + }) + + # Create products + cls.product_1 = cls.product_obj.create({ + 'name': 'Test Product 1', + 'type': 'product', + 'list_price': 100.0, + 'rma_customer_operation_id': cls.cust_refund_op.id, + }) + cls.product_2 = cls.product_obj.create({ + 'name': 'Test Product 2', + 'type': 'product', + 'list_price': 150.0, + 'rma_customer_operation_id': cls.operation_1.id, + }) + cls.product_3 = cls.product_obj.create({ + 'name': 'Test Product 3', + 'type': 'product', + }) + cls.product_4 = cls.product_obj.create({ + 'name': 'Test Product 4', + 'type': 'product', + }) + + # Create Invoices: + customer_account = cls.acc_obj. search( + [('user_type_id', '=', receivable_type.id)], limit=1).id + cls.inv_customer = cls.inv_obj.create({ + 'partner_id': customer1.id, + 'account_id': customer_account, + 'type': 'out_invoice', + }) + cls.inv_line_1 = cls.invl_obj.create({ + 'name': cls.product_1.name, + 'product_id': cls.product_1.id, + 'quantity': 12.0, + 'price_unit': 100.0, + 'invoice_id': cls.inv_customer.id, + 'uom_id': cls.product_1.uom_id.id, + 'account_id': customer_account, + }) + cls.inv_line_2 = cls.invl_obj.create({ + 'name': cls.product_2.name, + 'product_id': cls.product_2.id, + 'quantity': 15.0, + 'price_unit': 150.0, + 'invoice_id': cls.inv_customer.id, + 'uom_id': cls.product_2.uom_id.id, + 'account_id': customer_account, + }) + + supplier_account = cls.acc_obj.search( + [('user_type_id', '=', payable_type.id)], limit=1).id + cls.inv_supplier = cls.inv_obj.create({ + 'partner_id': supplier1.id, + 'account_id': supplier_account, + 'type': 'in_invoice', + }) + cls.inv_line_3 = cls.invl_obj.create({ + 'name': cls.product_3.name, + 'product_id': cls.product_3.id, + 'quantity': 17.0, + 'price_unit': 250.0, + 'invoice_id': cls.inv_supplier.id, + 'uom_id': cls.product_3.uom_id.id, + 'account_id': supplier_account, + }) + cls.inv_line_4 = cls.invl_obj.create({ + 'name': cls.product_4.name, + 'product_id': cls.product_4.id, + 'quantity': 9.0, + 'price_unit': 300.0, + 'invoice_id': cls.inv_supplier.id, + 'uom_id': cls.product_4.uom_id.id, + 'account_id': supplier_account, + }) + + def test_01_add_from_invoice_customer(self): + """Test wizard to create RMA from a customer invoice.""" + add_inv = self.rma_add_invoice_wiz.with_context({ + 'customer': True, + 'active_ids': self.rma_group_customer.id, + 'active_model': 'rma.order', + }).create({ + 'invoice_line_ids': + [(6, 0, self.inv_customer.invoice_line_ids.ids)], + }) + add_inv.add_lines() + self.assertEqual(len(self.rma_group_customer.rma_line_ids), 2) + for t in self.rma_group_supplier.rma_line_ids.mapped('type'): + self.assertEqual(t, 'customer') + rma_1 = self.rma_group_customer.rma_line_ids.filtered( + lambda r: r.product_id == self.product_1) + self.assertEqual(rma_1.operation_id, self.cust_refund_op) + rma_2 = self.rma_group_customer.rma_line_ids.filtered( + lambda r: r.product_id == self.product_2) + self.assertEqual(rma_2.operation_id, self.operation_1) + + def test_02_add_from_invoice_supplier(self): + """Test wizard to create RMA from a vendor bill.""" + add_inv = self.rma_add_invoice_wiz.with_context({ + 'supplier': True, + 'active_ids': self.rma_group_supplier.id, + 'active_model': 'rma.order', + }).create({ + 'invoice_line_ids': + [(6, 0, self.inv_supplier.invoice_line_ids.ids)], + }) + add_inv.add_lines() + self.assertEqual(len(self.rma_group_supplier.rma_line_ids), 2) + for t in self.rma_group_supplier.rma_line_ids.mapped('type'): + self.assertEqual(t, 'supplier') + + def test_03_rma_refund_operation(self): + """Test RMA quantities using refund operations.""" + # Received refund_policy: + rma_1 = self.rma_group_customer.rma_line_ids.filtered( + lambda r: r.product_id == self.product_1) + self.assertEqual(rma_1.refund_policy, 'received') + self.assertEqual(rma_1.qty_to_refund, 0.0) + # TODO: receive and check qty_to_refund is 12.0 + # Ordered refund_policy: + rma_2 = self.rma_group_customer.rma_line_ids.filtered( + lambda r: r.product_id == self.product_2) + rma_2._onchange_operation_id() + self.assertEqual(rma_2.refund_policy, 'ordered') + self.assertEqual(rma_2.qty_to_refund, 15.0) + + def test_04_rma_create_refund(self): + """Generate a Refund from a customer RMA.""" + rma = self.rma_group_customer.rma_line_ids.filtered( + lambda r: r.product_id == self.product_2) + rma.action_rma_to_approve() + rma.action_rma_approve() + self.assertEqual(rma.refund_count, 0) + self.assertEqual(rma.qty_to_refund, 15.0) + self.assertEqual(rma.qty_refunded, 0.0) + make_refund = self.rma_refund_wiz.with_context({ + 'customer': True, + 'active_ids': rma.ids, + 'active_model': 'rma.order.line', + }).create({ + 'description': 'Test refund', + }) + make_refund.invoice_refund() + rma.refund_line_ids.invoice_id.invoice_validate() + rma._compute_refund_count() + self.assertEqual(rma.refund_count, 1) + self.assertEqual(rma.qty_to_refund, 0.0) + self.assertEqual(rma.qty_refunded, 15.0) + + def test_05_fill_rma_from_inv_line(self): + """Test filling a RMA (line) from a invoice line.""" + rma = self.rma_line_obj.new({ + 'partner_id': self.inv_customer.partner_id.id, + 'invoice_line_id': self.inv_line_1.id, + }) + self.assertFalse(rma.product_id) + rma._onchange_invoice_line_id() + self.assertEqual(rma.product_id, self.product_1) + self.assertEqual(rma.product_qty, 12.0) diff --git a/rma_account/views/invoice_view.xml b/rma_account/views/invoice_view.xml new file mode 100644 index 00000000..e1f19fdc --- /dev/null +++ b/rma_account/views/invoice_view.xml @@ -0,0 +1,105 @@ + + + + account.invoice.form + account.invoice + + + +
+ +
+
+
+
+ + + account.invoice.supplier.form + account.invoice + + + +
+ +
+
+
+
+ + + rma.invoice.line.form + account.invoice.line + + + + + + + + + + + + + + + + + + + account.invoice.supplier.rma + account.invoice + + + + + + + + + + account.invoice.customer.rma + account.invoice + + + + + + + + + + Invoice Line + account.invoice.line + form + form + + +
diff --git a/rma_account/views/rma_account_menu.xml b/rma_account/views/rma_account_menu.xml new file mode 100644 index 00000000..7388a537 --- /dev/null +++ b/rma_account/views/rma_account_menu.xml @@ -0,0 +1,39 @@ + + + + + + Customer RMA + rma.order.line + [('type','=', 'customer')] + {"search_default_to_refund":1} + form + tree,form + + + + Supplier RMA + rma.order.line + [('type','=', 'supplier')] + {"search_default_to_refund":1, "supplier":1} + form + tree,form + + + + + + + + diff --git a/rma_account/views/rma_operation_view.xml b/rma_account/views/rma_operation_view.xml new file mode 100644 index 00000000..6f9c4062 --- /dev/null +++ b/rma_account/views/rma_operation_view.xml @@ -0,0 +1,24 @@ + + + + rma.operation.tree + rma.operation + + + + + + + + + + rma.operation.form + rma.operation + + + + + + + + diff --git a/rma_account/views/rma_order_line_view.xml b/rma_account/views/rma_order_line_view.xml new file mode 100644 index 00000000..09aaf60f --- /dev/null +++ b/rma_account/views/rma_order_line_view.xml @@ -0,0 +1,100 @@ + + + + rma.order.line.supplier.form + rma.order.line + + + + + + + + + + + + + + + + + + + + + + + + + + + + + rma.order.line.form + rma.order.line + + + + + + + + + + + + + + + + + + + + + + + + + + + + + rma.order.line.select + rma.order.line + + + + + + + + diff --git a/rma_account/views/rma_order_view.xml b/rma_account/views/rma_order_view.xml new file mode 100644 index 00000000..270b1058 --- /dev/null +++ b/rma_account/views/rma_order_view.xml @@ -0,0 +1,64 @@ + + + + rma.order.form - rma_account + rma.order + + + + + + + + + + + rma.order.supplier.form + rma.order + + + + + + + + + diff --git a/rma_account/wizards/__init__.py b/rma_account/wizards/__init__.py new file mode 100644 index 00000000..e3832b10 --- /dev/null +++ b/rma_account/wizards/__init__.py @@ -0,0 +1,6 @@ +# © 2017 Eficent Business and IT Consulting Services S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html) + +from . import rma_refund +from . import rma_add_invoice +from . import rma_order_line_make_supplier_rma diff --git a/rma_account/wizards/rma_add_invoice.py b/rma_account/wizards/rma_add_invoice.py new file mode 100644 index 00000000..8c44205d --- /dev/null +++ b/rma_account/wizards/rma_add_invoice.py @@ -0,0 +1,114 @@ +# © 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 ValidationError + + +class RmaAddInvoice(models.TransientModel): + _name = 'rma_add_invoice' + _description = 'Wizard to add rma lines' + + @api.model + def default_get(self, fields): + res = super(RmaAddInvoice, self).default_get(fields) + rma_obj = self.env['rma.order'] + rma_id = self.env.context['active_ids'] or [] + active_model = self.env.context['active_model'] + if not rma_id: + return res + assert active_model == 'rma.order', 'Bad context propagation' + rma = rma_obj.browse(rma_id) + res['rma_id'] = rma.id + res['partner_id'] = rma.partner_id.id + res['invoice_line_ids'] = False + return res + + rma_id = fields.Many2one('rma.order', string='RMA Order', readonly=True, + ondelete='cascade') + partner_id = fields.Many2one(comodel_name='res.partner', string='Partner', + readonly=True) + invoice_line_ids = fields.Many2many('account.invoice.line', + 'rma_add_invoice_add_line_rel', + 'invoice_line_id', + 'rma_add_invoice_id', + string='Invoice Lines') + + def _prepare_rma_line_from_inv_line(self, line): + if self.env.context.get('customer'): + operation = line.product_id.rma_customer_operation_id or \ + line.product_id.categ_id.rma_customer_operation_id + else: + operation = line.product_id.rma_supplier_operation_id or \ + line.product_id.categ_id.rma_supplier_operation_id + if not operation: + operation = self.env['rma.operation'].search( + [('type', '=', self.rma_id.type)], limit=1) + if not operation: + raise ValidationError(_("Please define an operation first")) + if not operation.in_route_id or not operation.out_route_id: + route = self.env['stock.location.route'].search( + [('rma_selectable', '=', True)], limit=1) + if not route: + raise ValidationError(_("Please define an rma route")) + + if not operation.in_warehouse_id or not operation.out_warehouse_id: + warehouse = self.env['stock.warehouse'].search( + [('company_id', '=', self.rma_id.company_id.id), + ('lot_rma_id', '!=', False)], limit=1) + if not warehouse: + raise ValidationError(_("Please define a warehouse with a" + " default rma location")) + data = { + 'partner_id': self.partner_id.id, + 'invoice_line_id': line.id, + 'product_id': line.product_id.id, + 'origin': line.invoice_id.number, + 'uom_id': line.uom_id.id, + 'operation_id': operation.id, + 'product_qty': line.quantity, + 'price_unit': line.invoice_id.currency_id.compute( + line.price_unit, line.currency_id, round=False), + 'delivery_address_id': line.invoice_id.partner_id.id, + 'invoice_address_id': line.invoice_id.partner_id.id, + 'rma_id': self.rma_id.id, + 'receipt_policy': operation.receipt_policy, + 'refund_policy': operation.refund_policy, + 'delivery_policy': operation.delivery_policy, + 'in_warehouse_id': operation.in_warehouse_id.id or warehouse.id, + 'out_warehouse_id': operation.out_warehouse_id.id or warehouse.id, + 'in_route_id': operation.in_route_id.id or route.id, + 'out_route_id': operation.out_route_id.id or route.id, + 'location_id': (operation.location_id.id or + operation.in_warehouse_id.lot_rma_id.id or + warehouse.lot_rma_id.id), + } + return data + + @api.model + def _get_rma_data(self): + data = { + 'date_rma': fields.Datetime.now(), + } + return data + + @api.model + def _get_existing_invoice_lines(self): + existing_invoice_lines = [] + for rma_line in self.rma_id.rma_line_ids: + existing_invoice_lines.append(rma_line.invoice_line_id) + return existing_invoice_lines + + @api.multi + def add_lines(self): + rma_line_obj = self.env['rma.order.line'] + existing_invoice_lines = self._get_existing_invoice_lines() + for line in self.invoice_line_ids: + # Load a PO line only once + if line not in existing_invoice_lines: + data = self._prepare_rma_line_from_inv_line(line) + rma_line_obj.create(data) + rma = self.rma_id + data_rma = self._get_rma_data() + rma.write(data_rma) + return {'type': 'ir.actions.act_window_close'} diff --git a/rma_account/wizards/rma_add_invoice.xml b/rma_account/wizards/rma_add_invoice.xml new file mode 100644 index 00000000..0ecb13ab --- /dev/null +++ b/rma_account/wizards/rma_add_invoice.xml @@ -0,0 +1,133 @@ + + + + + + rma.add.invoice + rma_add_invoice + +
+ + + + + + + + + + + + + + + + + +
+
+ +
+
+ + + rma.add.invoice.supplier + rma_add_invoice + +
+ + + + + + + + + + + + + + + + + +
+
+ +
+
+ + + Add Invoice + ir.actions.act_window + rma_add_invoice + rma.order + form + form + new + + + + + + Add Invoice + ir.actions.act_window + rma_add_invoice + rma.order + form + form + new + + + + + + rma.order.form - invoice wizard + rma.order + + + +