[ADD] rma: new module

[UPD] Update rma.pot

Update translation files

Updated by "Update PO files to match POT (msgmerge)" hook in Weblate.

Translation: rma-12.0/rma-12.0-rma
Translate-URL: https://translation.odoo-community.org/projects/rma-12-0/rma-12-0-rma/
This commit is contained in:
Ernesto Tejeda
2020-05-13 00:27:46 -04:00
committed by Chafique
parent 441a703391
commit eb0f8c23ee
48 changed files with 7643 additions and 0 deletions

152
rma/README.rst Normal file
View File

@@ -0,0 +1,152 @@
===========================================
Return Merchandise Authorization Management
===========================================
.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Frma-lightgray.png?logo=github
:target: https://github.com/OCA/rma/tree/12.0/rma
:alt: OCA/rma
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/rma-12-0/rma-12-0-rma
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png
:target: https://runbot.odoo-community.org/runbot/145/12.0
:alt: Try me on Runbot
|badge1| |badge2| |badge3| |badge4| |badge5|
This module allows you to manage `Return Merchandise Authorization (RMA)
<https://en.wikipedia.org/wiki/Return_merchandise_authorization>`_.
RMA documents can be created from scratch, from a delivery order or from
an incoming email. Product receptions and returning delivery operations
of the RMA module are fully integrated with the Receipts and Deliveries
Operations of Odoo inventory core module. It also allows you to generate
refunds in the same way as Odoo generates it.
Besides, you have full integration of the RMA documents in the customer portal.
**Table of contents**
.. contents::
:local:
Configuration
=============
If you want RMAs to be created from incoming emails, you need to:
#. Go to *Settings > General Settings*.
#. Check 'External Email Servers' checkbox under *Discuss* section.
#. Set an 'alias domain' and an incoming server.
#. Go to *RMA > Configuration > RMA Team* and select a team or create a new
one.
#. Go to 'Email' tab and set an 'Email Alias'.
Usage
=====
To use this module, you need to:
#. Go to *RMA > Orders* and create a new RMA.
#. Select a partner, an invoice address, select a product
(or select a picking and a move instead), write a quantity, fill the rest
of the form and click on 'confirm' button in the status bar.
#. You will see an smart button labeled 'Receipt'. Click on that button to see
the reception operation form.
#. If everything is right, validate the operation and go back to the RMA to
see it in a 'received' state.
#. Now you are able to generate a refund, generate a delivery order to return
to the customer the same product or another product as a replacement, split
the RMA by extracting a part of the remaining quantity to another RMA,
preview the RMA in the website. All of these operations can be done by
clicking on the buttons in the status bar.
* If you click on 'Refund' button, a refund will be created, and it will be
accessible via the smart button labeled Refund. The RMA will be set
automatically to 'Refunded' state when the refund is validated.
* If you click on 'Replace' or 'Return to customer' button instead,
a popup wizard will guide you to create a Delivery order to the client
and this order will be accessible via the smart button labeled Delivery.
The RMA will be set automatically to 'Replaced' or 'Returned' state when
the RMA quantity is equal or lower than the quantity in done delivery
orders linked to it.
An RMA can also be created from a return of a delivery order:
#. Select a delivery order and click on 'Return' button to create a return.
#. Check "Create RMAs" checkbox in the returning wizard, select the RMA
stock location and click on 'Return' button.
#. An RMA will be created for each product returned in the previous step.
Every RMA will be in confirmed state and they will
be linked to the returning operation generated previously.
**Note: An RMA can also be created from an incoming email (See configuration
section).**
Known issues / Roadmap
======================
* As soon as the picking is selected, the user should select the move,
but perhaps stock.move _rec_name could be improved to better show what
the product of that move is.
Bug Tracker
===========
Bugs are tracked on `GitHub Issues <https://github.com/OCA/rma/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 <https://github.com/OCA/rma/issues/new?body=module:%20rma%0Aversion:%2012.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
~~~~~~~
* Tecnativa
Contributors
~~~~~~~~~~~~
* `Tecnativa <https://www.tecnativa.com>`_:
* Ernesto Tejeda
* Pedro M. Baeza
* David Vidal
Maintainers
~~~~~~~~~~~
This module is maintained by the OCA.
.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org
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.
.. |maintainer-ernestotejeda| image:: https://github.com/ernestotejeda.png?size=40px
:target: https://github.com/ernestotejeda
:alt: ernestotejeda
Current `maintainer <https://odoo-community.org/page/maintainer-role>`__:
|maintainer-ernestotejeda|
This module is part of the `OCA/rma <https://github.com/OCA/rma/tree/12.0/rma>`_ project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

6
rma/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from . import controllers
from . import models
from . import wizard
from .hooks import post_init_hook

37
rma/__manifest__.py Normal file
View File

@@ -0,0 +1,37 @@
# Copyright 2020 Tecnativa - Ernesto Tejeda
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "Return Merchandise Authorization Management",
"summary": "Return Merchandise Authorization (RMA)",
"version": "12.0.1.0.0",
"development_status": "Beta",
"category": "RMA",
"website": "https://github.com/OCA/rma",
"author": "Tecnativa, Odoo Community Association (OCA)",
"maintainers": ["ernestotejeda"],
"license": "AGPL-3",
"depends": [
"account",
"stock",
],
"data": [
"views/report_rma.xml",
"report/report.xml",
"data/mail_data.xml",
"data/rma_operation_data.xml",
"security/rma_security.xml",
"security/ir.model.access.csv",
"wizard/stock_picking_return_views.xml",
"wizard/rma_delivery_views.xml",
"wizard/rma_split_views.xml",
"views/menus.xml",
"views/res_partner_views.xml",
"views/rma_portal_templates.xml",
"views/rma_team_views.xml",
"views/rma_views.xml",
"views/stock_picking_views.xml",
"views/stock_warehouse_views.xml",
],
'post_init_hook': 'post_init_hook',
"application": True,
}

View File

@@ -0,0 +1,3 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from . import main

132
rma/controllers/main.py Normal file
View File

@@ -0,0 +1,132 @@
# Copyright 2020 Tecnativa - Ernesto Tejeda
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import _, exceptions, http
from odoo.exceptions import AccessError, MissingError
from odoo.http import request
from odoo.addons.portal.controllers.portal import CustomerPortal,\
pager as portal_pager
from odoo.tools import consteq
class PortalRma(CustomerPortal):
def _prepare_portal_layout_values(self):
values = super()._prepare_portal_layout_values()
values['rma_count'] = request.env['rma'].search_count([])
return values
def _rma_get_page_view_values(self, rma, access_token, **kwargs):
values = {
'page_name': 'RMA',
'rma': rma,
}
return self._get_page_view_values(
rma, access_token, values, 'my_rmas_history', False, **kwargs)
def _get_filter_domain(self, kw):
return []
@http.route(['/my/rmas', '/my/rmas/page/<int:page>'],
type='http', auth="user", website=True)
def portal_my_rmas(self, page=1, date_begin=None, date_end=None,
sortby=None, **kw):
values = self._prepare_portal_layout_values()
rma_obj = request.env['rma']
domain = self._get_filter_domain(kw)
searchbar_sortings = {
'date': {'label': _('Date'), 'order': 'date desc'},
'name': {'label': _('Name'), 'order': 'name desc'},
'state': {'label': _('Status'), 'order': 'state'},
}
# default sort by order
if not sortby:
sortby = 'date'
order = searchbar_sortings[sortby]['order']
archive_groups = self._get_archive_groups('rma', domain)
if date_begin and date_end:
domain += [
('create_date', '>', date_begin),
('create_date', '<=', date_end),
]
# count for pager
rma_count = rma_obj.search_count(domain)
# pager
pager = portal_pager(
url="/my/rmas",
url_args={
'date_begin': date_begin,
'date_end': date_end,
'sortby': sortby,
},
total=rma_count,
page=page,
step=self._items_per_page
)
# content according to pager and archive selected
rmas = rma_obj.search(
domain,
order=order,
limit=self._items_per_page,
offset=pager['offset']
)
request.session['my_rmas_history'] = rmas.ids[:100]
values.update({
'date': date_begin,
'rmas': rmas,
'page_name': 'RMA',
'pager': pager,
'archive_groups': archive_groups,
'default_url': '/my/rmas',
'searchbar_sortings': searchbar_sortings,
'sortby': sortby,
})
return request.render("rma.portal_my_rmas", values)
@http.route(['/my/rmas/<int:rma_id>'],
type='http', auth="public", website=True)
def portal_my_rma_detail(self, rma_id, access_token=None,
report_type=None, download=False, **kw):
try:
rma_sudo = self._document_check_access('rma', rma_id, access_token)
except (AccessError, MissingError):
return request.redirect('/my')
if report_type in ('html', 'pdf', 'text'):
return self._show_report(
model=rma_sudo,
report_type=report_type,
report_ref='rma.report_rma_action',
download=download,
)
values = self._rma_get_page_view_values(rma_sudo, access_token, **kw)
return request.render("rma.portal_rma_page", values)
@http.route(['/my/rma/picking/pdf/<int:picking_id>'],
type='http', auth="public", website=True)
def portal_my_rma_picking_report(self, picking_id, access_token=None,
**kw):
try:
picking_sudo = self._stock_picking_check_access(
picking_id, access_token=access_token)
except exceptions.AccessError:
return request.redirect('/my')
report_sudo = request.env.ref('stock.action_report_delivery').sudo()
pdf = report_sudo.render_qweb_pdf([picking_sudo.id])[0]
pdfhttpheaders = [
('Content-Type', 'application/pdf'),
('Content-Length', len(pdf)),
]
return request.make_response(pdf, headers=pdfhttpheaders)
def _stock_picking_check_access(self, picking_id, access_token=None):
picking = request.env['stock.picking'].browse([picking_id])
picking_sudo = picking.sudo()
try:
picking.check_access_rights('read')
picking.check_access_rule('read')
except exceptions.AccessError:
if not access_token or not consteq(
picking_sudo.sale_id.access_token, access_token):
raise
return picking_sudo

46
rma/data/mail_data.xml Normal file
View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<data noupdate="1">
<!-- rma-related subtypes for messaging / Chatter -->
<record id="mt_rma_draft" model="mail.message.subtype">
<field name="name">Draft RMA</field>
<field name="res_model">rma</field>
<field name="default" eval="False"/>
<field name="description">New RMA in draft state</field>
</record>
<!-- rma_team-related subtypes for messaging / Chatter -->
<record id="mt_rma_team_rma_draft" model="mail.message.subtype">
<field name="name">Draft RMA</field>
<field name="sequence">10</field>
<field name="res_model">rma.team</field>
<field name="default" eval="True"/>
<field name="parent_id" eval="ref('rma.mt_rma_draft')"/>
<field name="relation_field">team_id</field>
</record>
<!--RMA email template -->
<record id="mail_template_rma_notification" model="mail.template">
<field name="name">RMA Notification</field>
<field name="model_id" ref="model_rma"/>
<field name="email_from">${object.user_id.email_formatted |safe}</field>
<field name="partner_to">${object.partner_id.id}</field>
<field name="subject">${object.company_id.name} RMA (Ref ${object.name or 'n/a' })</field>
<field name="report_template" ref="report_rma_action" />
<field name="report_name">${(object.name or '')}</field>
<field name="lang">${object.partner_id.lang}</field>
<field name="user_signature" eval="True"/>
<field name="auto_delete" eval="True"/>
<field name="body_html" type="html">
<div style="margin: 0px; padding: 0px;">
<p style="margin: 0px; padding: 0px; font-size: 13px;">
Dear ${object.partner_id.name}
% if object.partner_id.parent_id:
(${object.partner_id.parent_id.name})
% endif
<br /><br />
Here is the RMA <strong>${object.name}</strong> from ${object.company_id.name}.
<br /><br />
Do not hesitate to contact us if you have any question.
</p>
</div>
</field>
</record>
</data>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<data noupdate="1">
<record id="rma_operation_replace" model="rma.operation">
<field name="name">Replace</field>
</record>
<record id="rma_operation_return" model="rma.operation">
<field name="name">Repair</field>
</record>
<record id="rma_operation_refund" model="rma.operation">
<field name="name">Refund</field>
</record>
</data>

70
rma/hooks.py Normal file
View File

@@ -0,0 +1,70 @@
# Copyright 2020 Tecnativa - Ernesto Tejeda
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import api, SUPERUSER_ID
def post_init_hook(cr, registry):
env = api.Environment(cr, SUPERUSER_ID, {})
def _get_next_picking_type_color():
""" Choose the next available color for the operation types."""
stock_picking_type = env['stock.picking.type']
picking_type = stock_picking_type.search_read(
[('warehouse_id', '!=', False), ('color', '!=', False)],
['color'],
order='color',
)
all_used_colors = [res['color'] for res in picking_type]
available_colors = [color for color in range(0, 12)
if color not in all_used_colors]
return available_colors[0] if available_colors else 0
def create_rma_locations(warehouse):
stock_location = env['stock.location']
location_vals = warehouse._get_locations_values({})
for field_name, values in location_vals.items():
if field_name == 'rma_loc_id' and not warehouse.rma_loc_id:
warehouse.rma_loc_id = stock_location.with_context(
active_test=False).create(values).id
def create_rma_picking_types(whs):
ir_sequence_sudo = env['ir.sequence'].sudo()
stock_picking_type = env['stock.picking.type']
color = _get_next_picking_type_color()
stock_picking = stock_picking_type.search(
[('sequence', '!=', False)], limit=1, order='sequence desc')
max_sequence = stock_picking.sequence or 0
create_data = whs._get_picking_type_create_values(max_sequence)[0]
sequence_data = whs._get_sequence_values()
data = {}
for picking_type, values in create_data.items():
if (picking_type in ['rma_in_type_id', 'rma_out_type_id']
and not whs[picking_type]):
picking_sequence = sequence_data[picking_type]
sequence = ir_sequence_sudo.create(picking_sequence)
values.update(
warehouse_id=whs.id,
color=color,
sequence_id=sequence.id,
)
data[picking_type] = stock_picking_type.create(values).id
rma_out_type = stock_picking_type.browse(data['rma_out_type_id'])
rma_out_type.write({
'return_picking_type_id': data.get('rma_in_type_id', False)
})
rma_in_type = stock_picking_type.browse(data['rma_in_type_id'])
rma_in_type.write({
'return_picking_type_id': data.get('rma_out_type_id', False)
})
whs.write(data)
# Create rma locations and picking types
warehouses = env['stock.warehouse'].search([])
for warehouse in warehouses:
create_rma_locations(warehouse)
create_rma_picking_types(warehouse)
# Create rma sequence per company
for company in env['res.company'].search([]):
company.create_rma_index()

1635
rma/i18n/es.po Normal file

File diff suppressed because it is too large Load Diff

1485
rma/i18n/rma.pot Normal file

File diff suppressed because it is too large Load Diff

12
rma/models/__init__.py Normal file
View File

@@ -0,0 +1,12 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from . import account_invoice
from . import rma
from . import rma_operation
from . import rma_team
from . import res_company
from . import res_partner
from . import res_users
from . import stock_move
from . import stock_picking
from . import stock_warehouse

View File

@@ -0,0 +1,35 @@
# Copyright 2020 Tecnativa - Ernesto Tejeda
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import _, fields, models
from odoo.exceptions import ValidationError
from odoo.tools import float_compare
class AccountInvoice(models.Model):
_inherit = 'account.invoice'
def action_invoice_open(self):
""" Avoids to validate a refund with less quantity of product than
quantity in the linked RMA.
"""
precision = self.env['decimal.precision'].precision_get(
'Product Unit of Measure')
if self.mapped('invoice_line_ids').filtered(
lambda r: (r.rma_id and float_compare(
r.quantity, r.rma_id.product_uom_qty, precision) < 0)):
raise ValidationError(
_("There is at least one invoice lines whose quantity is "
"less than the quantity specified in its linked RMA."))
res = super().action_invoice_open()
self.mapped('invoice_line_ids.rma_id').write({'state': 'refunded'})
return res
class AccountInvoiceLine(models.Model):
_inherit = 'account.invoice.line'
rma_id = fields.Many2one(
comodel_name='rma',
string='RMA',
)

23
rma/models/res_company.py Normal file
View File

@@ -0,0 +1,23 @@
# Copyright 2020 Tecnativa - Ernesto Tejeda
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import api, models, _
class Company(models.Model):
_inherit = "res.company"
@api.model
def create(self, vals):
company = super(Company, self).create(vals)
company.create_rma_index()
return company
def create_rma_index(self):
self.env['ir.sequence'].sudo().create({
'name': _('RMA Code'),
'prefix': 'RMA',
'code': 'rma',
'padding': 4,
'company_id': self.id,
})

41
rma/models/res_partner.py Normal file
View File

@@ -0,0 +1,41 @@
# Copyright 2020 Tecnativa - Ernesto Tejeda
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import fields, models
class ResPartner(models.Model):
_inherit = 'res.partner'
rma_ids = fields.One2many(
comodel_name='rma',
inverse_name='partner_id',
string='RMAs',
)
rma_count = fields.Integer(
string='RMA count',
compute='_compute_rma_count',
)
def _compute_rma_count(self):
rma_data = self.env['rma'].read_group(
[('partner_id', 'in', self.ids)], ['partner_id'], ['partner_id'])
mapped_data = dict(
[(r['partner_id'][0], r['partner_id_count']) for r in rma_data])
for record in self:
record.rma_count = mapped_data.get(record.id, 0)
def action_view_rma(self):
self.ensure_one()
action = self.env.ref('rma.rma_action').read()[0]
rma = self.rma_ids
if len(rma) == 1:
action.update(
res_id=rma.id,
view_mode="form",
view_id=False,
views=False,
)
else:
action['domain'] = [('partner_id', 'in', self.ids)]
return action

14
rma/models/res_users.py Normal file
View File

@@ -0,0 +1,14 @@
# Copyright 2020 Tecnativa - Ernesto Tejeda
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import fields, models
class ResUsers(models.Model):
_inherit = 'res.users'
rma_team_id = fields.Many2one(
comodel_name='rma.team',
string="RMA Team",
help='RMA Team the user is member of.',
)

1144
rma/models/rma.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
# Copyright 2020 Tecnativa - Ernesto Tejeda
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import fields, models
class RmaOperation(models.Model):
_name = "rma.operation"
_description = "RMA requested operation"
name = fields.Char(required=True, translate=True)
_sql_constraints = [
('name_uniq', 'unique (name)', "That operation name already exists !"),
]

56
rma/models/rma_team.py Normal file
View File

@@ -0,0 +1,56 @@
# Copyright 2020 Tecnativa - Ernesto Tejeda
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import _, fields, models
class RmaTeam(models.Model):
_name = "rma.team"
_inherit = ['mail.alias.mixin', 'mail.thread']
_description = "RMA Team"
_order = "name"
name = fields.Char(
required=True,
translate=True,
)
active = fields.Boolean(
default=True,
help="If the active field is set to false, it will allow you "
"to hide the RMA Team without removing it.",
)
company_id = fields.Many2one(
comodel_name="res.company",
string="Company",
default=lambda self: self.env.user.company_id,
)
user_id = fields.Many2one(
comodel_name="res.users",
string="Team Leader",
domain=[('share', '=', False)],
default=lambda self: self.env.user,
)
member_ids = fields.One2many(
comodel_name='res.users',
inverse_name='rma_team_id',
string='Team Members',
)
def copy(self, default=None):
if default is None:
default = {}
if not default.get('name'):
default['name'] = _("%s (copy)") % self.name
team = super().copy(default)
for follower in self.message_follower_ids:
team.message_subscribe(partner_ids=follower.partner_id.ids,
subtype_ids=follower.subtype_ids.ids)
return team
def get_alias_model_name(self, vals):
return vals.get('alias_model', 'rma')
def get_alias_values(self):
values = super().get_alias_values()
values['alias_defaults'] = {'team_id': self.id}
return values

96
rma/models/stock_move.py Normal file
View File

@@ -0,0 +1,96 @@
# Copyright 2020 Tecnativa - Ernesto Tejeda
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class StockMove(models.Model):
_inherit = "stock.move"
# RMAs that were created from the delivery move
rma_ids = fields.One2many(
comodel_name='rma',
inverse_name='move_id',
string='RMAs',
copy=False,
)
# RMAs linked to the incoming movement from client
rma_receiver_ids = fields.One2many(
comodel_name='rma',
inverse_name='reception_move_id',
string='RMA receivers',
copy=False,
)
# RMA that create the delivery movement to the customer
rma_id = fields.Many2one(
comodel_name='rma',
string='RMA return',
copy=False,
)
def unlink(self):
rma_receiver = self.mapped('rma_receiver_ids')
rma = self.mapped('rma_id')
res = super().unlink()
rma_receiver.write({'state': 'draft'})
rma.update_received_state()
rma.update_replaced_state()
return res
def _action_cancel(self):
res = super()._action_cancel()
cancelled_moves = self.filtered(lambda r: r.state == 'cancel')
cancelled_moves.mapped('rma_receiver_ids').write({'state': 'draft'})
cancelled_moves.mapped('rma_id').update_received_state()
cancelled_moves.mapped('rma_id').update_replaced_state()
return res
def _action_done(self):
""" Avoids to validate stock.move with less quantity than the
quantity in the linked receiver RMA. It also set the appropriated
linked RMA to 'received' or 'delivered'.
"""
for move in self.filtered(
lambda r: r.state not in ('done', 'cancel')):
rma_receiver = move.rma_receiver_ids
if (rma_receiver
and move.quantity_done != rma_receiver.product_uom_qty):
raise ValidationError(
_("The quantity done for the product '%s' must "
"be equal to its initial demand because the "
"stock move is linked to an RMA (%s).")
% (move.product_id.name, move.rma_receiver_ids.name)
)
res = super()._action_done()
move_done = self.filtered(lambda r: r.state == 'done')
# set RMAs as received
to_be_received = move_done.mapped('rma_receiver_ids').filtered(
lambda r: r.state == 'confirmed')
to_be_received.write({'state': 'received'})
# set RMAs as delivered
move_done.mapped('rma_id').update_replaced_state()
move_done.mapped('rma_id').update_returned_state()
return res
@api.model
def _prepare_merge_moves_distinct_fields(self):
""" The main use is that launched delivery RMAs doesn't merge
two moves if they are linked to a different RMAs.
"""
return super()._prepare_merge_moves_distinct_fields() + ['rma_id']
def _prepare_move_split_vals(self, qty):
""" Intended to the backport of picking linked to RMAs propagates the
RMA link id.
"""
res = super()._prepare_move_split_vals(qty)
res['rma_id'] = self.rma_id.id
return res
class StockRule(models.Model):
_inherit = 'stock.rule'
def _get_custom_move_fields(self):
return super()._get_custom_move_fields() + ['rma_id']

View File

@@ -0,0 +1,41 @@
# Copyright 2020 Tecnativa - Ernesto Tejeda
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import fields, models
class StockPicking(models.Model):
_inherit = 'stock.picking'
rma_count = fields.Integer(
string='RMA count',
compute='_compute_rma_count',
)
def _compute_rma_count(self):
for rec in self:
rec.rma_count = len(rec.move_lines.mapped('rma_ids'))
def copy(self, default=None):
if self.env.context.get('set_rma_picking_type'):
location_dest_id = default['location_dest_id']
warehouse = self.env['stock.warehouse'].search(
[('rma_loc_id', 'parent_of', location_dest_id)], limit=1)
if warehouse:
default['picking_type_id'] = warehouse.rma_in_type_id.id
return super().copy(default)
def action_view_rma(self):
self.ensure_one()
action = self.env.ref('rma.rma_action').read()[0]
rma = self.move_lines.mapped('rma_ids')
if len(rma) == 1:
action.update(
res_id=rma.id,
view_mode="form",
view_id=False,
views=False,
)
else:
action['domain'] = [('id', 'in', rma.ids)]
return action

View File

@@ -0,0 +1,118 @@
# Copyright 2020 Tecnativa - Ernesto Tejeda
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import fields, models, _
class StockWarehouse(models.Model):
_inherit = 'stock.warehouse'
# This is a strategic field used to create an rma location
# and rma operation types in existing warehouses when
# installing this module.
rma = fields.Boolean(
'RMA',
default=True,
help="RMA related products can be stored in this warehouse.")
rma_in_type_id = fields.Many2one(
comodel_name='stock.picking.type',
string='RMA In Type',
)
rma_out_type_id = fields.Many2one(
comodel_name='stock.picking.type',
string='RMA Out Type',
)
rma_loc_id = fields.Many2one(
comodel_name='stock.location',
string='RMA Location',
)
def _get_locations_values(self, vals):
values = super()._get_locations_values(vals)
values.update({
'rma_loc_id': {
'name': 'RMA',
'active': True,
'return_location': True,
'usage': 'internal',
'company_id': vals.get('company_id', self.company_id.id),
'location_id': self.view_location_id.id,
},
})
return values
def _get_sequence_values(self):
values = super()._get_sequence_values()
values.update({
'rma_in_type_id': {
'name': self.name + ' ' + _('Sequence RMA in'),
'prefix': self.code + '/RMA/IN/', 'padding': 5,
'company_id': self.company_id.id,
},
'rma_out_type_id': {
'name': self.name + ' ' + _('Sequence RMA out'),
'prefix': self.code + '/RMA/OUT/', 'padding': 5,
'company_id': self.company_id.id,
},
})
return values
def _update_name_and_code(self, new_name=False, new_code=False):
for warehouse in self:
sequence_data = warehouse._get_sequence_values()
warehouse.rma_in_type_id.sequence_id.write(
sequence_data['rma_in_type_id'])
warehouse.rma_in_type_id.sequence_id.write(
sequence_data['rma_out_type_id'])
def _get_picking_type_create_values(self, max_sequence):
data, next_sequence = super()._get_picking_type_create_values(
max_sequence)
data.update({
'rma_in_type_id': {
'name': _('RMA Receipts'),
'code': 'incoming',
'use_create_lots': True,
'use_existing_lots': False,
'default_location_src_id': False,
'default_location_dest_id': self.rma_loc_id.id,
'sequence': max_sequence + 1,
},
'rma_out_type_id': {
'name': _('RMA Delivery Orders'),
'code': 'outgoing',
'use_create_lots': False,
'use_existing_lots': True,
'default_location_src_id': self.rma_loc_id.id,
'default_location_dest_id': False,
'sequence': max_sequence + 2,
},
})
return data, max_sequence + 3
def _get_picking_type_update_values(self):
data = super()._get_picking_type_update_values()
data.update({
'rma_in_type_id': {
'default_location_dest_id': self.rma_loc_id.id,
},
'rma_out_type_id': {
'default_location_src_id': self.rma_loc_id.id,
},
})
return data
def _create_or_update_sequences_and_picking_types(self):
data = super()._create_or_update_sequences_and_picking_types()
stock_picking_type = self.env['stock.picking.type']
if 'out_type_id' in data:
rma_out_type = stock_picking_type.browse(data['rma_out_type_id'])
rma_out_type.write({
'return_picking_type_id': data.get('rma_in_type_id', False)
})
if 'rma_in_type_id' in data:
rma_in_type = stock_picking_type.browse(data['rma_in_type_id'])
rma_in_type.write({
'return_picking_type_id': data.get('rma_out_type_id', False)
})
return data

8
rma/readme/CONFIGURE.rst Normal file
View File

@@ -0,0 +1,8 @@
If you want RMAs to be created from incoming emails, you need to:
#. Go to *Settings > General Settings*.
#. Check 'External Email Servers' checkbox under *Discuss* section.
#. Set an 'alias domain' and an incoming server.
#. Go to *RMA > Configuration > RMA Team* and select a team or create a new
one.
#. Go to 'Email' tab and set an 'Email Alias'.

View File

@@ -0,0 +1,5 @@
* `Tecnativa <https://www.tecnativa.com>`_:
* Ernesto Tejeda
* Pedro M. Baeza
* David Vidal

View File

@@ -0,0 +1,8 @@
This module allows you to manage `Return Merchandise Authorization (RMA)
<https://en.wikipedia.org/wiki/Return_merchandise_authorization>`_.
RMA documents can be created from scratch, from a delivery order or from
an incoming email. Product receptions and returning delivery operations
of the RMA module are fully integrated with the Receipts and Deliveries
Operations of Odoo inventory core module. It also allows you to generate
refunds in the same way as Odoo generates it.
Besides, you have full integration of the RMA documents in the customer portal.

3
rma/readme/ROADMAP.rst Normal file
View File

@@ -0,0 +1,3 @@
* As soon as the picking is selected, the user should select the move,
but perhaps stock.move _rec_name could be improved to better show what
the product of that move is.

37
rma/readme/USAGE.rst Normal file
View File

@@ -0,0 +1,37 @@
To use this module, you need to:
#. Go to *RMA > Orders* and create a new RMA.
#. Select a partner, an invoice address, select a product
(or select a picking and a move instead), write a quantity, fill the rest
of the form and click on 'confirm' button in the status bar.
#. You will see an smart button labeled 'Receipt'. Click on that button to see
the reception operation form.
#. If everything is right, validate the operation and go back to the RMA to
see it in a 'received' state.
#. Now you are able to generate a refund, generate a delivery order to return
to the customer the same product or another product as a replacement, split
the RMA by extracting a part of the remaining quantity to another RMA,
preview the RMA in the website. All of these operations can be done by
clicking on the buttons in the status bar.
* If you click on 'Refund' button, a refund will be created, and it will be
accessible via the smart button labeled Refund. The RMA will be set
automatically to 'Refunded' state when the refund is validated.
* If you click on 'Replace' or 'Return to customer' button instead,
a popup wizard will guide you to create a Delivery order to the client
and this order will be accessible via the smart button labeled Delivery.
The RMA will be set automatically to 'Replaced' or 'Returned' state when
the RMA quantity is equal or lower than the quantity in done delivery
orders linked to it.
An RMA can also be created from a return of a delivery order:
#. Select a delivery order and click on 'Return' button to create a return.
#. Check "Create RMAs" checkbox in the returning wizard, select the RMA
stock location and click on 'Return' button.
#. An RMA will be created for each product returned in the previous step.
Every RMA will be in confirmed state and they will
be linked to the returning operation generated previously.
**Note: An RMA can also be created from an incoming email (See configuration
section).**

14
rma/report/report.xml Normal file
View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<data>
<report
id="report_rma_action"
string="RMA Report"
model="rma"
report_type="qweb-pdf"
file="rma.report_rma"
name="rma.report_rma"
print_report_name="object._get_report_base_filename()"
/>
</data>
</odoo>

View File

@@ -0,0 +1,8 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_rma_team_user_own,rma.team.user.own,model_rma_team,rma_group_user_own,1,0,0,0
access_rma_team_manager,rma.team.manager,model_rma_team,rma_group_manager,1,1,1,1
access_rma_portal,rma.portal,model_rma,base.group_portal,1,0,0,0
access_rma_user_own,rma.user.own,model_rma,rma_group_user_own,1,1,1,0
access_rma_manager,rma.manager,model_rma,rma_group_manager,1,1,1,1
access_rma_operation_user_own,rma.operation.user.own,model_rma_operation,rma_group_user_own,1,0,0,0
access_rma_operation_manager,rma.operation.manager,model_rma_operation,rma_group_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_rma_team_user_own rma.team.user.own model_rma_team rma_group_user_own 1 0 0 0
3 access_rma_team_manager rma.team.manager model_rma_team rma_group_manager 1 1 1 1
4 access_rma_portal rma.portal model_rma base.group_portal 1 0 0 0
5 access_rma_user_own rma.user.own model_rma rma_group_user_own 1 1 1 0
6 access_rma_manager rma.manager model_rma rma_group_manager 1 1 1 1
7 access_rma_operation_user_own rma.operation.user.own model_rma_operation rma_group_user_own 1 0 0 0
8 access_rma_operation_manager rma.operation.manager model_rma_operation rma_group_manager 1 1 1 1

View File

@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2020 Tecnativa - Ernesto Tejeda
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<odoo>
<!-- Application -->
<record id="rma_module_category" model="ir.module.category">
<field name="name">RMA</field>
<field name="description">Manage Return Merchandise Authorizations (RMAs).</field>
</record>
<!-- Access Groups -->
<record id="rma_group_user_own" model="res.groups">
<field name="name">User: Own Documents Only</field>
<field name="category_id" ref="rma_module_category"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
<field name="comment">the user will have access to his own data in the RMA application.</field>
</record>
<record id="rma_group_user_all" model="res.groups">
<field name="name">User: All Documents</field>
<field name="category_id" ref="rma_module_category"/>
<field name="implied_ids" eval="[(4, ref('rma_group_user_own'))]"/>
<field name="comment">the user will have access to all records of everyone in the RMA application.</field>
</record>
<record id="rma_group_manager" model="res.groups">
<field name="name">Manager</field>
<field name="comment">the user will have an access to the RMA configuration as well as statistic reports.</field>
<field name="category_id" ref="rma_module_category"/>
<field name="implied_ids" eval="[(4, ref('rma_group_user_all'))]"/>
<field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
</record>
<!-- Record Rules -->
<record id="rma_rule_user_own" model="ir.rule">
<field name="name">Personal RMAs</field>
<field ref="model_rma" name="model_id"/>
<field name="domain_force">['|',('user_id','=',user.id),('user_id','=',False)]</field>
<field name="groups" eval="[(4, ref('rma_group_user_own'))]"/>
</record>
<record id="rma_rule_user_all" model="ir.rule">
<field name="name">All RMAs</field>
<field ref="model_rma" name="model_id"/>
<field name="domain_force">[(1,'=',1)]</field>
<field name="groups" eval="[(4, ref('rma_group_user_all'))]"/>
</record>
<!-- Multi-Company Rules -->
<record id="rma_rule_multi_company" model="ir.rule">
<field name="name">RMA multi-company</field>
<field name="model_id" ref="model_rma"/>
<field name="global" eval="True"/>
<field name="domain_force">['|',('company_id','=',False),('company_id','child_of',[user.company_id.id])]</field>
</record>
<record id="rma_team_rule_multi_company" model="ir.rule">
<field name="name">RMA team multi-company</field>
<field name="model_id" ref="model_rma_team"/>
<field name="global" eval="True"/>
<field name="domain_force">['|',('company_id','=',False),('company_id','child_of',[user.company_id.id])]</field>
</record>
<!-- New users will belong to rma_group_user_own -->
<record id="base.default_user" model="res.users">
<field name="groups_id" eval="[(4, ref('rma_group_user_own'))]"/>
</record>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@@ -0,0 +1,496 @@
<?xml version="1.0" encoding="utf-8" ?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils 0.15.1: http://docutils.sourceforge.net/" />
<title>Return Merchandise Authorization Management</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
:Id: $Id: html4css1.css 7952 2016-07-26 18:15:59Z milde $
:Copyright: This stylesheet has been placed in the public domain.
Default cascading style sheet for the HTML output of Docutils.
See http://docutils.sf.net/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
pre.code .ln { color: grey; } /* line numbers */
pre.code, code { background-color: #eeeeee }
pre.code .comment, code .comment { color: #5C6576 }
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document" id="return-merchandise-authorization-management">
<h1 class="title">Return Merchandise Authorization Management</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external" href="https://github.com/OCA/rma/tree/12.0/rma"><img alt="OCA/rma" src="https://img.shields.io/badge/github-OCA%2Frma-lightgray.png?logo=github" /></a> <a class="reference external" href="https://translation.odoo-community.org/projects/rma-12-0/rma-12-0-rma"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external" href="https://runbot.odoo-community.org/runbot/145/12.0"><img alt="Try me on Runbot" src="https://img.shields.io/badge/runbot-Try%20me-875A7B.png" /></a></p>
<p>This module allows you to manage <a class="reference external" href="https://en.wikipedia.org/wiki/Return_merchandise_authorization">Return Merchandise Authorization (RMA)</a>.
RMA documents can be created from scratch, from a delivery order or from
an incoming email. Product receptions and returning delivery operations
of the RMA module are fully integrated with the Receipts and Deliveries
Operations of Odoo inventory core module. It also allows you to generate
refunds in the same way as Odoo generates it.
Besides, you have full integration of the RMA documents in the customer portal.</p>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#configuration" id="id1">Configuration</a></li>
<li><a class="reference internal" href="#usage" id="id2">Usage</a></li>
<li><a class="reference internal" href="#known-issues-roadmap" id="id3">Known issues / Roadmap</a></li>
<li><a class="reference internal" href="#bug-tracker" id="id4">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="id5">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="id6">Authors</a></li>
<li><a class="reference internal" href="#contributors" id="id7">Contributors</a></li>
<li><a class="reference internal" href="#maintainers" id="id8">Maintainers</a></li>
</ul>
</li>
</ul>
</div>
<div class="section" id="configuration">
<h1><a class="toc-backref" href="#id1">Configuration</a></h1>
<p>If you want RMAs to be created from incoming emails, you need to:</p>
<ol class="arabic simple">
<li>Go to <em>Settings &gt; General Settings</em>.</li>
<li>Check External Email Servers checkbox under <em>Discuss</em> section.</li>
<li>Set an alias domain and an incoming server.</li>
<li>Go to <em>RMA &gt; Configuration &gt; RMA Team</em> and select a team or create a new
one.</li>
<li>Go to Email tab and set an Email Alias.</li>
</ol>
</div>
<div class="section" id="usage">
<h1><a class="toc-backref" href="#id2">Usage</a></h1>
<p>To use this module, you need to:</p>
<ol class="arabic simple">
<li>Go to <em>RMA &gt; Orders</em> and create a new RMA.</li>
<li>Select a partner, an invoice address, select a product
(or select a picking and a move instead), write a quantity, fill the rest
of the form and click on confirm button in the status bar.</li>
<li>You will see an smart button labeled Receipt. Click on that button to see
the reception operation form.</li>
<li>If everything is right, validate the operation and go back to the RMA to
see it in a received state.</li>
<li>Now you are able to generate a refund, generate a delivery order to return
to the customer the same product or another product as a replacement, split
the RMA by extracting a part of the remaining quantity to another RMA,
preview the RMA in the website. All of these operations can be done by
clicking on the buttons in the status bar.<ul>
<li>If you click on Refund button, a refund will be created, and it will be
accessible via the smart button labeled Refund. The RMA will be set
automatically to Refunded state when the refund is validated.</li>
<li>If you click on Replace or Return to customer button instead,
a popup wizard will guide you to create a Delivery order to the client
and this order will be accessible via the smart button labeled Delivery.
The RMA will be set automatically to Replaced or Returned state when
the RMA quantity is equal or lower than the quantity in done delivery
orders linked to it.</li>
</ul>
</li>
</ol>
<p>An RMA can also be created from a return of a delivery order:</p>
<ol class="arabic simple">
<li>Select a delivery order and click on Return button to create a return.</li>
<li>Check “Create RMAs” checkbox in the returning wizard, select the RMA
stock location and click on Return button.</li>
<li>An RMA will be created for each product returned in the previous step.
Every RMA will be in confirmed state and they will
be linked to the returning operation generated previously.</li>
</ol>
<p><strong>Note: An RMA can also be created from an incoming email (See configuration
section).</strong></p>
</div>
<div class="section" id="known-issues-roadmap">
<h1><a class="toc-backref" href="#id3">Known issues / Roadmap</a></h1>
<ul class="simple">
<li>As soon as the picking is selected, the user should select the move,
but perhaps stock.move _rec_name could be improved to better show what
the product of that move is.</li>
</ul>
</div>
<div class="section" id="bug-tracker">
<h1><a class="toc-backref" href="#id4">Bug Tracker</a></h1>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/rma/issues">GitHub Issues</a>.
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
<a class="reference external" href="https://github.com/OCA/rma/issues/new?body=module:%20rma%0Aversion:%2012.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h1><a class="toc-backref" href="#id5">Credits</a></h1>
<div class="section" id="authors">
<h2><a class="toc-backref" href="#id6">Authors</a></h2>
<ul class="simple">
<li>Tecnativa</li>
</ul>
</div>
<div class="section" id="contributors">
<h2><a class="toc-backref" href="#id7">Contributors</a></h2>
<ul class="simple">
<li><a class="reference external" href="https://www.tecnativa.com">Tecnativa</a>:<ul>
<li>Ernesto Tejeda</li>
<li>Pedro M. Baeza</li>
<li>David Vidal</li>
</ul>
</li>
</ul>
</div>
<div class="section" id="maintainers">
<h2><a class="toc-backref" href="#id8">Maintainers</a></h2>
<p>This module is maintained by the OCA.</p>
<a class="reference external image-reference" href="https://odoo-community.org"><img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" /></a>
<p>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.</p>
<p>Current <a class="reference external" href="https://odoo-community.org/page/maintainer-role">maintainer</a>:</p>
<p><a class="reference external" href="https://github.com/ernestotejeda"><img alt="ernestotejeda" src="https://github.com/ernestotejeda.png?size=40px" /></a></p>
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/rma/tree/12.0/rma">OCA/rma</a> project on GitHub.</p>
<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
</div>
</div>
</div>
</body>
</html>

3
rma/tests/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from . import test_rma

638
rma/tests/test_rma.py Normal file
View File

@@ -0,0 +1,638 @@
# Copyright 2020 Tecnativa - Ernesto Tejeda
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo.tests import Form, SavepointCase
from odoo.exceptions import UserError, ValidationError
class TestRma(SavepointCase):
@classmethod
def setUpClass(cls):
super(TestRma, cls).setUpClass()
cls.res_partner = cls.env['res.partner']
cls.product_product = cls.env['product.product']
cls.company = cls.env.user.company_id
cls.warehouse_company = cls.env['stock.warehouse'].search(
[('company_id', '=', cls.company.id)], limit=1)
cls.rma_loc = cls.warehouse_company.rma_loc_id
account_pay = cls.env['account.account'].create({
'code': 'X1111',
'name': 'Creditors - (test)',
'user_type_id': cls.env.ref(
'account.data_account_type_payable').id,
'reconcile': True,
})
cls.journal = cls.env['account.journal'].create({
'name': 'sale_0',
'code': 'SALE0',
'type': 'sale',
'default_debit_account_id': account_pay.id,
})
cls.product = cls.product_product.create({
'name': 'Product test 1',
'type': 'product',
})
account_type = cls.env['account.account.type'].create({
'name': 'RCV type',
'type': 'receivable',
})
cls.account_receiv = cls.env['account.account'].create({
'name': 'Receivable',
'code': 'RCV00',
'user_type_id': account_type.id,
'reconcile': True,
})
cls.partner = cls.res_partner.create({
'name': 'Partner test',
'property_account_receivable_id': cls.account_receiv.id,
})
cls.partner_invoice = cls.res_partner.create({
'name': 'Partner invoice test',
'parent_id': cls.partner.id,
'type': 'invoice',
})
def _create_rma(self, partner=None, product=None, qty=None, location=None):
rma_form = Form(self.env['rma'])
if partner:
rma_form.partner_id = partner
if product:
rma_form.product_id = product
if qty:
rma_form.product_uom_qty = qty
if location:
rma_form.location_id = location
return rma_form.save()
def _create_confirm_receive(self, partner=None, product=None, qty=None,
location=None):
rma = self._create_rma(partner, product, qty, location)
rma.action_confirm()
rma.reception_move_id.quantity_done = rma.product_uom_qty
rma.reception_move_id.picking_id.action_done()
return rma
def _test_readonly_fields(self, rma):
with Form(rma) as rma_form:
with self.assertRaises(AssertionError):
rma_form.partner_id = self.env['res.partner']
with self.assertRaises(AssertionError):
rma_form.partner_invoice_id = self.env['res.partner']
with self.assertRaises(AssertionError):
rma_form.picking_id = self.env['stock.picking']
with self.assertRaises(AssertionError):
rma_form.move_id = self.env['stock.move']
with self.assertRaises(AssertionError):
rma_form.product_id = self.env['product.product']
with self.assertRaises(AssertionError):
rma_form.product_uom_qty = 0
with self.assertRaises(AssertionError):
rma_form.product_uom = self.env['uom.uom']
with self.assertRaises(AssertionError):
rma_form.location_id = self.env['stock.location']
def _create_delivery(self):
picking_type = self.env['stock.picking.type'].search(
[
('code', '=', 'outgoing'),
'|',
('warehouse_id.company_id', '=', self.company.id),
('warehouse_id', '=', False)
],
limit=1,
)
picking_form = Form(
recordp=self.env['stock.picking'].with_context(
default_picking_type_id=picking_type.id),
view="stock.view_picking_form",
)
picking_form.company_id = self.company
picking_form.partner_id = self.partner
with picking_form.move_ids_without_package.new() as move:
move.product_id = self.product
move.product_uom_qty = 10
with picking_form.move_ids_without_package.new() as move:
move.product_id = self.product_product.create(
{'name': 'Product 2 test', 'type': 'product'})
move.product_uom_qty = 20
picking = picking_form.save()
picking.action_confirm()
for move in picking.move_lines:
move.quantity_done = move.product_uom_qty
picking.button_validate()
return picking
def test_onchange(self):
rma_form = Form(self.env['rma'])
# If partner changes, the invoice address is set
rma_form.partner_id = self.partner
self.assertEqual(rma_form.partner_invoice_id, self.partner_invoice)
# If origin move changes, the product is set
uom_ten = self.env['uom.uom'].create({
'name': "Ten",
'category_id': self.env.ref('uom.product_uom_unit').id,
'factor_inv': 10,
'uom_type': 'bigger',
})
product_2 = self.product_product.create({
'name': 'Product test 2',
'type': 'product',
'uom_id': uom_ten.id,
})
outgoing_picking_type = self.env['stock.picking.type'].search(
[
('code', '=', 'outgoing'),
'|',
('warehouse_id.company_id', '=', self.company.id),
('warehouse_id', '=', False)
],
limit=1,
)
picking_form = Form(
recordp=self.env['stock.picking'].with_context(
default_picking_type_id=outgoing_picking_type.id),
view="stock.view_picking_form",
)
picking_form.company_id = self.company
picking_form.partner_id = self.partner
picking_form.partner_id = self.partner
with picking_form.move_ids_without_package.new() as move:
move.product_id = product_2
move.product_uom_qty = 15
move.product_uom = uom_ten
picking = picking_form.save()
picking.action_done()
rma_form.picking_id = picking
rma_form.move_id = picking.move_lines
self.assertEqual(rma_form.product_id, product_2)
self.assertEqual(rma_form.product_uom_qty, 15)
self.assertEqual(rma_form.product_uom, uom_ten)
# If product changes, unit of measure changes
rma_form.picking_id = self.env['stock.picking']
rma_form.product_id = self.product
self.assertEqual(rma_form.product_id, self.product)
self.assertEqual(rma_form.product_uom_qty, 15)
self.assertNotEqual(rma_form.product_uom, uom_ten)
self.assertEqual(rma_form.product_uom, self.product.uom_id)
rma = rma_form.save()
# If product changes, unit of measure domain should also change
domain = rma._onchange_product_id()['domain']['product_uom']
self.assertListEqual(
domain, [('category_id', '=', self.product.uom_id.category_id.id)])
def test_ensure_required_fields_on_confirm(self):
rma = self._create_rma()
with self.assertRaises(ValidationError) as e:
rma.action_confirm()
self.assertEqual(
e.exception.name,
"Required field(s):\nCustomer\nInvoice Address\nProduct\nLocation"
)
with Form(rma) as rma_form:
rma_form.partner_id = self.partner
with self.assertRaises(ValidationError) as e:
rma.action_confirm()
self.assertEqual(
e.exception.name, "Required field(s):\nProduct\nLocation")
with Form(rma) as rma_form:
rma_form.product_id = self.product
rma_form.location_id = self.rma_loc
rma.action_confirm()
self.assertEqual(rma.state, 'confirmed')
def test_confirm_and_receive(self):
rma = self._create_rma(self.partner, self.product, 10, self.rma_loc)
rma.action_confirm()
self.assertEqual(rma.reception_move_id.picking_id.state, 'assigned')
self.assertEqual(rma.reception_move_id.product_id, rma.product_id)
self.assertEqual(rma.reception_move_id.product_uom_qty, 10)
self.assertEqual(rma.reception_move_id.product_uom, rma.product_uom)
self.assertEqual(rma.state, 'confirmed')
self._test_readonly_fields(rma)
rma.reception_move_id.quantity_done = 9
with self.assertRaises(ValidationError):
rma.reception_move_id.picking_id.action_done()
rma.reception_move_id.quantity_done = 10
rma.reception_move_id.picking_id.action_done()
self.assertEqual(rma.reception_move_id.picking_id.state, 'done')
self.assertEqual(rma.reception_move_id.quantity_done, 10)
self.assertEqual(rma.state, 'received')
self._test_readonly_fields(rma)
def test_cancel(self):
# cancel a draft RMA
rma = self._create_rma(self.partner, self.product)
rma.action_cancel()
self.assertEqual(rma.state, 'cancelled')
self._test_readonly_fields(rma)
# cancel a confirmed RMA
rma = self._create_rma(self.partner, self.product, 10, self.rma_loc)
rma.action_confirm()
rma.action_cancel()
self.assertEqual(rma.state, 'cancelled')
# A RMA is only cancelled from draft and confirmed states
rma = self._create_confirm_receive(self.partner, self.product, 10,
self.rma_loc)
with self.assertRaises(UserError):
rma.action_cancel()
def test_lock_unlock(self):
# A RMA is only locked from 'received' state
rma_1 = self._create_rma(self.partner, self.product, 10, self.rma_loc)
rma_2 = self._create_confirm_receive(self.partner, self.product, 10,
self.rma_loc)
self.assertEqual(rma_1.state, 'draft')
self.assertEqual(rma_2.state, 'received')
(rma_1 | rma_2).action_lock()
self.assertEqual(rma_1.state, 'draft')
self.assertEqual(rma_2.state, 'locked')
# A RMA is only unlocked from 'lock' state and it will be set
# to 'received' state
(rma_1 | rma_2).action_unlock()
self.assertEqual(rma_1.state, 'draft')
self.assertEqual(rma_2.state, 'received')
def test_action_refund(self):
rma = self._create_confirm_receive(self.partner, self.product, 10,
self.rma_loc)
self.assertEqual(rma.state, 'received')
self.assertTrue(rma.can_be_refunded)
self.assertTrue(rma.can_be_returned)
self.assertTrue(rma.can_be_replaced)
rma.action_refund()
self.assertEqual(rma.refund_id.type, 'out_refund')
self.assertEqual(rma.refund_id.state, 'draft')
self.assertEqual(rma.refund_line_id.product_id, rma.product_id)
self.assertEqual(rma.refund_line_id.quantity, 10)
self.assertEqual(rma.refund_line_id.uom_id, rma.product_uom)
self.assertEqual(rma.state, 'waiting_refund')
self.assertFalse(rma.can_be_refunded)
self.assertFalse(rma.can_be_returned)
self.assertFalse(rma.can_be_replaced)
rma.refund_line_id.quantity = 9
with self.assertRaises(ValidationError):
rma.refund_id.action_invoice_open()
rma.refund_line_id.quantity = 10
rma.refund_id.action_invoice_open()
self.assertEqual(rma.state, 'refunded')
self.assertFalse(rma.can_be_refunded)
self.assertFalse(rma.can_be_returned)
self.assertFalse(rma.can_be_replaced)
self._test_readonly_fields(rma)
def test_mass_refund(self):
# Create, confirm and receive rma_1
rma_1 = self._create_confirm_receive(self.partner, self.product, 10,
self.rma_loc)
# create, confirm and receive 3 more RMAs
# rma_2: Same partner and same product as rma_1
rma_2 = self._create_confirm_receive(self.partner, self.product, 15,
self.rma_loc)
# rma_3: Same partner and different product than rma_1
product = self.product_product.create(
{'name': 'Product 2 test', 'type': 'product'})
rma_3 = self._create_confirm_receive(self.partner, product, 20,
self.rma_loc)
# rma_4: Different partner and same product as rma_1
partner = self.res_partner.create({
'name': 'Partner 2 test',
'property_account_receivable_id': self.account_receiv.id,
'company_id': self.company.id,
})
rma_4 = self._create_confirm_receive(partner, product, 25,
self.rma_loc)
# all rmas are ready to refund
all_rmas = (rma_1 | rma_2 | rma_3 | rma_4)
self.assertEqual(all_rmas.mapped('state'), ['received']*4)
self.assertEqual(all_rmas.mapped('can_be_refunded'), [True]*4)
# Mass refund of those four RMAs
action = self.env.ref('rma.rma_refund_action_server')
ctx = dict(self.env.context)
ctx.update(active_ids=all_rmas.ids, active_model='rma')
action.with_context(ctx).run()
# Two refunds were created
refund_1 = (rma_1 | rma_2 | rma_3).mapped('refund_id')
refund_2 = rma_4.refund_id
self.assertEqual(len(refund_1), 1)
self.assertEqual(len(refund_2), 1)
self.assertEqual((refund_1 | refund_2).mapped('state'), ['draft']*2)
# One refund per partner
self.assertNotEqual(refund_1.partner_id, refund_2.partner_id)
self.assertEqual(
refund_1.partner_id,
(rma_1 | rma_2 | rma_3).mapped('partner_invoice_id'),
)
self.assertEqual(refund_2.partner_id, rma_4.partner_invoice_id)
# Each RMA (rma_1, rma_2 and rma_3) is linked with a different
# line of refund_1
self.assertEqual(len(refund_1.invoice_line_ids), 3)
self.assertEqual(
refund_1.invoice_line_ids.mapped('rma_id'),
(rma_1 | rma_2 | rma_3),
)
self.assertEqual(
(rma_1 | rma_2 | rma_3).mapped('refund_line_id'),
refund_1.invoice_line_ids,
)
# rma_4 is linked with the unique line of refund_2
self.assertEqual(len(refund_2.invoice_line_ids), 1)
self.assertEqual(refund_2.invoice_line_ids.rma_id, rma_4)
self.assertEqual(rma_4.refund_line_id, refund_2.invoice_line_ids)
# Assert product and quantities are propagated correctly
for rma in all_rmas:
self.assertEqual(rma.product_id, rma.refund_line_id.product_id)
self.assertEqual(rma.product_uom_qty, rma.refund_line_id.quantity)
self.assertEqual(rma.product_uom, rma.refund_line_id.uom_id)
# Less quantity -> error on confirm
rma_2.refund_line_id.quantity = 14
with self.assertRaises(ValidationError):
refund_1.action_invoice_open()
rma_2.refund_line_id.quantity = 15
refund_1.action_invoice_open()
refund_2.action_invoice_open()
self.assertEqual(all_rmas.mapped('state'), ['refunded']*4)
def test_replace(self):
# Create, confirm and receive an RMA
rma = self._create_confirm_receive(self.partner, self.product, 10,
self.rma_loc)
# Replace with another product with quantity 2.
product_2 = self.product_product.create(
{'name': 'Product 2 test', 'type': 'product'})
delivery_form = Form(
self.env['rma.delivery.wizard'].with_context(
active_ids=rma.ids,
rma_delivery_type='replace',
)
)
delivery_form.product_id = product_2
delivery_form.product_uom_qty = 2
delivery_wizard = delivery_form.save()
delivery_wizard.action_deliver()
self.assertEqual(len(rma.delivery_move_ids.picking_id.move_lines), 1)
self.assertEqual(rma.delivery_move_ids.product_id, product_2)
self.assertEqual(rma.delivery_move_ids.product_uom_qty, 2)
self.assertTrue(rma.delivery_move_ids.picking_id.state, 'waiting')
self.assertEqual(rma.state, 'waiting_replacement')
self.assertFalse(rma.can_be_refunded)
self.assertFalse(rma.can_be_returned)
self.assertTrue(rma.can_be_replaced)
self.assertEqual(rma.delivered_qty, 2)
self.assertEqual(rma.remaining_qty, 8)
self.assertEqual(rma.delivered_qty_done, 0)
self.assertEqual(rma.remaining_qty_to_done, 10)
first_move = rma.delivery_move_ids
picking = first_move.picking_id
# Replace again with another product with the remaining quantity
product_3 = self.product_product.create(
{'name': 'Product 3 test', 'type': 'product'})
delivery_form = Form(
self.env['rma.delivery.wizard'].with_context(
active_ids=rma.ids,
rma_delivery_type='replace',
)
)
delivery_form.product_id = product_3
delivery_wizard = delivery_form.save()
delivery_wizard.action_deliver()
second_move = rma.delivery_move_ids - first_move
self.assertEqual(len(rma.delivery_move_ids), 2)
self.assertEqual(rma.delivery_move_ids.mapped('picking_id'), picking)
self.assertEqual(first_move.product_id, product_2)
self.assertEqual(first_move.product_uom_qty, 2)
self.assertEqual(second_move.product_id, product_3)
self.assertEqual(second_move.product_uom_qty, 8)
self.assertTrue(picking.state, 'waiting')
self.assertEqual(rma.delivered_qty, 10)
self.assertEqual(rma.remaining_qty, 0)
self.assertEqual(rma.delivered_qty_done, 0)
self.assertEqual(rma.remaining_qty_to_done, 10)
# remaining_qty is 0 but rma is not set to 'replaced' until
# remaining_qty_to_done is less than or equal to 0
first_move.quantity_done = 2
second_move.quantity_done = 8
picking.button_validate()
self.assertEqual(picking.state, 'done')
self.assertEqual(rma.delivered_qty, 10)
self.assertEqual(rma.remaining_qty, 0)
self.assertEqual(rma.delivered_qty_done, 10)
self.assertEqual(rma.remaining_qty_to_done, 0)
# The RMA is now in 'replaced' state
self.assertEqual(rma.state, 'replaced')
self.assertFalse(rma.can_be_refunded)
self.assertFalse(rma.can_be_returned)
# Despite being in 'replaced' state,
# RMAs can still perform replacements.
self.assertTrue(rma.can_be_replaced)
self._test_readonly_fields(rma)
def test_return_to_customer(self):
# Create, confirm and receive an RMA
rma = self._create_confirm_receive(self.partner, self.product, 10,
self.rma_loc)
# Return the same product with quantity 2 to the customer.
delivery_form = Form(
self.env['rma.delivery.wizard'].with_context(
active_ids=rma.ids,
rma_delivery_type='return',
)
)
delivery_form.product_uom_qty = 2
delivery_wizard = delivery_form.save()
delivery_wizard.action_deliver()
picking = rma.delivery_move_ids.picking_id
self.assertEqual(len(picking.move_lines), 1)
self.assertEqual(rma.delivery_move_ids.product_id, self.product)
self.assertEqual(rma.delivery_move_ids.product_uom_qty, 2)
self.assertTrue(picking.state, 'waiting')
self.assertEqual(rma.state, 'waiting_return')
self.assertFalse(rma.can_be_refunded)
self.assertFalse(rma.can_be_replaced)
self.assertTrue(rma.can_be_returned)
self.assertEqual(rma.delivered_qty, 2)
self.assertEqual(rma.remaining_qty, 8)
self.assertEqual(rma.delivered_qty_done, 0)
self.assertEqual(rma.remaining_qty_to_done, 10)
first_move = rma.delivery_move_ids
picking = first_move.picking_id
# Validate the picking
first_move.quantity_done = 2
picking.button_validate()
self.assertEqual(picking.state, 'done')
self.assertEqual(rma.delivered_qty, 2)
self.assertEqual(rma.remaining_qty, 8)
self.assertEqual(rma.delivered_qty_done, 2)
self.assertEqual(rma.remaining_qty_to_done, 8)
# Return the remaining quantity to the customer
delivery_form = Form(
self.env['rma.delivery.wizard'].with_context(
active_ids=rma.ids,
rma_delivery_type='return',
)
)
delivery_wizard = delivery_form.save()
delivery_wizard.action_deliver()
second_move = rma.delivery_move_ids - first_move
second_move.quantity_done = 8
self.assertEqual(rma.delivered_qty, 10)
self.assertEqual(rma.remaining_qty, 0)
self.assertEqual(rma.delivered_qty_done, 2)
self.assertEqual(rma.remaining_qty_to_done, 8)
self.assertEqual(rma.state, 'waiting_return')
# remaining_qty is 0 but rma is not set to 'returned' until
# remaining_qty_to_done is less than or equal to 0
picking_2 = second_move.picking_id
picking_2.button_validate()
self.assertEqual(picking_2.state, 'done')
self.assertEqual(rma.delivered_qty, 10)
self.assertEqual(rma.remaining_qty, 0)
self.assertEqual(rma.delivered_qty_done, 10)
self.assertEqual(rma.remaining_qty_to_done, 0)
# The RMA is now in 'returned' state
self.assertEqual(rma.state, 'returned')
self.assertFalse(rma.can_be_refunded)
self.assertFalse(rma.can_be_returned)
self.assertFalse(rma.can_be_replaced)
self._test_readonly_fields(rma)
def test_mass_return_to_customer(self):
# Create, confirm and receive rma_1
rma_1 = self._create_confirm_receive(self.partner, self.product, 10,
self.rma_loc)
# create, confirm and receive 3 more RMAs
# rma_2: Same partner and same product as rma_1
rma_2 = self._create_confirm_receive(self.partner, self.product, 15,
self.rma_loc)
# rma_3: Same partner and different product than rma_1
product = self.product_product.create(
{'name': 'Product 2 test', 'type': 'product'})
rma_3 = self._create_confirm_receive(self.partner, product, 20,
self.rma_loc)
# rma_4: Different partner and same product as rma_1
partner = self.res_partner.create({'name': 'Partner 2 test'})
rma_4 = self._create_confirm_receive(partner, product, 25,
self.rma_loc)
# all rmas are ready to be returned to the customer
all_rmas = (rma_1 | rma_2 | rma_3 | rma_4)
self.assertEqual(all_rmas.mapped('state'), ['received'] * 4)
self.assertEqual(all_rmas.mapped('can_be_returned'), [True] * 4)
# Mass return of those four RMAs
delivery_wizard = self.env['rma.delivery.wizard'].with_context(
active_ids=all_rmas.ids, rma_delivery_type='return').create({})
delivery_wizard.action_deliver()
# Two pickings were created
pick_1 = (rma_1 | rma_2 | rma_3).mapped('delivery_move_ids.picking_id')
pick_2 = rma_4.delivery_move_ids.picking_id
self.assertEqual(len(pick_1), 1)
self.assertEqual(len(pick_2), 1)
self.assertNotEqual(pick_1, pick_2)
self.assertEqual((pick_1 | pick_2).mapped('state'), ['assigned'] * 2)
# One picking per partner
self.assertNotEqual(pick_1.partner_id, pick_2.partner_id)
self.assertEqual(
pick_1.partner_id,
(rma_1 | rma_2 | rma_3).mapped('partner_id'),
)
self.assertEqual(pick_2.partner_id, rma_4.partner_id)
# Each RMA of (rma_1, rma_2 and rma_3) is linked to a different
# line of picking_1
self.assertEqual(len(pick_1.move_lines), 3)
self.assertEqual(
pick_1.move_lines.mapped('rma_id'),
(rma_1 | rma_2 | rma_3),
)
self.assertEqual(
(rma_1 | rma_2 | rma_3).mapped('delivery_move_ids'),
pick_1.move_lines,
)
# rma_4 is linked with the unique move of pick_2
self.assertEqual(len(pick_2.move_lines), 1)
self.assertEqual(pick_2.move_lines.rma_id, rma_4)
self.assertEqual(rma_4.delivery_move_ids, pick_2.move_lines)
# Assert product and quantities are propagated correctly
for rma in all_rmas:
self.assertEqual(rma.product_id, rma.delivery_move_ids.product_id)
self.assertEqual(rma.product_uom_qty,
rma.delivery_move_ids.product_uom_qty)
self.assertEqual(rma.product_uom,
rma.delivery_move_ids.product_uom)
rma.delivery_move_ids.quantity_done = rma.product_uom_qty
pick_1.button_validate()
pick_2.button_validate()
self.assertEqual(all_rmas.mapped('state'), ['returned'] * 4)
def test_rma_from_picking_return(self):
# Create a return from a delivery picking
origin_delivery = self._create_delivery()
return_wizard = self.env['stock.return.picking'].with_context(
active_id=origin_delivery.id,
active_ids=origin_delivery.ids,
).create({'create_rma': True})
picking_action = return_wizard.create_returns()
# Each origin move is linked to a different RMA
origin_moves = origin_delivery.move_lines
self.assertTrue(origin_moves[0].rma_ids)
self.assertTrue(origin_moves[1].rma_ids)
rmas = origin_moves.mapped('rma_ids')
self.assertEqual(rmas.mapped('state'), ['confirmed']*2)
# Each reception move is linked one of the generated RMAs
reception = self.env['stock.picking'].browse(picking_action['res_id'])
reception_moves = reception.move_lines
self.assertTrue(reception_moves[0].rma_receiver_ids)
self.assertTrue(reception_moves[1].rma_receiver_ids)
self.assertEqual(reception_moves.mapped('rma_receiver_ids'), rmas)
# Validate the reception picking to set rmas to 'received' state
reception_moves[0].quantity_done = reception_moves[0].product_uom_qty
reception_moves[1].quantity_done = reception_moves[1].product_uom_qty
reception.button_validate()
self.assertEqual(rmas.mapped('state'), ['received'] * 2)
def test_split(self):
origin_delivery = self._create_delivery()
rma_form = Form(self.env['rma'])
rma_form.partner_id = self.partner
rma_form.picking_id = origin_delivery
rma_form.move_id = origin_delivery.move_lines.filtered(
lambda r: r.product_id == self.product)
rma = rma_form.save()
rma.action_confirm()
rma.reception_move_id.quantity_done = 10
rma.reception_move_id.picking_id.action_done()
# Return quantity 4 of the same product to the customer
delivery_form = Form(
self.env['rma.delivery.wizard'].with_context(
active_ids=rma.ids,
rma_delivery_type='return',
)
)
delivery_form.product_uom_qty = 4
delivery_wizard = delivery_form.save()
delivery_wizard.action_deliver()
rma.delivery_move_ids.quantity_done = 4
rma.delivery_move_ids.picking_id.button_validate()
self.assertEqual(rma.state, 'waiting_return')
# Extract the remaining quantity to another RMA
self.assertTrue(rma.can_be_split)
split_wizard = self.env['rma.split.wizard'].with_context(
active_id=rma.id,
active_ids=rma.ids,
).create({})
action = split_wizard.action_split()
# Check rma is set to 'returned' after split. Check new_rma values
self.assertEqual(rma.state, 'returned')
new_rma = self.env['rma'].browse(action['res_id'])
self.assertEqual(new_rma.origin_split_rma_id, rma)
self.assertEqual(new_rma.delivered_qty, 0)
self.assertEqual(new_rma.remaining_qty, 6)
self.assertEqual(new_rma.delivered_qty_done, 0)
self.assertEqual(new_rma.remaining_qty_to_done, 6)
self.assertEqual(new_rma.state, 'received')
self.assertTrue(new_rma.can_be_refunded)
self.assertTrue(new_rma.can_be_returned)
self.assertTrue(new_rma.can_be_replaced)
self.assertEqual(new_rma.move_id, rma.move_id)
self.assertEqual(new_rma.reception_move_id, rma.reception_move_id)
self.assertEqual(new_rma.product_uom_qty + rma.product_uom_qty, 10)
self.assertEqual(new_rma.move_id.quantity_done, 10)
self.assertEqual(new_rma.reception_move_id.quantity_done, 10)

24
rma/views/menus.xml Normal file
View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2020 Tecnativa - Ernesto Tejeda
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<odoo>
<menuitem
id="rma_menu"
name="RMA"
web_icon="rma,static/description/icon.png"/>
<menuitem
id="rma_orders_menu"
parent="rma_menu"
name="Orders"
sequence="10"/>
<menuitem
id="rma_reporting_menu"
parent="rma_menu"
name="Reporting"
sequence="20"/>
<menuitem
id="rma_configuration_menu"
parent="rma_menu"
name="Configuration"
sequence="30"/>
</odoo>

96
rma/views/report_rma.xml Normal file
View File

@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<template id="report_rma_document">
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)" />
<t t-if="doc.partner_id">
<t t-set="address">
<div t-field="doc.partner_id"
t-options='{"widget": "contact", "fields": ["address", "name"], "no_marker": True}' />
<p t-if="doc.partner_id.vat"><t t-esc="doc.company_id.country_id.vat_label or 'Tax ID'"/>: <span t-field="doc.partner_id.vat"/></p>
</t>
</t>
<t t-if="(doc.partner_id or doc.partner_invoice_id) and doc.partner_id != doc.partner_invoice_id">
<t t-set="information_block">
<strong>Invoicing address:</strong>
<div t-field="doc.partner_invoice_id"
t-options='{"widget": "contact", "fields": ["address", "name", "phone"], "no_marker": True, "phone_icons": True}'/>
</t>
</t>
<div class="page">
<h2 class="mt16">
<span t-if="doc.state not in ['draft', 'cancelled']">RMA # </span>
<span t-field="doc.name"/>
</h2>
<div class="row mt32 mb32" id="general_information">
<div t-if="doc.origin" class="col-auto mw-100 mb-2">
<strong>Origin:</strong>
<p class="m-0" t-field="doc.origin"/>
</div>
<div class="col-auto mw-100 mb-2">
<strong>Date:</strong>
<p class="m-0" t-field="doc.date"/>
</div>
<div t-if="doc.deadline" class="col-auto mw-100 mb-2">
<strong>Deadline:</strong>
<p class="m-0" t-field="doc.deadline"/>
</div>
<div t-if="doc.user_id" class="col-auto mw-100 mb-2">
<strong>Responsible:</strong>
<p class="m-0" t-field="doc.user_id"/>
</div>
<div class="col-auto mw-100 mb-2">
<strong>State:</strong>
<p class="m-0">
<t t-if="doc.state in ['refunded', 'replaced', 'returned']">
<span class="small text-success orders_label_text_align"><i class="fa fa-fw fa-check"/> <b><span t-field="doc.state"/></b></span>
</t>
<t t-elif="doc.state in ['cancelled', 'locked']">
<span class="small text-danger orders_label_text_align"><i class="fa fa-fw fa-times"/> <b><span t-field="doc.state"/></b></span>
</t>
<t t-else="">
<span class="small text-info orders_label_text_align"><i class="fa fa-fw fa-clock-o"/> <b><span t-field="doc.state"/></b></span>
</t>
</p>
</div>
</div>
<div class="row mt32 mb32" id="product_information">
<div t-if="doc.picking_id" class="col-auto mw-100 mb-2">
<strong>Origin delivery:</strong>
<p class="m-0" t-field="doc.picking_id"/>
</div>
<div t-if="doc.move_id" class="col-auto mw-100 mb-2">
<strong>Move:</strong>
<p class="m-0" t-field="doc.move_id"/>
</div>
<div t-if="doc.product_id" class="col-auto mw-100 mb-2">
<strong>Product:</strong>
<p class="m-0" t-field="doc.product_id"/>
</div>
<div t-if="doc.product_id" class="col-auto mw-100 mb-2">
<strong>Quantity:</strong>
<p class="m-0" t-field="doc.product_uom_qty">
<span t-field="doc.product_uom_qty"/>
<span t-field="doc.uom_id" groups="uom.group_uom"/>
</p>
</div>
<div t-if="doc.delivered_qty" class="col-auto mw-100 mb-2">
<strong>Delivered qty:</strong>
<p class="m-0" t-field="doc.delivered_qty"/>
</div>
</div>
<div t-if="doc.description">
<strong>RMA Note:</strong>
<p t-field="doc.description" />
</div>
</div>
</t>
</template>
<template id="report_rma">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="rma.report_rma_document" t-lang="doc.partner_id.lang" />
</t>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2020 Tecnativa - Ernesto Tejeda
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<odoo>
<record id="view_partner_form" model="ir.ui.view">
<field name="name">res.partner.form</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<div name="button_box">
<button name="action_view_rma"
type="object"
class="oe_stat_button"
icon="fa-reply"
attrs="{'invisible': [('rma_count', '=', 0)]}">
<field name="rma_count"
widget="statinfo"
string="RMA"/>
</button>
</div>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,269 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2020 Tecnativa - Ernesto Tejeda
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<odoo>
<template id="portal_my_home_menu_rma" name="Portal layout : RMA menu entries" inherit_id="portal.portal_breadcrumbs" priority="35">
<xpath expr="//ol[hasclass('o_portal_submenu')]" position="inside">
<li t-if="page_name == 'RMA'" t-attf-class="breadcrumb-item #{'active ' if not rma else ''}">
<a t-if="rma" t-attf-href="/my/rmas?{{ keep_query() }}">RMA Orders</a>
<t t-else="">RMA Orders</t>
</li>
<li t-if="rma" class="breadcrumb-item active">
<t t-esc="rma.name"/>
</li>
</xpath>
</template>
<template id="portal_my_home_rma" name="Portal My Home : RMA entries" inherit_id="portal.portal_my_home" priority="30">
<xpath expr="//div[hasclass('o_portal_docs')]" position="inside">
<t t-call="portal.portal_docs_entry">
<t t-set="title">RMA Orders</t>
<t t-set="url" t-value="'/my/rmas'"/>
<t t-set="count" t-value="rma_count"/>
</t>
</xpath>
</template>
<template id="portal_my_rmas" name="My RMA Orders">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="True"/>
<t t-call="portal.portal_searchbar">
<t t-set="title">RMA Orders</t>
</t>
<t t-if="rmas" t-call="portal.portal_table">
<thead>
<tr class="active">
<th>RMA #</th>
<th class='d-none d-md-table-cell'>Date</th>
<th>Product</th>
<th class='text-right'>Quantity</th>
<th class='d-none d-md-table-cell'>Status</th>
</tr>
</thead>
<tbody>
<t t-foreach="rmas" t-as="rma">
<tr>
<td>
<a t-att-href="rma.get_portal_url()" t-att-title="rma.name">
<t t-esc="rma.name"/>
</a>
</td>
<td class="d-none d-md-table-cell"><span t-field="rma.date"/></td>
<td><span t-field="rma.product_id.name"/></td>
<td class='text-right'><span t-field="rma.product_uom_qty"/></td>
<td class="d-none d-md-table-cell tx_status">
<span class="badge badge-pill badge-secondary"><span t-field="rma.state"/></span>
</td>
</tr>
</t>
</tbody>
</t>
</t>
</template>
<template id="portal_rma_page" name="My RMA">
<t t-call="portal.portal_layout">
<t t-set="o_portal_fullwidth_alert" groups="rma.rma_group_user_own">
<t t-call="portal.portal_back_in_edit_mode">
<t t-set="backend_url" t-value="'/web#return_label=Website&amp;model=rma&amp;id=%s&amp;view_type=form' % (rma.id)"/>
</t>
</t>
<t t-call="portal.portal_record_layout">
<t t-set="card_header">
<h5 class="mb-0">
<span>
RMA Order - <span t-field="rma.name"/>
</span>
<span style="position: absolute; left: 50%;" class="d-none d-sm-inline">
<a t-att-href="rma.get_portal_url(report_type='pdf', download=True)">
<i class="fa fa-download" role="img" aria-label="Download" title="Download"/>
</a>
</span>
<span class="float-right">
<t t-if="rma.state in ['refunded', 'returned', 'replaced']">
<span class="small text-success orders_label_text_align"><i class="fa fa-fw fa-check"/> <b><span t-field="rma.state"/></b></span>
</t>
<t t-elif="rma.state in ['cancelled', 'locked']">
<span class="small text-danger orders_label_text_align"><i class="fa fa-fw fa-times"/> <b><span t-field="rma.state"/></b></span>
</t>
<t t-else="">
<span class="small text-info orders_label_text_align"><i class="fa fa-fw fa-clock-o"/> <b><span t-field="rma.state"/></b></span>
</t>
</span>
</h5>
</t>
<t t-set="card_body">
<div id="general_information">
<div class="row mt4">
<div t-if="rma.partner_id" class="col-12 col-md-6 mb-4 mb-md-0">
<h6><strong>Customer:</strong></h6>
<div class="row">
<div class="col flex-grow-0 pr-3">
<img t-if="rma.partner_id.image" class="rounded-circle mt-1 o_portal_contact_img" t-att-src="image_data_uri(rma.partner_id.image)" alt="Contact"/>
<img t-else="" class="rounded-circle mt-1 o_portal_contact_img" src="/web/static/src/img/user_menu_avatar.png" alt="Contact"/>
</div>
<div class="col pl-sm-0">
<address t-field="rma.partner_id" t-options='{"widget": "contact", "fields": ["name", "email", "phone"]}'/>
</div>
</div>
</div>
<div t-if="rma.user_id" class="col-12 col-md-6">
<h6><strong>Responsible:</strong></h6>
<div class="row">
<div class="col flex-grow-0 pr-3">
<img t-if="rma.user_id.image" class="rounded-circle mt-1 o_portal_contact_img" t-att-src="image_data_uri(rma.user_id.image)" alt="Contact"/>
<img t-else="" class="rounded-circle mt-1 o_portal_contact_img" src="/web/static/src/img/user_menu_avatar.png" alt="Contact"/>
</div>
<div class="col pl-sm-0">
<address t-field="rma.user_id" t-options='{"widget": "contact", "fields": ["name", "email", "phone"]}'/>
</div>
</div>
</div>
</div>
<div class="row mt32" id="product_information">
<div class="col-12 col-md-6 mb-4 mb-md-0">
<div t-if="rma.picking_id" class="row mb-2 mb-sm-1">
<div class="col-12 col-sm-4">
<strong>Origin delivery</strong>
</div>
<div class="col-12 col-sm-8">
<span t-field="rma.picking_id"/>
</div>
</div>
<div t-if="rma.product_id" class="row mb-2 mb-sm-1">
<div class="col-12 col-sm-4">
<strong>Product</strong>
</div>
<div class="col-12 col-sm-8">
<span t-field="rma.product_id"/>
</div>
</div>
<div t-if="rma.product_uom_qty" class="row mb-2 mb-sm-1">
<div class="col-12 col-sm-4">
<strong>Quantity</strong>
</div>
<div class="col-12 col-sm-8">
<span t-field="rma.product_uom_qty"/>
</div>
</div>
<div t-if="rma.delivered_qty" class="row">
<div class="col-12 col-sm-4">
<strong>Delivered quantity</strong>
</div>
<div class="col-12 col-sm-8">
<span t-field="rma.delivered_qty"/>
</div>
</div>
</div>
<div class="col-12 col-md-6">
<div t-if="rma.date" class="row mb-2 mb-sm-1">
<div class="col-12 col-sm-4">
<strong>RMA Date</strong>
</div>
<div class="col-12 col-sm-8">
<span t-field="rma.date" t-options='{"widget": "date"}'/>
</div>
</div>
<div t-if="rma.deadline" class="row mb-2 mb-sm-1">
<div class="col-12 col-sm-4">
<strong>Deadline</strong>
</div>
<div class="col-12 col-sm-8">
<span t-field="rma.deadline" t-options='{"widget": "date"}'/>
</div>
</div>
<div t-if="rma.origin" class="row">
<div class="col-12 col-sm-4">
<strong>Origin</strong>
</div>
<div class="col-12 col-sm-8">
<span t-field="rma.origin"/>
</div>
</div>
</div>
</div>
</div>
<section t-if="rma.reception_move_id" id="reception_section" style="page-break-inside: auto;" class="mt32">
<strong class="d-block mb-1">Reception</strong>
<t t-set="picking" t-value="rma.reception_move_id.picking_id"/>
<t t-set="report_url" t-value="'/my/rma/picking/pdf/%s?%s' % (picking.id, keep_query())"/>
<a class="list-group-item list-group-item-action d-flex flex-wrap align-items-center justify-content-between py-2 px-3" t-att-href="report_url">
<div>
<i class="fa fa-truck mr-1" role="img" aria-label="Download" title="Download"/>
<span t-esc="picking.name" class="mr-lg-3"/>
<div class="d-lg-inline-block">Date: <span class="text-muted" t-field="picking.date"/></div>
</div>
<t t-if="picking.state == 'done'">
<span class="badge badge-success label-text-align"><i class="fa fa-fw fa-truck"/> Shipped</span>
</t>
<t t-if="picking.state == 'partially_available'">
<span class="badge badge-warning label-text-align"><i class="fa fa-fw fa-clock-o"/> Partially Available</span>
</t>
<t t-if="picking.state == 'cancel'">
<span class="badge badge-danger label-text-align"><i class="fa fa-fw fa-times"/> Cancelled</span>
</t>
<t t-if="picking.state in ['draft', 'waiting', 'confirmed', 'assigned']">
<span class="badge badge-info label-text-align"><i class="fa fa-fw fa-clock-o"/> Preparation</span>
</t>
</a>
</section>
<section t-if="rma.refund_id" id="refund_section" style="page-break-inside: auto;" class="mt32">
<strong class="d-block mb-1">Refund</strong>
<t t-set="refund" t-value="rma.refund_id"/>
<t t-set="report_url" t-value="refund.get_portal_url(report_type='pdf')"/>
<a class="list-group-item list-group-item-action d-flex flex-wrap align-items-center justify-content-between py-2 px-3" t-att-href="report_url">
<div>
<i class="fa fa-pencil-square-o mr-1" role="img" aria-label="Download" title="Download"/>
<span t-esc="refund.number" class="mr-lg-3"/>
<div class="d-lg-inline-block">Date: <span class="text-muted" t-field="refund.date_invoice"/></div>
</div>
<span t-if="refund.state == 'paid'" class="small text-success orders_label_text_align"><i class="fa fa-fw fa-check"/> <b>Paid</b></span>
<span t-else="" class="small text-info orders_label_text_align"><i class="fa fa-fw fa-clock-o"/> <b>Waiting Payment</b></span>
</a>
</section>
<section t-if="rma.delivery_move_ids" id="reception_section" style="page-break-inside: auto;" class="mt32">
<strong class="d-block mb-1">Delivery</strong>
<ul class="list-group mb-4">
<t t-foreach="rma.delivery_move_ids.mapped('picking_id')" t-as="picking">
<t t-set="report_url" t-value="'/my/rma/picking/pdf/%s?%s' % (picking.id, keep_query())"/>
<a class="list-group-item list-group-item-action d-flex flex-wrap align-items-center justify-content-between py-2 px-3" t-att-href="report_url">
<div>
<i class="fa fa-truck mr-1" role="img" aria-label="Download" title="Download"/>
<span t-esc="picking.name" class="mr-lg-3"/>
<div class="d-lg-inline-block">Date: <span class="text-muted" t-field="picking.date"/></div>
</div>
<t t-if="picking.state == 'done'">
<span class="badge badge-success label-text-align"><i class="fa fa-fw fa-truck"/> Shipped</span>
</t>
<t t-if="picking.state == 'partially_available'">
<span class="badge badge-warning label-text-align"><i class="fa fa-fw fa-clock-o"/> Partially Available</span>
</t>
<t t-if="picking.state == 'cancel'">
<span class="badge badge-danger label-text-align"><i class="fa fa-fw fa-times"/> Cancelled</span>
</t>
<t t-if="picking.state in ['draft', 'waiting', 'confirmed', 'assigned']">
<span class="badge badge-info label-text-align"><i class="fa fa-fw fa-clock-o"/> Preparation</span>
</t>
</a>
</t>
</ul>
</section>
<section id="description" class="mt-5" t-if="rma.description">
<h3 class="">Description</h3>
<hr class="mt-0 mb-1"/>
<em t-field="rma.description"/>
</section>
</t>
</t>
<!-- chatter -->
<div id="rma_communication" class="mt-4">
<h2>Communication</h2>
<t t-call="portal.message_thread">
<t t-set="object" t-value="rma"/>
</t>
</div>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,90 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2020 Tecnativa - Ernesto Tejeda
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<odoo>
<record id="rma_team_view_form" model="ir.ui.view">
<field name="name">rma.team.view.form</field>
<field name="model">rma.team</field>
<field name="arch" type="xml">
<form>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="toggle_active" type="object" class="oe_stat_button" icon="fa-archive">
<field name="active" widget="boolean_button" options='{"terminology": "archive"}'/>
</button>
</div>
<div class="oe_title">
<label for="name" class="oe_edit_only" string="RMA Team" />
<h1>
<field name="name" />
</h1>
</div>
<group>
<group>
<field name="user_id" />
<field name="company_id" options="{'no_create': True}" groups="base.group_multi_company" />
</group>
</group>
<notebook>
<page name="members" string="Team Members">
<field name="member_ids" widget="many2many" options="{'not_delete': True}">
<kanban quick_create="false" create="true" delete="true">
<field name="id"/>
<field name="name"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click" style="max-width: 200px">
<div class="o_kanban_record_top">
<img t-att-src="kanban_image('res.users', 'image_small', record.id.raw_value)" height="40" width="40" class="oe_avatar oe_kanban_avatar_smallbox mb0" alt="Avatar"/>
<div class="o_kanban_record_headings ml8">
<strong class="o_kanban_record_title"><field name="name"/></strong>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</page>
<page name="emails" string="Email" attrs="{'invisible': [('alias_domain', '=', False)]}">
<group name="group_alias">
<label for="alias_name" string="Email Alias"/>
<div name="alias_def">
<field name="alias_id" class="oe_read_only oe_inline"
string="Email Alias" required="0"/>
<div class="oe_edit_only oe_inline" name="edit_alias" style="display: inline;" >
<field name="alias_name" class="oe_inline"/>@<field name="alias_domain" class="oe_inline" readonly="1"/>
</div>
</div>
<field name="alias_contact" class="oe_inline oe_edit_only"
string="Accept Emails From"/>
</group>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids" widget="mail_followers"/>
<field name="message_ids" widget="mail_thread"/>
</div>
</form>
</field>
</record>
<record id="rma_team_action" model="ir.actions.act_window">
<field name="name">RMA team</field>
<field name="res_model">rma.team</field>
<field name="view_type">form</field>
<field name="view_mode">tree,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Click to add a new RMA.
</p>
</field>
</record>
<menuitem
id="rma_configuration_rma_team_menu"
name="RMA Team"
parent="rma_configuration_menu"
action="rma_team_action"/>
</odoo>

285
rma/views/rma_views.xml Normal file
View File

@@ -0,0 +1,285 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2020 Tecnativa - Ernesto Tejeda
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<odoo>
<record id="rma_view_search" model="ir.ui.view">
<field name="name">rma.view.search</field>
<field name="model">rma</field>
<field name="arch" type="xml">
<search>
<field name="name" />
<field name="user_id" />
<filter name="draft_filter"
string="Draft"
domain="[('state','=', 'draft')]" />
<filter name="confirmed_filter"
string="Confirmed"
domain="[('state','=', 'confirmed')]" />
<filter name="received_filter"
string="Received"
domain="[('state','=', 'received')]" />
<separator />
<filter name="no_user_id_filter"
string="Unassigned RMAs"
domain="[('user_id','=', False)]" />
<group string="Group By" name="group_by">
<filter string="Partner"
name="partner_id_group_by"
context="{'group_by':'partner_id'}" />
<filter string="Responsible"
name="user_id_group_by"
context="{'group_by':'user_id'}" />
<filter string="State"
name="state_group_by"
context="{'group_by':'state'}" />
<filter string="Date"
name="date_group_by"
context="{'group_by':'date'}" />
<filter string="Deadline"
name="deadline_group_by"
context="{'group_by':'deadline'}" />
</group>
</search>
</field>
</record>
<record id="rma_view_tree" model="ir.ui.view">
<field name="name">rma.view.tree</field>
<field name="model">rma</field>
<field name="arch" type="xml">
<tree decoration-muted="state in ['cancelled', 'locked']"
decoration-bf="state == 'draft' and product_id == False">
<field name="name"/>
<field name="origin"/>
<field name="user_id"/>
<field name="partner_id"/>
<field name="product_id"/>
<field name="product_uom_qty"/>
<field name="product_uom" groups="uom.group_uom"/>
<field name="date"/>
<field name="deadline"/>
<field name="state"/>
</tree>
</field>
</record>
<record id="rma_view_form" model="ir.ui.view">
<field name="name">rma.view.form</field>
<field name="model">rma</field>
<field name="arch" type="xml">
<form>
<header>
<button name="%(portal.portal_share_action)d"
string="Share"
type="action"
class="oe_highlight oe_read_only"/>
<button type="object"
string="Send by Email"
name="action_rma_send"
attrs="{'invisible':['|', ('sent','=',True), ('state', 'not in', ['draft', 'confirmed', 'received'])]}"
class="btn-primary"/>
<button type="object"
string="Send by Mail"
name="action_rma_send"
attrs="{'invisible':['|', ('sent','=',False), ('state', 'not in', ['draft', 'confirmed', 'received'])]}" />
<button type="object"
string="Confirm"
name="action_confirm"
states="draft"
class="btn-primary"/>
<button type="object"
string="To Refund"
name="action_refund"
attrs="{'invisible': [('can_be_refunded', '=', False)]}"
class="btn-primary"/>
<button type="object"
string="Replace"
name="action_replace"
attrs="{'invisible': [('can_be_replaced', '=', False)]}"
class="btn-primary"/>
<button type="object"
string="Return to customer"
name="action_return"
attrs="{'invisible': [('can_be_returned', '=', False)]}"
class="btn-primary"/>
<button type="object"
string="Split"
name="action_split"
attrs="{'invisible': [('can_be_split', '=', False)]}"/>
<button type="object"
string="Cancel"
name="action_cancel"
confirm="Are you sure you want to cancel this RMA"
states="draft,confirmed"/>
<button type="object"
string="Set to draft"
name="action_draft"
states="cancelled"/>
<button type="object"
string="Lock"
name="action_lock"
attrs="{'invisible': [('can_be_locked', '=', False)]}"/>
<button type="object"
string="Unlock"
name="action_unlock"
states="locked"/>
<button type="object"
string="Preview"
name="action_preview"/>
<field name="state" widget="statusbar" statusbar_visible="draft,confirmed,received"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button type="object"
name="action_view_receipt"
string="Receipt"
class="oe_stat_button"
icon="fa-truck"
attrs="{'invisible': [('reception_move_id', '=', False)]}">
</button>
<button type="object"
name="action_view_delivery"
class="oe_stat_button"
icon="fa-truck"
attrs="{'invisible': [('delivery_picking_count', '=', 0)]}">
<field name="delivery_picking_count" widget="statinfo" string="Delivery"/>
</button>
<button type="object"
string="Refund"
name="action_view_refund"
class="oe_stat_button"
icon="fa-pencil-square-o"
attrs="{'invisible': [('refund_id', '=', False)]}">
</button>
</div>
<div class="oe_title">
<h1>
<field name="name" readonly="1"/>
</h1>
</div>
<group>
<group>
<field name="partner_id" widget="res_partner_many2one" context="{'search_default_customer':1, 'show_address': 1, 'show_vat': True}" options="{'always_reload': True}"/>
<field name="partner_invoice_id"/>
<field name="picking_id"
options="{'no_create': True}"/>
<field name="move_id"
attrs="{'required': [('picking_id', '!=', False)], 'readonly': [('picking_id', '=', False)]}"
options="{'no_create': True}"
force_save="1"/>
<field name="product_id"
force_save="1"
attrs="{'readonly': ['|', ('picking_id', '!=', False), ('state', '!=', 'draft')]}"/>
<label for="product_uom_qty"/>
<div class="o_row">
<field name="product_uom_qty"/>
<field name="product_uom"
groups="uom.group_uom"/>
</div>
<field name="delivered_qty"
attrs="{'invisible': [('delivered_qty', '=', 0.0)]}"/>
</group>
<group>
<field name="date"/>
<field name="user_id"/>
<field name="team_id"/>
<field name="origin"/>
<field name="operation_id"/>
<field name="company_id"
options="{'no_create': True}"
groups="base.group_multi_company"/>
</group>
</group>
<notebook>
<page name="page_other"
string="Other Information">
<group>
<group>
<field name="procurement_group_id"/>
<field name="location_id"
options="{'no_create': True, 'no_open': True}"
groups="stock.group_stock_multi_locations" />
</group>
<group>
<field name="deadline"/>
<field name="priority" widget="priority"/>
<field name="origin_split_rma_id"
attrs="{'invisible': [('origin_split_rma_id', '=', False)]}"/>
</group>
</group>
<group>
<label for="description" class="oe_edit_only"/>
<field name="description" colspan="4" nolabel="1"/>
</group>
</page>
</notebook>
<field name="sent" invisible="1"/>
<field name="reception_move_id" invisible="1"/>
<field name="refund_id" invisible="1"/>
<field name="can_be_refunded" invisible="1"/>
<field name="can_be_returned" invisible="1"/>
<field name="can_be_replaced" invisible="1"/>
<field name="can_be_split" invisible="1"/>
<field name="can_be_locked" invisible="1"/>
<field name="commercial_partner_id" invisible="1"/>
<field name="remaining_qty" invisible="1"/>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids" widget="mail_followers"/>
<field name="activity_ids" widget="mail_activity"/>
<field name="message_ids" widget="mail_thread"/>
</div>
</form>
</field>
</record>
<record id="rma_view_pivot" model="ir.ui.view">
<field name="name">rma.pivot</field>
<field name="model">rma</field>
<field name="arch" type="xml">
<pivot>
<field name="date" type="row"/>
<field name="product_uom_qty" type="measure"/>
<field name="delivered_qty" type="measure"/>
</pivot>
</field>
</record>
<record id="rma_view_calendar" model="ir.ui.view">
<field name="name">rma.calendar</field>
<field name="model">rma</field>
<field name="arch" type="xml">
<calendar date_start="date" mode="month" color="state" quick_add="False">
<field name="name"/>
<field name="partner_id"/>
<field name="product_id"/>
<field name="product_uom_qty" widget="monetary"/>
</calendar>
</field>
</record>
<record id="rma_refund_action_server" model="ir.actions.server">
<field name="name">To Refund</field>
<field name="model_id" ref="model_rma"/>
<field name="binding_model_id" ref="model_rma"/>
<field name="state">code</field>
<field name="code">records.action_refund()</field>
</record>
<record id="rma_action" model="ir.actions.act_window">
<field name="name">RMA</field>
<field name="res_model">rma</field>
<field name="view_type">form</field>
<field name="view_mode">tree,form,pivot,calendar</field>
<field name="context">{"search_default_user_id": uid}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Click to add a new RMA.
</p>
</field>
</record>
<record id="rma.rma_orders_menu" model="ir.ui.menu">
<field name="action" ref="rma_action"/>
</record>
</odoo>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2020 Tecnativa - Ernesto Tejeda
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<odoo>
<record id="view_picking_form" model="ir.ui.view">
<field name="name">stock.picking.form</field>
<field name="model">stock.picking</field>
<field name="inherit_id" ref="stock.view_picking_form"/>
<field name="arch" type="xml">
<div name="button_box">
<button name="action_view_rma"
type="object"
class="oe_stat_button"
icon="fa-reply"
attrs="{'invisible': [('rma_count', '=', 0)]}">
<field name="rma_count"
widget="statinfo"
string="RMA"/>
</button>
</div>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_warehouse_inherit_mrp" model="ir.ui.view">
<field name="name">Stock Warehouse Inherit MRP</field>
<field name="model">stock.warehouse</field>
<field name="inherit_id" ref="stock.view_warehouse"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='wh_output_stock_loc_id']/..">
<field name="rma_loc_id"/>
</xpath>
<xpath expr="//field[@name='out_type_id']/..">
<field name="rma_in_type_id"/>
<field name="rma_out_type_id"/>
</xpath>
</field>
</record>
</odoo>

5
rma/wizard/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from . import rma_delivery
from . import rma_split
from . import stock_picking_return

102
rma/wizard/rma_delivery.py Normal file
View File

@@ -0,0 +1,102 @@
# Copyright 2020 Tecnativa - Ernesto Tejeda
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
import odoo.addons.decimal_precision as dp
class RmaReDeliveryWizard(models.TransientModel):
_name = 'rma.delivery.wizard'
_description = 'RMA Delivery Wizard'
rma_count = fields.Integer()
type = fields.Selection(
selection=[
('replace', 'Replace'),
('return', 'Return to customer'),
],
string="Type",
required=True,
)
product_id = fields.Many2one(
comodel_name="product.product",
string="Replace Product",
)
product_uom_qty = fields.Float(
string='Product qty',
digits=dp.get_precision('Product Unit of Measure'),
)
product_uom = fields.Many2one(
comodel_name="uom.uom",
string="Unit of measure",
)
scheduled_date = fields.Datetime(
required=True,
default=fields.Datetime.now(),
)
warehouse_id = fields.Many2one(
comodel_name="stock.warehouse",
string='Warehouse',
required=True,
)
@api.constrains('product_uom_qty')
def _check_product_uom_qty(self):
self.ensure_one()
rma_ids = self.env.context.get('active_ids')
if len(rma_ids) == 1 and self.product_uom_qty <= 0:
raise ValidationError(_('Quantity must be greater than 0.'))
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
rma_ids = self.env.context.get('active_ids')
rma = self.env['rma'].browse(rma_ids)
warehouse_id = self.env['stock.warehouse'].search(
[('company_id', '=', rma[0].company_id.id)], limit=1).id
delivery_type = self.env.context.get('rma_delivery_type')
product_id = False
if len(rma) == 1 and delivery_type == 'return':
product_id = rma.product_id.id
product_uom_qty = 0.0
if len(rma) == 1 and rma.remaining_qty > 0.0:
product_uom_qty = rma.remaining_qty
res.update(
rma_count=len(rma),
warehouse_id=warehouse_id,
type=delivery_type,
product_id=product_id,
product_uom_qty=product_uom_qty,
)
return res
@api.onchange("product_id")
def _onchange_product_id(self):
domain_product_uom = []
if self.product_id:
domain_product_uom = [
('category_id', '=', self.product_id.uom_id.category_id.id)
]
if (not self.product_uom
or self.product_id.uom_id.id != self.product_uom.id):
self.product_uom = self.product_id.uom_id
return {'domain': {'product_uom': domain_product_uom}}
def action_deliver(self):
self.ensure_one()
rma_ids = self.env.context.get('active_ids')
rma = self.env['rma'].browse(rma_ids)
if self.type == 'replace':
rma.create_replace(
self.scheduled_date,
self.warehouse_id,
self.product_id,
self.product_uom_qty,
self.product_uom,
)
elif self.type == 'return':
qty = uom = None
if self.rma_count == 1:
qty, uom = self.product_uom_qty, self.product_uom
rma.create_return(self.scheduled_date, qty, uom)

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2020 Tecnativa - Ernesto Tejeda
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<odoo>
<record id="rma_redelivery_wizard_view_form" model="ir.ui.view">
<field name="name">rma.delivery.wizard.form</field>
<field name="model">rma.delivery.wizard</field>
<field name="arch" type="xml">
<form>
<group>
<group>
<field name="scheduled_date"/>
<field name="warehouse_id"
attrs="{'invisible': [('type', '!=', 'replace')]}"/>
</group>
<group>
<field name="product_id"
attrs="{'invisible': ['|', ('type', '!=', 'replace'), ('rma_count', '>', 1)], 'required': [('type', '=', 'replace'), ('rma_count', '=', 1)]}"/>
<label for="product_uom_qty"
attrs="{'invisible': [('rma_count', '>', 1)]}"/>
<div class="o_row"
attrs="{'invisible': [('rma_count', '>', 1)]}">
<field name="product_uom_qty"
attrs="{'required': [('rma_count', '=', 1)]}"/>
<field name="product_uom"
groups="uom.group_uom"
attrs="{'required': [('rma_count', '=', 1)]}"/>
</div>
</group>
</group>
<field name="rma_count" invisible="1"/>
<field name="type" invisible="1"/>
<footer>
<button name="action_deliver" string="Deliver" type="object" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel" />
</footer>
</form>
</field>
</record>
<act_window id="rma_delivery_wizard_action"
name="Return to customer"
src_model="rma"
res_model="rma.delivery.wizard"
view_type="form"
view_mode="form"
key2="client_action_multi"
target="new"
multi="True"
context="{'rma_delivery_type': 'return'}"/>
</odoo>

70
rma/wizard/rma_split.py Normal file
View File

@@ -0,0 +1,70 @@
# Copyright 2020 Tecnativa - Ernesto Tejeda
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import _, api, fields, models
import odoo.addons.decimal_precision as dp
class RmaReSplitWizard(models.TransientModel):
_name = 'rma.split.wizard'
_description = 'RMA Split Wizard'
rma_id = fields.Many2one(
comodel_name='rma',
string='RMA',
)
product_uom_qty = fields.Float(
string='Quantity to extract',
digits=dp.get_precision('Product Unit of Measure'),
required=True,
help="Quantity to extract to a new RMA."
)
product_uom = fields.Many2one(
comodel_name='uom.uom',
string='Unit of measure',
required=True,
)
_sql_constraints = [
(
'check_product_uom_qty_positive',
'CHECK(product_uom_qty > 0)',
'Quantity must be greater than 0.'
),
]
@api.model
def fields_get(self, allfields=None, attributes=None):
res = super().fields_get(allfields, attributes=attributes)
rma_id = self.env.context.get('active_id')
rma = self.env['rma'].browse(rma_id)
res['product_uom']['domain'] = [
('category_id', '=', rma.product_uom.category_id.id)
]
return res
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
rma_id = self.env.context.get('active_id')
rma = self.env['rma'].browse(rma_id)
res.update(
rma_id=rma.id,
product_uom_qty=rma.remaining_qty,
product_uom=rma.product_uom.id,
)
return res
def action_split(self):
self.ensure_one()
extracted_rma = self.rma_id.extract_quantity(
self.product_uom_qty, self.product_uom)
return {
'name': _('Extracted RMA'),
'type': 'ir.actions.act_window',
'view_type': 'form',
'view_mode': 'form',
'res_model': 'rma',
'views': [(self.env.ref('rma.rma_view_form').id, 'form')],
'res_id': extracted_rma.id,
}

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2020 Tecnativa - Ernesto Tejeda
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<odoo>
<record id="rma_split_wizard_view_form2" model="ir.ui.view">
<field name="name">rma.split.wizard.form</field>
<field name="model">rma.split.wizard</field>
<field name="arch" type="xml">
<form>
<group>
<group>
<label for="product_uom_qty"/>
<div class="o_row">
<field name="product_uom_qty"/>
<field name="product_uom" groups="uom.group_uom"/>
</div>
</group>
</group>
<footer>
<button name="action_split" string="Split" type="object" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel" />
</footer>
</form>
</field>
</record>
<record id="rma_split_wizard_action" model="ir.actions.act_window">
<field name="name">Split RMA</field>
<field name="res_model">rma.split.wizard</field>
<field name="view_type">form</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,83 @@
# Copyright 2020 Tecnativa - Ernesto Tejeda
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class ReturnPicking(models.TransientModel):
_inherit = 'stock.return.picking'
create_rma = fields.Boolean(
string="Create RMAs"
)
picking_type_code = fields.Selection(
selection=[
('incoming', 'Vendors'),
('outgoing', 'Customers'),
('internal', 'Internal'),
],
related='picking_id.picking_type_id.code',
store=True,
readonly=True,
)
@api.onchange("create_rma")
def _onchange_create_rma(self):
if self.create_rma:
warehouse = self.picking_id.picking_type_id.warehouse_id
self.location_id = warehouse.rma_loc_id.id
rma_loc = warehouse.search([]).mapped('rma_loc_id')
rma_loc_domain = [('id', 'child_of', rma_loc.ids)]
else:
self.location_id = self.default_get(['location_id'])['location_id']
rma_loc_domain = [
'|',
('id', '=', self.picking_id.location_id.id),
('return_location', '=', True),
]
return {'domain': {'location_id': rma_loc_domain}}
def create_returns(self):
""" Override create_returns method for creating one or more
'confirmed' RMAs after return a delivery picking in case
'Create RMAs' checkbox is checked in this wizard.
New RMAs will be linked to the delivery picking as the origin
delivery and also RMAs will be linked to the returned picking
as the 'Receipt'.
"""
if self.create_rma:
# set_rma_picking_type is to override the copy() method of stock
# picking and change the default picking type to rma picking type
self_with_context = self.with_context(set_rma_picking_type=True)
res = super(ReturnPicking, self_with_context).create_returns()
partner = self.picking_id.partner_id
if not partner:
raise ValidationError(_(
"You must specify the 'Customer' in the "
"'Stock Picking' from which RMAs will be created"))
picking = self.picking_id
returned_picking = self.env['stock.picking'].browse(res['res_id'])
if hasattr(picking, 'sale_id') and picking.sale_id:
partner_invoice_id = picking.sale_id.partner_invoice_id.id
else:
partner_invoice_id = partner.address_get(
['invoice']).get('invoice', False),
for move in returned_picking.move_lines:
self.env['rma'].create({
'partner_id': partner.id,
'partner_invoice_id': partner_invoice_id,
'origin': picking.name,
'picking_id': picking.id,
'move_id': move.origin_returned_move_id.id,
'product_id': move.origin_returned_move_id.product_id.id,
'product_uom_qty': move.product_uom_qty,
'product_uom': move.product_uom.id,
'reception_move_id': move.id,
'company_id': move.company_id.id,
'location_id': move.location_dest_id.id,
'state': 'confirmed',
})
return res
else:
return super().create_returns()

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2020 Tecnativa - Ernesto Tejeda
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<odoo>
<record id="view_stock_return_picking_form" model="ir.ui.view">
<field name="name">Return lines inherit RMA</field>
<field name="model">stock.return.picking</field>
<field name="inherit_id" ref="stock.view_stock_return_picking_form" />
<field name="arch" type="xml">
<xpath expr="//group[.//field[@name='product_return_moves']]" position="before">
<group name="group_rma">
<field name="create_rma"
attrs="{'invisible': [('picking_type_code', '!=', 'outgoing')]}"/>
<field name="picking_id" invisible="1"/>
<field name="picking_type_code" invisible="1"/>
</group>
</xpath>
</field>
</record>
</odoo>