From 9afed572a21bec5516de6b7f03950bb1de10f56b Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Fri, 3 Jul 2020 08:53:24 -0700 Subject: [PATCH] [MOV] rma: from Hibou Suite Enterprise for 13.0 --- rma/__init__.py | 5 + rma/__manifest__.py | 31 ++ rma/controllers/__init__.py | 4 + rma/controllers/main.py | 51 ++ rma/controllers/portal.py | 137 +++++ rma/data/cron_data.xml | 17 + rma/data/ir_sequence_data.xml | 14 + rma/demo/rma_demo.xml | 45 ++ rma/migrations/13.0.1.2.0/post-migration.py | 11 + rma/models/__init__.py | 5 + rma/models/account.py | 13 + rma/models/rma.py | 582 ++++++++++++++++++++ rma/models/stock_picking.py | 26 + rma/security/ir.model.access.csv | 10 + rma/security/rma_security.xml | 51 ++ rma/tests/__init__.py | 3 + rma/tests/test_rma.py | 246 +++++++++ rma/views/account_views.xml | 17 + rma/views/portal_templates.xml | 267 +++++++++ rma/views/rma_views.xml | 286 ++++++++++ rma/views/stock_picking_views.xml | 13 + rma/wizard/__init__.py | 3 + rma/wizard/rma_lines.py | 59 ++ rma/wizard/rma_lines_views.xml | 40 ++ 24 files changed, 1936 insertions(+) create mode 100644 rma/__init__.py create mode 100644 rma/__manifest__.py create mode 100644 rma/controllers/__init__.py create mode 100644 rma/controllers/main.py create mode 100644 rma/controllers/portal.py create mode 100644 rma/data/cron_data.xml create mode 100644 rma/data/ir_sequence_data.xml create mode 100644 rma/demo/rma_demo.xml create mode 100644 rma/migrations/13.0.1.2.0/post-migration.py create mode 100644 rma/models/__init__.py create mode 100644 rma/models/account.py create mode 100644 rma/models/rma.py create mode 100644 rma/models/stock_picking.py create mode 100644 rma/security/ir.model.access.csv create mode 100644 rma/security/rma_security.xml create mode 100644 rma/tests/__init__.py create mode 100644 rma/tests/test_rma.py create mode 100644 rma/views/account_views.xml create mode 100644 rma/views/portal_templates.xml create mode 100644 rma/views/rma_views.xml create mode 100644 rma/views/stock_picking_views.xml create mode 100644 rma/wizard/__init__.py create mode 100644 rma/wizard/rma_lines.py create mode 100644 rma/wizard/rma_lines_views.xml diff --git a/rma/__init__.py b/rma/__init__.py new file mode 100644 index 00000000..d62bb54d --- /dev/null +++ b/rma/__init__.py @@ -0,0 +1,5 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import controllers +from . import models +from . import wizard diff --git a/rma/__manifest__.py b/rma/__manifest__.py new file mode 100644 index 00000000..a1de4a07 --- /dev/null +++ b/rma/__manifest__.py @@ -0,0 +1,31 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +{ + 'name': 'Hibou RMAs', + 'version': '13.0.1.2.0', + 'category': 'Warehouse', + 'author': 'Hibou Corp.', + 'license': 'OPL-1', + 'website': 'https://hibou.io/', + 'depends': [ + 'stock', + 'delivery', + ], + 'demo': [ + 'demo/rma_demo.xml', + ], + 'data': [ + 'data/cron_data.xml', + 'data/ir_sequence_data.xml', + 'security/ir.model.access.csv', + 'security/rma_security.xml', + 'views/account_views.xml', + 'views/portal_templates.xml', + 'views/rma_views.xml', + 'views/stock_picking_views.xml', + 'wizard/rma_lines_views.xml', + ], + 'installable': True, + 'application': True, + 'auto_install': False, + } diff --git a/rma/controllers/__init__.py b/rma/controllers/__init__.py new file mode 100644 index 00000000..b4e3c98a --- /dev/null +++ b/rma/controllers/__init__.py @@ -0,0 +1,4 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import main +from . import portal diff --git a/rma/controllers/main.py b/rma/controllers/main.py new file mode 100644 index 00000000..4a87c318 --- /dev/null +++ b/rma/controllers/main.py @@ -0,0 +1,51 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import http, exceptions +from base64 import b64decode +import hmac +from hashlib import sha256 +from datetime import datetime +from time import mktime + + +def create_hmac(secret, a_attchment_id, e_expires): + return hmac.new(secret.encode(), str(str(a_attchment_id) + str(e_expires)).encode(), sha256).hexdigest() + + +def check_hmac(secret, hash_, a_attachment_id, e_expires): + myh = hmac.new(secret.encode(), str(str(a_attachment_id) + str(e_expires)).encode(), sha256) + return hmac.compare_digest(str(hash_), myh.hexdigest()) + + +class RMAController(http.Controller): + + @http.route(['/rma_label'], type='http', auth='public', website=True) + def index(self, *args, **request): + a_attachment_id = request.get('a') + e_expires = request.get('e') + hash = request.get('h') + + if not all([a_attachment_id, e_expires, hash]): + return http.Response('Invalid Request', status=400) + + now = datetime.utcnow() + now = int(mktime(now.timetuple())) + + config = http.request.env['ir.config_parameter'].sudo() + secret = str(config.search([('key', '=', 'database.secret')], limit=1).value) + + if not check_hmac(secret, hash, a_attachment_id, e_expires): + return http.Response('Invalid Request', status=400) + + if now > int(e_expires): + return http.Response('Expired', status=404) + + attachment = http.request.env['ir.attachment'].sudo().search([('id', '=', int(a_attachment_id))], limit=1) + if attachment: + data = attachment.datas + filename = attachment.name + mimetype = attachment.mimetype + return http.request.make_response(b64decode(data), [ + ('Content-Type', mimetype), + ('Content-Disposition', 'attachment; filename="' + filename + '"')]) + return http.Response('Invalid Attachment', status=404) diff --git a/rma/controllers/portal.py b/rma/controllers/portal.py new file mode 100644 index 00000000..f88c499c --- /dev/null +++ b/rma/controllers/portal.py @@ -0,0 +1,137 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from collections import OrderedDict + +from odoo import http, fields +from odoo.exceptions import AccessError, MissingError, ValidationError +from odoo.http import request +from odoo.tools.translate import _ +from odoo.addons.portal.controllers.portal import pager as portal_pager, CustomerPortal + + +class CustomerPortal(CustomerPortal): + + def _prepare_portal_layout_values(self): + values = super(CustomerPortal, self)._prepare_portal_layout_values() + values['rma_count'] = request.env['rma.rma'].search_count([ + ]) + return values + + def _rma_get_page_view_values(self, rma, access_token, **kwargs): + values = { + 'rma': rma, + 'current_date': fields.Datetime.now(), + } + return self._get_page_view_values(rma, access_token, values, 'my_rma_history', True, **kwargs) + + @http.route(['/my/rma', '/my/rma/page/'], type='http', auth="user", website=True) + def portal_my_rma(self, page=1, date_begin=None, date_end=None, sortby=None, filterby=None, **kw): + values = self._prepare_portal_layout_values() + RMA = request.env['rma.rma'] + + domain = [] + fields = ['name', 'create_date'] + + archive_groups = self._get_archive_groups('rma.rma', domain, fields) + if date_begin and date_end: + domain += [('create_date', '>', date_begin), ('create_date', '<=', date_end)] + + searchbar_sortings = { + 'date': {'label': _('Newest'), 'order': 'create_date desc, id desc'}, + 'name': {'label': _('Name'), 'order': 'name asc, id asc'}, + } + # default sort by value + if not sortby: + sortby = 'date' + order = searchbar_sortings[sortby]['order'] + + searchbar_filters = { + 'all': {'label': _('All'), 'domain': [('state', 'in', ['draft', 'confirmed', 'done', 'cancel'])]}, + 'draft': {'label': _('Draft'), 'domain': [('state', '=', 'draft')]}, + 'purchase': {'label': _('Confirmed'), 'domain': [('state', '=', 'confirmed')]}, + 'cancel': {'label': _('Cancelled'), 'domain': [('state', '=', 'cancel')]}, + 'done': {'label': _('Done'), 'domain': [('state', '=', 'done')]}, + } + # default filter by value + if not filterby: + filterby = 'all' + domain += searchbar_filters[filterby]['domain'] + + # count for pager + rma_count = RMA.search_count(domain) + # make pager + pager = portal_pager( + url="/my/rma", + url_args={'date_begin': date_begin, 'date_end': date_end}, + total=rma_count, + page=page, + step=self._items_per_page + ) + # search the rmas to display, according to the pager data + rmas = RMA.search( + domain, + order=order, + limit=self._items_per_page, + offset=pager['offset'] + ) + request.session['my_rma_history'] = rmas.ids[:100] + + rma_templates = request.env['rma.template'].sudo().search([('portal_ok', '=', True)]) + + values.update({ + 'request': request, + 'date': date_begin, + 'rma_list': rmas, + 'rma_templates': rma_templates, + 'page_name': 'rma', + 'pager': pager, + 'archive_groups': archive_groups, + 'searchbar_sortings': searchbar_sortings, + 'sortby': sortby, + 'searchbar_filters': OrderedDict(sorted(searchbar_filters.items())), + 'filterby': filterby, + 'default_url': '/my/rma', + }) + return request.render("rma.portal_my_rma", values) + + @http.route(['/my/rma/'], type='http', auth="public", website=True) + def portal_my_rma_rma(self, rma_id=None, access_token=None, **kw): + try: + rma_sudo = self._document_check_access('rma.rma', rma_id, access_token=access_token) + except (AccessError, MissingError): + return request.redirect('/my') + + values = self._rma_get_page_view_values(rma_sudo, access_token, **kw) + return request.render("rma.portal_my_rma_rma", values) + + @http.route(['/my/rma/new/', + '/my/rma/new//res/'], type='http', auth='public', website=True) + def portal_rma_new(self, rma_template_id=None, res_id=None, **kw): + if request.env.user.has_group('base.group_public'): + return request.redirect('/my') + + rma_template = request.env['rma.template'].sudo().browse(rma_template_id) + if not rma_template.exists() or not rma_template.portal_ok: + return request.redirect('/my') + + error = None + try: + if res_id: + # Even if res_id is not important to the RMA type, some sort of number + # should be submitted to indicate that a selection has occurred. + rma = rma_template._portal_try_create(request.env.user, res_id, **kw) + if rma: + return request.redirect('/my/rma/' + str(rma.id)) + except ValidationError as e: + error = e.name + + template_name = rma_template._portal_template(res_id=res_id) + if not template_name: + return request.redirect('/my') + values = rma_template._portal_values(request.env.user, res_id=res_id) + values.update({ + 'request': request, + 'error': error, + 'current_date': fields.Datetime.now(), + }) + return request.render(template_name, values) diff --git a/rma/data/cron_data.xml b/rma/data/cron_data.xml new file mode 100644 index 00000000..4843bf35 --- /dev/null +++ b/rma/data/cron_data.xml @@ -0,0 +1,17 @@ + + + + + + RMA Expiration + 1 + days + -1 + + + code + model._rma_expire() + + + + \ No newline at end of file diff --git a/rma/data/ir_sequence_data.xml b/rma/data/ir_sequence_data.xml new file mode 100644 index 00000000..b021bdee --- /dev/null +++ b/rma/data/ir_sequence_data.xml @@ -0,0 +1,14 @@ + + + + + + RMA + rma.rma + RMA + 3 + + + + + diff --git a/rma/demo/rma_demo.xml b/rma/demo/rma_demo.xml new file mode 100644 index 00000000..17f405e0 --- /dev/null +++ b/rma/demo/rma_demo.xml @@ -0,0 +1,45 @@ + + + + Missing Item + + + + + + make_to_stock + + + + RMA Returns + standard + WH/RMA/ + + + + + + RMA Receipts + WH/RMA + + + incoming + + + + + + + + + Picking Return + stock_picking + + + + + + make_to_stock + + + \ No newline at end of file diff --git a/rma/migrations/13.0.1.2.0/post-migration.py b/rma/migrations/13.0.1.2.0/post-migration.py new file mode 100644 index 00000000..d00872a4 --- /dev/null +++ b/rma/migrations/13.0.1.2.0/post-migration.py @@ -0,0 +1,11 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +def migrate(cr, installed_version): + # Provide defaults for RMAs that were created before these fields existed. + cr.execute(''' +UPDATE rma_rma as r +SET initial_in_picking_carrier_id = t.in_carrier_id , + initial_out_picking_carrier_id = t.out_carrier_id +FROM rma_template as t +WHERE r.template_id = t.id + ''') diff --git a/rma/models/__init__.py b/rma/models/__init__.py new file mode 100644 index 00000000..4577f1ec --- /dev/null +++ b/rma/models/__init__.py @@ -0,0 +1,5 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import account +from . import rma +from . import stock_picking diff --git a/rma/models/account.py b/rma/models/account.py new file mode 100644 index 00000000..53d634b6 --- /dev/null +++ b/rma/models/account.py @@ -0,0 +1,13 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import fields, models + + +class AccountMove(models.Model): + _inherit = 'account.move' + + rma_ids = fields.Many2many('rma.rma', + 'rma_invoice_rel', + 'invoice_id', + 'rma_id', + string='RMAs') diff --git a/rma/models/rma.py b/rma/models/rma.py new file mode 100644 index 00000000..d2e9847e --- /dev/null +++ b/rma/models/rma.py @@ -0,0 +1,582 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError, ValidationError +from ..controllers.main import create_hmac +from datetime import timedelta, datetime +from time import mktime + + +class RMATemplate(models.Model): + _name = 'rma.template' + _description = 'RMA Template' + + name = fields.Char(string='Name') + usage = fields.Selection([ + ('stock_picking', 'Stock Picking'), + ], string='Applies To') + description = fields.Html(string='Internal Instructions') + customer_description = fields.Html(string='Customer Instructions') + valid_days = fields.Integer(string='Expire in Days') + automatic_expire = fields.Boolean('Automatic Expire', + help='RMAs with this template will automatically ' + 'expire when past their expiration date.') + invoice_done = fields.Boolean(string='Invoice on Completion') + + create_in_picking = fields.Boolean(string='Create Inbound Picking') + create_out_picking = fields.Boolean(string='Create Outbound Picking') + + in_type_id = fields.Many2one('stock.picking.type', string='Inbound Picking Type') + out_type_id = fields.Many2one('stock.picking.type', string='Outbound Picking Type') + + in_location_id = fields.Many2one('stock.location', string='Inbound Source Location') + in_location_dest_id = fields.Many2one('stock.location', string='Inbound Destination Location') + in_carrier_id = fields.Many2one('delivery.carrier', string='Inbound Carrier') + in_require_return = fields.Boolean(string='Inbound Require return of picking') + in_procure_method = fields.Selection([ + ('make_to_stock', 'Take from Stock'), + ('make_to_order', 'Apply Procurements') + ], string="Inbound Procurement Method", default='make_to_stock') + in_to_refund = fields.Boolean(string='Inbound Mark Refund') + + out_location_id = fields.Many2one('stock.location', string='Outbound Source Location') + out_location_dest_id = fields.Many2one('stock.location', string='Outbound Destination Location') + out_carrier_id = fields.Many2one('delivery.carrier', string='Outbound Carrier') + out_require_return = fields.Boolean(string='Outbound Require picking to duplicate') + out_procure_method = fields.Selection([ + ('make_to_stock', 'Take from Stock'), + ('make_to_order', 'Apply Procurements') + ], string="Outbound Procurement Method", default='make_to_stock') + out_to_refund = fields.Boolean(string='Outbound Mark Refund') + portal_ok = fields.Boolean(string='Allow on Portal') + company_id = fields.Many2one('res.company', 'Company') + responsible_user_ids = fields.Many2many('res.users', string='Responsible Users', + help='Users that get activities when creating RMA.') + + def _portal_try_create(self, request_user, res_id, **kw): + if self.usage == 'stock_picking': + prefix = 'move_' + move_map = {int(key[len(prefix):]): float(kw[key]) for key in kw if key.find(prefix) == 0 and kw[key]} + if move_map: + picking = self.env['stock.picking'].browse(res_id) + if picking.partner_id != request_user.partner_id: + raise ValidationError('Invalid user for picking.') + lines = [] + for move_id, qty in move_map.items(): + move = picking.move_lines.filtered(lambda l: l.id == move_id) + if move: + if not qty: + continue + if qty < 0.0 or move.quantity_done < qty: + raise ValidationError('Invalid quantity.') + lines.append((0, 0, { + 'product_id': move.product_id.id, + 'product_uom_id': move.product_uom.id, + 'product_uom_qty': qty, + })) + if not lines: + raise ValidationError('Missing product quantity.') + rma = self.env['rma.rma'].create({ + 'name': _('New'), + 'stock_picking_id': picking.id, + 'template_id': self.id, + 'partner_id': request_user.partner_id.id, + 'partner_shipping_id': request_user.partner_id.id, + 'lines': lines, + }) + return rma + + def _portal_template(self, res_id=None): + if self.usage == 'stock_picking': + return 'rma.portal_new_stock_picking' + + def _portal_values(self, request_user, res_id=None): + if self.usage == 'stock_picking': + picking = None + pickings = None + if res_id: + picking = self.env['stock.picking'].browse(res_id) + if picking.partner_id != request_user.partner_id: + picking = None + else: + pickings = self.env['stock.picking'].search([('partner_id', '=', request_user.partner_id.id)], limit=100) + return { + 'rma_template': self, + 'pickings': pickings, + 'picking': picking, + } + + def _values_for_in_picking(self, rma): + return { + 'origin': rma.name, + 'partner_id': rma.partner_shipping_id.id, + 'picking_type_id': self.in_type_id.id, + 'location_id': self.in_location_id.id, + 'location_dest_id': self.in_location_dest_id.id, + 'carrier_id': rma.initial_in_picking_carrier_id.id, + 'move_lines': [(0, None, { + 'name': rma.name + ' IN: ' + l.product_id.name_get()[0][1], + 'product_id': l.product_id.id, + 'product_uom_qty': l.product_uom_qty, + 'product_uom': l.product_uom_id.id, + 'procure_method': self.in_procure_method, + 'to_refund': self.in_to_refund, + }) for l in rma.lines.filtered(lambda l: l.product_id.type != 'service')], + } + + def _values_for_out_picking(self, rma): + return { + 'origin': rma.name, + 'partner_id': rma.partner_shipping_id.id, + 'picking_type_id': self.out_type_id.id, + 'location_id': self.out_location_id.id, + 'location_dest_id': self.out_location_dest_id.id, + 'carrier_id': rma.initial_out_picking_carrier_id.id, + 'move_lines': [(0, None, { + 'name': rma.name + ' OUT: ' + l.product_id.name_get()[0][1], + 'product_id': l.product_id.id, + 'product_uom_qty': l.product_uom_qty, + 'product_uom': l.product_uom_id.id, + 'procure_method': self.out_procure_method, + 'to_refund': self.out_to_refund, + }) for l in rma.lines.filtered(lambda l: l.product_id.type != 'service')], + } + + def _schedule_responsible_activities(self, rma): + model_id = self.env['ir.model']._get(rma._name).id + activity_to_write = [] + for user in self.responsible_user_ids: + if rma.with_user(user).check_access_rights('read', raise_exception=False): + activity_to_write.append((0, 0, { + 'res_id': rma.id, + 'res_model_id': model_id, + 'summary': 'Review New RMA', + 'activity_type_id': False, + 'user_id': user.id, + })) + if activity_to_write: + rma.write({ + 'activity_ids': activity_to_write, + }) + + def _rma_expire(self): + templates = self.sudo().search([('automatic_expire', '=', True)]) + if not templates: + return True + rmas = self.env['rma.rma'].sudo().search([ + ('template_id', 'in', templates.ids), + ('state', 'in', ('draft', 'confirmed',)), + ('validity_date', '<', fields.Datetime.now()) + ]) + if rmas: + return rmas._action_expire() + return True + + +class RMATag(models.Model): + _name = "rma.tag" + _description = "RMA Tag" + + name = fields.Char('Tag Name', required=True) + color = fields.Integer('Color Index') + + _sql_constraints = [ + ('name_uniq', 'unique (name)', "Tag name already exists !"), + ] + + +class RMA(models.Model): + _name = 'rma.rma' + _inherit = ['portal.mixin', 'mail.thread', 'mail.activity.mixin'] + _description = 'RMA' + _order = 'id desc' + _mail_post_access = 'read' + + name = fields.Char(string='Number', copy=False) + state = fields.Selection([ + ('draft', 'New'), + ('confirmed', 'Confirmed'), + ('done', 'Done'), + ('expired', 'Expired'), + ('cancel', 'Cancelled'), + ], string='State', default='draft', copy=False) + company_id = fields.Many2one('res.company', 'Company') + template_id = fields.Many2one('rma.template', string='Type', required=True) + template_create_in_picking = fields.Boolean(related='template_id.create_in_picking') + template_create_out_picking = fields.Boolean(related='template_id.create_out_picking') + + stock_picking_id = fields.Many2one('stock.picking', string='Stock Picking') + stock_picking_rma_count = fields.Integer('Number of RMAs for this Picking', compute='_compute_stock_picking_rma_count') + partner_id = fields.Many2one('res.partner', string='Partner') + partner_shipping_id = fields.Many2one('res.partner', string='Shipping') + lines = fields.One2many('rma.line', 'rma_id', string='Lines') + tag_ids = fields.Many2many('rma.tag', 'rma_tags_rel', 'rma_id', 'tag_id', string='Tags') + description = fields.Html(string='Internal Instructions', related='template_id.description') + customer_description = fields.Html(string='Customer Instructions', related='template_id.customer_description') + template_usage = fields.Selection(string='Template Usage', related='template_id.usage') + validity_date = fields.Datetime(string='Expiration Date') + invoice_ids = fields.Many2many('account.move', + 'rma_invoice_rel', + 'rma_id', + 'invoice_id', + string='Invoices') + + initial_in_picking_carrier_id = fields.Many2one('delivery.carrier', string='In Delivery Method') + initial_out_picking_carrier_id = fields.Many2one('delivery.carrier', string='Out Delivery Method') + + in_picking_id = fields.Many2one('stock.picking', string='Inbound Picking', copy=False) + out_picking_id = fields.Many2one('stock.picking', string='Outbound Picking', copy=False) + + in_picking_state = fields.Selection(string='In Picking State', related='in_picking_id.state') + out_picking_state = fields.Selection(string='Out Picking State', related='out_picking_id.state') + + in_picking_carrier_id = fields.Many2one('delivery.carrier', related='in_picking_id.carrier_id', readonly=False) + out_picking_carrier_id = fields.Many2one('delivery.carrier', related='out_picking_id.carrier_id', readonly=False) + + in_carrier_tracking_ref = fields.Char(related='in_picking_id.carrier_tracking_ref') + in_label_url = fields.Char(compute='_compute_in_label_url') + out_carrier_tracking_ref = fields.Char(related='out_picking_id.carrier_tracking_ref') + + + def _compute_access_url(self): + super(RMA, self)._compute_access_url() + for rma in self: + rma.access_url = '/my/rma/%s' % (rma.id) + + @api.onchange('template_id') + def _onchange_template_id(self): + for rma in self: + rma.initial_in_picking_carrier_id = rma.template_id.in_carrier_id + rma.initial_out_picking_carrier_id = rma.template_id.out_carrier_id + + @api.onchange('template_usage') + def _onchange_template_usage(self): + now = datetime.now() + for rma in self: + if rma.template_id.valid_days: + rma.validity_date = now + timedelta(days=rma.template_id.valid_days) + if rma.template_usage != 'stock_picking': + rma.stock_picking_id = False + + @api.onchange('stock_picking_id') + def _onchange_stock_picking_id(self): + for rma in self.filtered(lambda rma: rma.stock_picking_id): + rma.partner_id = rma.stock_picking_id.partner_id + rma.partner_shipping_id = rma.stock_picking_id.partner_id + + @api.onchange('in_carrier_tracking_ref', 'validity_date') + def _compute_in_label_url(self): + config = self.env['ir.config_parameter'].sudo() + secret = config.search([('key', '=', 'database.secret')], limit=1) + secret = str(secret.value) if secret else '' + base_url = config.search([('key', '=', 'web.base.url')], limit=1) + base_url = str(base_url.value) if base_url else '' + for rma in self: + if not rma.in_picking_id: + rma.in_label_url = '' + continue + if rma.validity_date: + e_expires = int(mktime(fields.Datetime.from_string(rma.validity_date).timetuple())) + else: + year = datetime.now() + timedelta(days=365) + e_expires = int(mktime(year.timetuple())) + attachment = self.env['ir.attachment'].search([ + ('res_model', '=', 'stock.picking'), + ('res_id', '=', rma.in_picking_id.id), + ('name', 'like', 'Label%')], limit=1) + if not attachment: + rma.in_label_url = '' + continue + rma.in_label_url = base_url + '/rma_label?a=' + \ + str(attachment.id) + '&e=' + str(e_expires) + \ + '&h=' + create_hmac(secret, attachment.id, e_expires) + + @api.depends('stock_picking_id') + def _compute_stock_picking_rma_count(self): + for rma in self: + if rma.stock_picking_id: + rma_data = self.read_group([('stock_picking_id', '=', rma.stock_picking_id.id), ('state', '!=', 'cancel')], + ['stock_picking_id'], ['stock_picking_id']) + if rma_data: + rma.stock_picking_rma_count = rma_data[0]['stock_picking_id_count'] + else: + rma.stock_picking_rma_count = 0.0 + + def open_stock_picking_rmas(self): + return { + 'type': 'ir.actions.act_window', + 'name': _('Picking RMAs'), + 'res_model': 'rma.rma', + 'view_mode': 'tree,form', + 'context': {'search_default_stock_picking_id': self[0].stock_picking_id.id} + } + + def _action_expire(self): + pickings_to_cancel = self.env['stock.picking'] + rmas = self.filtered(lambda rma: rma.in_picking_state != 'done' and rma.out_picking_state != 'done') + pickings_to_cancel += rmas.filtered(lambda r: r.in_picking_id).mapped('in_picking_id') + pickings_to_cancel += rmas.filtered(lambda r: r.out_picking_id).mapped('out_picking_id') + pickings_to_cancel.action_cancel() + rmas.write({'state': 'expired'}) + return True + + @api.model + def create(self, vals): + if vals.get('name', _('New')) == _('New'): + if 'company_id' in vals: + vals['name'] = self.env['ir.sequence'].with_context(force_company=vals['company_id']).next_by_code('rma.rma') or _('New') + else: + vals['name'] = self.env['ir.sequence'].next_by_code('rma.rma') or _('New') + + # Provide defaults on create (e.g. from portal) + if vals.get('template_id'): + template = self.env['rma.template'].browse(vals.get('template_id')) + if 'initial_in_picking_carrier_id' not in vals: + vals['initial_in_picking_carrier_id'] = template.in_carrier_id.id + if 'initial_out_picking_carrier_id' not in vals: + vals['initial_out_picking_carrier_id'] = template.out_carrier_id.id + if template.valid_days and 'validity_date' not in vals: + now = datetime.now() + vals['validity_date'] = now + timedelta(days=template.valid_days) + + result = super(RMA, self).create(vals) + result.template_id._schedule_responsible_activities(result) + return result + + def action_confirm(self): + for rma in self: + in_picking_id = False + out_picking_id = False + if any((not rma.template_id, not rma.lines, not rma.partner_id, not rma.partner_shipping_id)): + raise UserError(_('You can only confirm RMAs with lines, and partner information.')) + if rma.template_id.create_in_picking: + in_picking_id = rma._create_in_picking() + if in_picking_id: + in_picking_id.action_confirm() + in_picking_id.action_assign() + if rma.template_id.create_out_picking: + out_picking_id = rma._create_out_picking() + if out_picking_id: + out_picking_id.action_confirm() + out_picking_id.action_assign() + rma.write({'state': 'confirmed', + 'in_picking_id': in_picking_id.id if in_picking_id else False, + 'out_picking_id': out_picking_id.id if out_picking_id else False}) + + def action_done(self): + for rma in self: + if rma.in_picking_id and rma.in_picking_id.state not in ('done', 'cancel'): + raise UserError(_('Inbound picking not complete or cancelled.')) + if rma.out_picking_id and rma.out_picking_id.state not in ('done', 'cancel'): + raise UserError(_('Outbound picking not complete or cancelled.')) + self.write({'state': 'done'}) + + def action_cancel(self): + for rma in self: + rma.in_picking_id.action_cancel() + rma.out_picking_id.action_cancel() + self.write({'state': 'cancel'}) + + def action_draft(self): + self.filtered(lambda l: l.state in ('cancel', 'expired')).write({ + 'state': 'draft', 'in_picking_id': False, 'out_picking_id': False}) + + def _create_in_picking(self): + if self.template_usage and hasattr(self, '_create_in_picking_' + self.template_usage): + return getattr(self, '_create_in_picking_' + self.template_usage)() + values = self.template_id._values_for_in_picking(self) + return self.env['stock.picking'].sudo().create(values) + + def _create_out_picking(self): + if self.template_usage and hasattr(self, '_create_out_picking_' + self.template_usage): + return getattr(self, '_create_out_picking_' + self.template_usage)() + values = self.template_id._values_for_out_picking(self) + return self.env['stock.picking'].sudo().create(values) + + def _find_candidate_return_picking(self, product_ids, pickings, location_id): + done_pickings = pickings.filtered(lambda p: p.state == 'done' and p.location_dest_id.id == location_id) + for p in done_pickings: + p_product_ids = p.move_lines.filtered(lambda l: l.state == 'done').mapped('product_id.id') + if set(product_ids) & set(p_product_ids) == set(product_ids): + return p + return None + + def action_in_picking_send_to_shipper(self): + for rma in self: + if rma.in_picking_id and rma.in_picking_carrier_id: + rma.in_picking_id.send_to_shipper() + + def action_add_picking_lines(self): + make_line_obj = self.env['rma.picking.make.lines'] + for rma in self: + lines = make_line_obj.create({ + 'rma_id': rma.id, + }) + action = self.env.ref('rma.action_rma_add_lines').read()[0] + action['res_id'] = lines.id + return action + + def unlink(self): + for rma in self: + if rma.state not in ('draft'): + raise UserError(_('You can not delete a non-draft RMA.')) + return super(RMA, self).unlink() + + def _picking_from_values(self, values, values_update, move_line_values_update): + values.update(values_update) + move_lines = [] + for l1, l2, vals in values['move_lines']: + vals.update(move_line_values_update) + move_lines.append((l1, l2, vals)) + values['move_lines'] = move_lines + return self.env['stock.picking'].sudo().create(values) + + def _new_in_picking(self, old_picking): + new_picking = old_picking.copy({ + 'move_lines': [], + 'picking_type_id': self.template_id.in_type_id.id, + 'state': 'draft', + 'origin': old_picking.name + ' ' + self.name, + 'location_id': self.template_id.in_location_id.id, + 'location_dest_id': self.template_id.in_location_dest_id.id, + 'carrier_id': self.initial_in_picking_carrier_id.id, + 'carrier_tracking_ref': False, + 'carrier_price': False + }) + new_picking.message_post_with_view('mail.message_origin_link', + values={'self': new_picking, 'origin': self}, + subtype_id=self.env.ref('mail.mt_note').id) + return new_picking + + def _new_in_move_vals(self, rma_line, new_picking, old_move): + return { + 'name': self.name + ' IN: ' + rma_line.product_id.name_get()[0][1], + 'product_id': rma_line.product_id.id, + 'product_uom_qty': rma_line.product_uom_qty, + 'product_uom': rma_line.product_uom_id.id, + 'picking_id': new_picking.id, + 'state': 'draft', + 'location_id': old_move.location_dest_id.id, + 'location_dest_id': self.template_id.in_location_dest_id.id, + 'picking_type_id': new_picking.picking_type_id.id, + 'warehouse_id': new_picking.picking_type_id.warehouse_id.id, + 'origin_returned_move_id': old_move.id, + 'procure_method': self.template_id.in_procure_method, + 'to_refund': self.template_id.in_to_refund, + } + + def _new_in_moves(self, old_picking, new_picking, move_update): + lines = self.lines.filtered(lambda l: l.product_uom_qty >= 1) + if not lines: + raise UserError(_('You have no lines with positive quantity.')) + + moves = self.env['stock.move'] + for l in lines: + return_move = old_picking.move_lines.filtered(lambda ol: ol.state == 'done' and ol.product_id.id == l.product_id.id)[0] + copy_vals = self._new_in_move_vals(l, new_picking, return_move) + copy_vals.update(move_update) + r = return_move.copy(copy_vals) + vals = {} + # +--------------------------------------------------------------------------------------------------------+ + # | picking_pick <--Move Orig-- picking_pack --Move Dest--> picking_ship + # | | returned_move_ids ↑ | returned_move_ids + # | ↓ | return_line.move_id ↓ + # | return pick(Add as dest) return toLink return ship(Add as orig) + # +--------------------------------------------------------------------------------------------------------+ + move_orig_to_link = return_move.move_dest_ids.mapped('returned_move_ids') + move_dest_to_link = return_move.move_orig_ids.mapped('returned_move_ids') + vals['move_orig_ids'] = [(4, m.id) for m in move_orig_to_link | return_move] + vals['move_dest_ids'] = [(4, m.id) for m in move_dest_to_link] + r.write(vals) + moves += r + return moves + + def _new_out_picking(self, old_picking): + new_picking = old_picking.copy({ + 'move_lines': [], + 'picking_type_id': self.template_id.out_type_id.id, + 'state': 'draft', + 'origin': old_picking.name + ' ' + self.name, + 'location_id': self.template_id.out_location_id.id, + 'location_dest_id': self.template_id.out_location_dest_id.id, + 'carrier_id': self.initial_out_picking_carrier_id.id, + 'carrier_tracking_ref': False, + 'carrier_price': False + }) + new_picking.message_post_with_view('mail.message_origin_link', + values={'self': new_picking, 'origin': self}, + subtype_id=self.env.ref('mail.mt_note').id) + return new_picking + + def _new_out_move_vals(self, rma_line, new_picking, old_move): + return { + 'name': self.name + ' OUT: ' + rma_line.product_id.name_get()[0][1], + 'product_id': rma_line.product_id.id, + 'product_uom_qty': rma_line.product_uom_qty, + 'picking_id': new_picking.id, + 'state': 'draft', + 'location_id': self.template_id.out_location_id.id, + 'location_dest_id': self.template_id.out_location_dest_id.id, + 'picking_type_id': new_picking.picking_type_id.id, + 'warehouse_id': new_picking.picking_type_id.warehouse_id.id, + 'origin_returned_move_id': False, + 'procure_method': self.template_id.out_procure_method, + 'to_refund': self.template_id.out_to_refund, + } + + def _new_out_moves(self, old_picking, new_picking, move_update): + lines = self.lines.filtered(lambda l: l.product_uom_qty >= 1) + if not lines: + raise UserError(_('You have no lines with positive quantity.')) + moves = self.env['stock.move'] + for l in lines: + return_move = old_picking.move_lines.filtered(lambda ol: ol.state == 'done' and ol.product_id.id == l.product_id.id)[0] + copy_vals = self._new_out_move_vals(l, new_picking, return_move) + copy_vals.update(move_update) + moves += return_move.copy(copy_vals) + return moves + + def _create_in_picking_stock_picking(self): + if not self.stock_picking_id or self.stock_picking_id.state != 'done': + raise UserError(_('You must have a completed stock picking for this RMA.')) + if not self.template_id.in_require_return: + group_id = self.stock_picking_id.group_id.id if self.stock_picking_id.group_id else 0 + values = self.template_id._values_for_in_picking(self) + update = {'group_id': group_id} + return self._picking_from_values(values, update, update) + + old_picking = self.stock_picking_id + + new_picking = self._new_in_picking(old_picking) + self._new_in_moves(old_picking, new_picking, {}) + return new_picking + + def _create_out_picking_stock_picking(self): + if not self.stock_picking_id or self.stock_picking_id.state != 'done': + raise UserError(_('You must have a completed stock picking for this RMA.')) + if not self.template_id.out_require_return: + group_id = self.stock_picking_id.group_id.id if self.stock_picking_id.group_id else 0 + values = self.template_id._values_for_out_picking(self) + update = {'group_id': group_id} + return self._picking_from_values(values, update, update) + + old_picking = self.stock_picking_id + new_picking = self._new_out_picking(old_picking) + self._new_out_moves(old_picking, new_picking, {}) + return new_picking + + +class RMALine(models.Model): + _name = 'rma.line' + _description = 'RMA Line' + + rma_id = fields.Many2one('rma.rma', string='RMA') + product_id = fields.Many2one('product.product', 'Product') + product_uom_id = fields.Many2one('uom.uom', 'UOM') + product_uom_qty = fields.Float(string='QTY') + rma_template_usage = fields.Selection(related='rma_id.template_usage') + + @api.onchange('product_id') + def _onchange_product_id(self): + for line in self: + line.product_uom_id = line.product_id.uom_id diff --git a/rma/models/stock_picking.py b/rma/models/stock_picking.py new file mode 100644 index 00000000..0b31801d --- /dev/null +++ b/rma/models/stock_picking.py @@ -0,0 +1,26 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import api, models, _ + + +class StockPicking(models.Model): + _inherit = 'stock.picking' + + def send_to_shipper(self): + res = False + for pick in self.filtered(lambda p: not p.carrier_tracking_ref): + # deliver full order if no items are done. + pick_has_no_done = sum(pick.move_line_ids.mapped('qty_done')) == 0 + if pick_has_no_done: + pick._rma_complete() + res = super(StockPicking, pick).send_to_shipper() + if pick_has_no_done: + pick._rma_complete_reverse() + return res + + def _rma_complete(self): + for line in self.move_line_ids: + line.qty_done = line.product_uom_qty + + def _rma_complete_reverse(self): + self.move_line_ids.write({'qty_done': 0.0}) diff --git a/rma/security/ir.model.access.csv b/rma/security/ir.model.access.csv new file mode 100644 index 00000000..a7c216cd --- /dev/null +++ b/rma/security/ir.model.access.csv @@ -0,0 +1,10 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +"manage_rma stock","manage rma","model_rma_rma","stock.group_stock_user",1,1,1,1 +"manage_rma_line stock","manage rma line","model_rma_line","stock.group_stock_user",1,1,1,1 +"manage_rma_template stock","manage rma template","model_rma_template","stock.group_stock_manager",1,1,1,1 +"manage_rma_tag stock","manage rma tag","model_rma_tag","stock.group_stock_manager",1,1,1,1 +"access_rma_template stock","access rma template","model_rma_template","stock.group_stock_user",1,1,0,0 +"access_rma_tag stock","access rma tag","model_rma_tag","stock.group_stock_user",1,0,0,0 +access_rma_portal,rma.rma.portal,rma.model_rma_rma,base.group_portal,1,0,0,0 +access_rma_line_portal,rma.line.portal,rma.model_rma_line,base.group_portal,1,0,0,0 +access_rma_template_portal,rma.template.portal,rma.model_rma_template,base.group_portal,1,0,0,0 \ No newline at end of file diff --git a/rma/security/rma_security.xml b/rma/security/rma_security.xml new file mode 100644 index 00000000..31c8c174 --- /dev/null +++ b/rma/security/rma_security.xml @@ -0,0 +1,51 @@ + + + + + + + + RMA: RMA + + + ['|', + ('company_id', '=', False), + ('company_id', 'child_of', [user.company_id.id]), + ] + + + + RMA: RMA Template + + + ['|', + ('company_id', '=', False), + ('company_id', 'child_of', [user.company_id.id]), + ] + + + + + Portal Personal RMAs + + [('partner_id','child_of',[user.commercial_partner_id.id])] + + + + + + + + + Portal RMA Line + + [('rma_id.partner_id','child_of',[user.commercial_partner_id.id])] + + + + + + + + + diff --git a/rma/tests/__init__.py b/rma/tests/__init__.py new file mode 100644 index 00000000..586c5532 --- /dev/null +++ b/rma/tests/__init__.py @@ -0,0 +1,3 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import test_rma diff --git a/rma/tests/test_rma.py b/rma/tests/test_rma.py new file mode 100644 index 00000000..588cce36 --- /dev/null +++ b/rma/tests/test_rma.py @@ -0,0 +1,246 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo.tests import common +from odoo.exceptions import UserError, ValidationError +import logging + + +_logger = logging.getLogger(__name__) + + +class TestRMA(common.TransactionCase): + def setUp(self): + super(TestRMA, self).setUp() + self.product1 = self.env.ref('product.product_product_24') + self.template_missing = self.env.ref('rma.template_missing_item') + self.template_return = self.env.ref('rma.template_picking_return') + self.partner1 = self.env.ref('base.res_partner_2') + self.user1 = self.env.ref('base.user_demo') + + def test_00_basic_rma(self): + self.template_missing.responsible_user_ids += self.user1 + self.template_missing.usage = False + rma = self.env['rma.rma'].create({ + 'template_id': self.template_missing.id, + 'partner_id': self.partner1.id, + 'partner_shipping_id': self.partner1.id, + }) + self.assertEqual(rma.state, 'draft') + self.assertTrue(rma.activity_ids) + self.assertEqual(rma.activity_ids.user_id, self.user1) + rma_line = self.env['rma.line'].create({ + 'rma_id': rma.id, + 'product_id': self.product1.id, + 'product_uom_id': self.product1.uom_id.id, + 'product_uom_qty': 2.0, + }) + rma.action_confirm() + # Should have made pickings + self.assertEqual(rma.state, 'confirmed') + # No inbound picking + self.assertFalse(rma.in_picking_id) + # Good outbound picking + self.assertTrue(rma.out_picking_id) + self.assertEqual(rma_line.product_id, rma.out_picking_id.move_lines.product_id) + self.assertEqual(rma_line.product_uom_qty, rma.out_picking_id.move_lines.product_uom_qty) + + with self.assertRaises(UserError): + rma.action_done() + + rma.out_picking_id.move_lines.quantity_done = 2.0 + rma.out_picking_id.action_done() + rma.action_done() + self.assertEqual(rma.state, 'done') + + def test_10_rma_cancel(self): + self.template_missing.usage = False + rma = self.env['rma.rma'].create({ + 'template_id': self.template_missing.id, + 'partner_id': self.partner1.id, + 'partner_shipping_id': self.partner1.id, + }) + self.assertEqual(rma.state, 'draft') + rma_line = self.env['rma.line'].create({ + 'rma_id': rma.id, + 'product_id': self.product1.id, + 'product_uom_id': self.product1.uom_id.id, + 'product_uom_qty': 2.0, + }) + rma.action_confirm() + # Good outbound picking + self.assertEqual(rma.out_picking_id.move_lines.state, 'assigned') + rma.action_cancel() + self.assertEqual(rma.out_picking_id.move_lines.state, 'cancel') + + def test_20_picking_rma(self): + type_out = self.env.ref('stock.picking_type_out') + location = self.env.ref('stock.stock_location_stock') + location_customer = self.env.ref('stock.stock_location_customers') + + adj = self.env['stock.inventory'].create({ + 'name': 'Adjust Out', + 'product_ids': [(4, self.product1.id)], + }) + adj.action_start() + adj.line_ids.write({ + 'product_qty': 0.0, + }) + adj.action_validate() + + # Adjust in a single serial + self.product1.tracking = 'serial' + + # Need to ensure this is the only quant that can be reserved for this move. + lot = self.env['stock.production.lot'].create({ + 'product_id': self.product1.id, + 'name': 'X1000', + 'product_uom_id': self.product1.uom_id.id, + 'company_id': self.env.user.company_id.id, + }) + adj = self.env['stock.inventory'].create({ + 'name': 'Initial', + 'product_ids': [(4, self.product1.id)], + }) + adj.action_start() + if not adj.line_ids: + _ = self.env['stock.inventory.line'].create({ + 'inventory_id': adj.id, + 'product_id': self.product1.id, + 'location_id': self.env.ref('stock.warehouse0').lot_stock_id.id, + }) + adj.line_ids.write({ + 'product_qty': 1.0, + 'prod_lot_id': lot.id, + }) + adj.action_validate() + + self.assertEqual(self.product1.qty_available, 1.0) + self.assertTrue(lot.quant_ids) + # Test some internals in Odoo 12.0 + lot_internal_quants = lot.quant_ids.filtered(lambda q: q.location_id.usage in ['internal', 'transit']) + self.assertEqual(len(lot_internal_quants), 1) + self.assertEqual(lot_internal_quants.mapped('quantity'), [1.0]) + # Re-compute qty as it does not depend on anything. + lot._product_qty() + self.assertEqual(lot.product_qty, 1.0) + + # Create initial picking that will be returned by RMA + picking_out = self.env['stock.picking'].create({ + 'partner_id': self.partner1.id, + 'name': 'testpicking', + 'picking_type_id': type_out.id, + 'location_id': location.id, + 'location_dest_id': location_customer.id, + }) + self.env['stock.move'].create({ + 'name': self.product1.name, + 'product_id': self.product1.id, + 'product_uom_qty': 1.0, + 'product_uom': self.product1.uom_id.id, + 'picking_id': picking_out.id, + 'location_id': location.id, + 'location_dest_id': location_customer.id, + }) + picking_out.with_context(planned_picking=True).action_confirm() + + # Try to RMA item not delivered yet + rma = self.env['rma.rma'].create({ + 'template_id': self.template_return.id, + 'partner_id': self.partner1.id, + 'partner_shipping_id': self.partner1.id, + 'stock_picking_id': picking_out.id, + }) + self.assertEqual(rma.state, 'draft') + wizard = self.env['rma.picking.make.lines'].create({ + 'rma_id': rma.id, + }) + wizard.line_ids.product_uom_qty = 1.0 + wizard.add_lines() + self.assertEqual(len(rma.lines), 1) + + # Make sure that we cannot 'return' if we cannot 'reverse' a stock move + # (this is what `in_require_return` and `out_require_return` do on `rma.template`) + with self.assertRaises(UserError): + rma.action_confirm() + + # Finish our original picking + picking_out.action_assign() + self.assertEqual(picking_out.state, 'assigned') + + # The only lot should be reserved, so we shouldn't get an exception finishing the transfer. + picking_out.move_line_ids.write({ + 'qty_done': 1.0, + }) + picking_out.button_validate() + self.assertEqual(picking_out.state, 'done') + + # Now we can 'return' that picking + rma.action_confirm() + self.assertEqual(rma.in_picking_id.state, 'assigned') + pack_opt = rma.in_picking_id.move_line_ids[0] + self.assertTrue(pack_opt) + + # We cannot check this directly anymore. Instead just try to return the same lot and make sure you can. + # self.assertEqual(pack_opt.lot_id, lot) + + with self.assertRaises(UserError): + rma.action_done() + + pack_opt.qty_done = 1.0 + with self.assertRaises(UserError): + # require a lot + rma.in_picking_id.button_validate() + + pack_opt.lot_id = lot + rma.in_picking_id.button_validate() + rma.action_done() + + # Ensure that the same lot was in fact returned into our destination inventory + quant = self.env['stock.quant'].search([('product_id', '=', self.product1.id), ('location_id', '=', location.id)]) + self.assertEqual(len(quant), 1) + self.assertEqual(quant.lot_id, lot) + + # Make another RMA for the same picking + rma2 = self.env['rma.rma'].create({ + 'template_id': self.template_return.id, + 'partner_id': self.partner1.id, + 'partner_shipping_id': self.partner1.id, + 'stock_picking_id': picking_out.id, + }) + wizard = self.env['rma.picking.make.lines'].create({ + 'rma_id': rma2.id, + }) + wizard.line_ids.product_uom_qty = 1.0 + wizard.add_lines() + self.assertEqual(len(rma2.lines), 1) + + rma2.action_confirm() + + # In Odoo 10, this would not have been able to reserve. + # In Odoo 11, reservation can still happen, but at least we can't move the same lot twice! + # self.assertEqual(rma2.in_picking_id.state, 'confirmed') + + # Requires Lot + with self.assertRaises(UserError): + rma2.in_picking_id.move_line_ids.write({'qty_done': 1.0}) + rma2.in_picking_id.button_validate() + + + self.assertTrue(rma2.in_picking_id.move_line_ids) + self.assertFalse(rma2.in_picking_id.move_line_ids.lot_id.name) + + # Assign existing lot + # TODO: Investigate + # rma2.in_picking_id.move_line_ids.write({ + # 'lot_id': lot.id + # }) + + # Existing lot cannot be re-used. + # TODO: Investigate + # It appears that in Odoo 13 You can move the lot again... + # with self.assertRaises(ValidationError): + # rma2.in_picking_id.action_done() + + # RMA cannot be completed because the inbound picking state is confirmed + with self.assertRaises(UserError): + rma2.action_done() diff --git a/rma/views/account_views.xml b/rma/views/account_views.xml new file mode 100644 index 00000000..b189fc7a --- /dev/null +++ b/rma/views/account_views.xml @@ -0,0 +1,17 @@ + + + + + account.move.form.rma + account.move + + + + + + + + + + + \ No newline at end of file diff --git a/rma/views/portal_templates.xml b/rma/views/portal_templates.xml new file mode 100644 index 00000000..abd1728e --- /dev/null +++ b/rma/views/portal_templates.xml @@ -0,0 +1,267 @@ + + + + + + + + + + + + + + + + + + diff --git a/rma/views/rma_views.xml b/rma/views/rma_views.xml new file mode 100644 index 00000000..f636ba54 --- /dev/null +++ b/rma/views/rma_views.xml @@ -0,0 +1,286 @@ + + + + rma.rma.form + rma.rma + +
+
+
+ +
+ +
+
+

+ +

+
+ + + + + + + +
+