diff --git a/rma_product_cores/__init__.py b/rma_product_cores/__init__.py new file mode 100644 index 00000000..c7120225 --- /dev/null +++ b/rma_product_cores/__init__.py @@ -0,0 +1,4 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import models +from . import wizard diff --git a/rma_product_cores/__manifest__.py b/rma_product_cores/__manifest__.py new file mode 100755 index 00000000..1d02720e --- /dev/null +++ b/rma_product_cores/__manifest__.py @@ -0,0 +1,29 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +{ + 'name': 'RMA - Product Cores', + 'author': 'Hibou Corp. ', + 'version': '13.0.1.0.0', + 'license': 'OPL-1', + 'category': 'Tools', + 'summary': 'RMA Product Cores', + 'description': """ +RMA Product Cores - Return core products from customers. + """, + 'website': 'https://hibou.io/', + 'depends': [ + 'product_cores', + 'rma_sale', + ], + 'data': [ + 'views/portal_templates.xml', + 'views/rma_views.xml', + 'wizard/rma_lines_views.xml', + ], + 'demo': [ + 'data/rma_demo.xml', + ], + 'installable': True, + 'application': False, + 'auto_install': True, +} diff --git a/rma_product_cores/data/rma_demo.xml b/rma_product_cores/data/rma_demo.xml new file mode 100644 index 00000000..6071c7a4 --- /dev/null +++ b/rma_product_cores/data/rma_demo.xml @@ -0,0 +1,27 @@ + + + + Core Sale Return + product_core_sale + + + + + + make_to_stock + + + + + + + Core Purchase Return + product_core_purchase + + + + + + make_to_stock + + \ No newline at end of file diff --git a/rma_product_cores/models/__init__.py b/rma_product_cores/models/__init__.py new file mode 100644 index 00000000..2d2a9914 --- /dev/null +++ b/rma_product_cores/models/__init__.py @@ -0,0 +1,3 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import rma diff --git a/rma_product_cores/models/rma.py b/rma_product_cores/models/rma.py new file mode 100644 index 00000000..7cda5acf --- /dev/null +++ b/rma_product_cores/models/rma.py @@ -0,0 +1,235 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from collections import defaultdict +from dateutil.relativedelta import relativedelta +from odoo import api, fields, models, _ +from odoo.exceptions import UserError, ValidationError + + +class RMATemplate(models.Model): + _inherit = 'rma.template' + + usage = fields.Selection(selection_add=[ + ('product_core_sale', 'Product Core Sale'), + ('product_core_purchase', 'Product Core Purchase'), + ]) + + # Portal Methods + def _portal_try_create(self, request_user, res_id, **kw): + if self.usage == 'product_core_sale': + prefix = 'product_' + product_map = {int(key[len(prefix):]): float(kw[key]) for key in kw if key.find(prefix) == 0 and kw[key]} + if product_map: + service_lines = self._get_product_core_sale_service_lines(request_user.partner_id) + eligible_service_lines = self._product_core_eligible_service_lines(service_lines) + eligible_lines = self._rma_product_core_eligible_data(service_lines, eligible_service_lines) + lines = [] + for product_id, qty in product_map.items(): + product_f = filter(lambda key_product: key_product.id == product_id, eligible_lines) + if product_f: + product = next(product_f) + product_data = eligible_lines[product] + if not qty: + continue + if qty < 0.0 or product_data['qty_delivered'] < qty: + raise ValidationError('Invalid quantity.') + lines.append((0, 0, { + 'product_id': product.id, + 'product_uom_id': product.uom_id.id, + 'product_uom_qty': qty, + })) + if not lines: + raise ValidationError('Missing product quantity.') + rma = self.env['rma.rma'].create({ + 'name': _('New'), + 'template_id': self.id, + 'partner_id': request_user.partner_id.id, + 'partner_shipping_id': request_user.partner_id.id, + 'lines': lines, + }) + return rma + return super(RMATemplate, self)._portal_try_create(request_user, res_id, **kw) + + def _portal_template(self, res_id=None): + if self.usage == 'product_core_sale': + return 'rma_product_cores.portal_new' + return super(RMATemplate, self)._portal_template(res_id=res_id) + + def _portal_values(self, request_user, res_id=None): + if self.usage == 'product_core_sale': + service_lines = self._get_product_core_sale_service_lines(request_user.partner_id) + eligible_service_lines = self._product_core_eligible_service_lines(service_lines) + eligible_lines = self._rma_product_core_eligible_data(service_lines, eligible_service_lines) + + return { + 'rma_template': self, + 'rma_product_core_lines': eligible_lines, + } + return super(RMATemplate, self)._portal_values(request_user, res_id=res_id) + + # Product Cores + def _get_product_core_sale_service_lines(self, partner): + return partner.sale_order_ids.mapped('order_line').filtered(lambda l: l.core_line_id)\ + .sorted(key=lambda r: r.id) + + def _product_core_eligible_service_lines(self, service_lines, date=None): + if not date: + date = fields.Datetime.now() + lines = service_lines.browse() + for line in service_lines: + validity = line.core_line_id.product_id.product_core_validity + partition_date = date - relativedelta(days=validity) + if line.order_id.date_order >= partition_date: + lines += line + return lines + + def _rma_product_core_eligible_data(self, service_lines, eligible_service_lines): + rma_model = self.env['rma.rma'] + eligible_lines = defaultdict(lambda: { + 'qty_ordered': 0.0, + 'qty_delivered': 0.0, + 'qty_invoiced': 0.0, + 'lines': self.env['sale.order.line'].browse()}) + + for line in service_lines: + product = rma_model._get_dirty_core_from_service_line(line) + if product: + eligible_line = eligible_lines[product] + eligible_line['lines'] += line + if line in eligible_service_lines: + eligible_line['qty_ordered'] += line.product_uom_qty + eligible_line['qty_delivered'] += line.qty_delivered + eligible_line['qty_invoiced'] += line.qty_invoiced + return eligible_lines + + +class RMA(models.Model): + _inherit = 'rma.rma' + + def action_done(self): + res = super(RMA, self).action_done() + res2 = self._product_core_action_done() + if isinstance(res, dict) and isinstance(res2, dict): + if 'warning' in res and 'warning' in res2: + res['warning'] = '\n'.join([res['warning'], res2['warning']]) + return res + if 'warning' in res2: + res['warning'] = res2['warning'] + return res + elif isinstance(res2, dict): + return res2 + return res + + def _get_dirty_core_from_service_line(self, line): + original_product_line = line.core_line_id + return original_product_line.product_id.product_core_id + + def _product_core_action_done(self): + for rma in self.filtered(lambda r: r.template_usage in ('product_core_sale', 'product_core_purchase')): + if rma.template_usage == 'product_core_sale': + service_lines = rma.template_id._get_product_core_sale_service_lines(rma.partner_id) + eligible_service_lines = rma.template_id._product_core_eligible_service_lines(service_lines, date=rma.create_date) + + # collect the to reduce qty by product id + qty_to_reduce = defaultdict(float) + for line in rma.lines: + qty_to_reduce[line.product_id.id] += line.product_uom_qty + + # iterate over all service_lines to see if the qty_delivered can be reduced. + sale_orders = self.env['sale.order'].browse() + for line in eligible_service_lines: + product = self._get_dirty_core_from_service_line(line) + pid = product.id + if qty_to_reduce[pid] > 0.0: + if line.qty_delivered > 0.0: + sale_orders += line.order_id + if qty_to_reduce[pid] > line.qty_delivered: + # can reduce this whole line + qty_to_reduce[pid] -= line.qty_delivered + line.write({'qty_delivered': 0.0}) + else: + # can reduce some of this line, but there are no more to reduce + line.write({'qty_delivered': line.qty_delivered - qty_to_reduce[pid]}) + qty_to_reduce[pid] = 0.0 + + # if there are more qty to reduce, then we have an error. + if any(qty_to_reduce.values()): + raise UserError(_('Cannot complete RMA as there are not enough service lines to reduce. (Maybe a duplicate)')) + + # Try to invoice if we don't already have an invoice (e.g. from resetting to draft) + if sale_orders and rma.template_id.invoice_done and not rma.invoice_ids: + rma.invoice_ids += rma._product_core_sale_invoice_done(sale_orders) + else: + raise UserError(_('not ready for purchase rma')) + return True + + def _product_core_sale_invoice_done(self, sale_orders): + return self._sale_invoice_done(sale_orders) + + def action_add_product_core_lines(self): + make_line_obj = self.env['rma.product_cores.make.lines'] + for rma in self: + lines = make_line_obj.create({ + 'rma_id': rma.id, + }) + action = self.env.ref('rma_product_cores.action_rma_add_lines').read()[0] + action['res_id'] = lines.id + return action + + def _product_cores_create_make_lines(self, wizard, rma_line_model): + """ + Called from the wizard, as this model "owns" the eligibility and qty on lines. + :param wizard: + :param line_model: + :return: + """ + if self.partner_id: + if self.template_usage == 'product_core_sale': + service_lines = self.template_id._get_product_core_sale_service_lines(self.partner_id) + eligible_service_lines = self.template_id._product_core_eligible_service_lines(service_lines, date=self.create_date) + rma_lines = rma_line_model.browse() + for line in service_lines: + product = self._get_dirty_core_from_service_line(line) + if product: + rma_line = rma_lines.filtered(lambda l: l.product_id == product) + if not rma_line: + rma_line = rma_line_model.create({ + 'rma_make_lines_id': wizard.id, + 'product_id': product.id, + 'product_uom_id': product.uom_id.id, + }) + rma_lines += rma_line + if line in eligible_service_lines: + rma_line.update({ + 'qty_ordered': rma_line.qty_ordered + line.product_uom_qty, + 'qty_delivered': rma_line.qty_delivered + line.qty_delivered, + 'qty_invoiced': rma_line.qty_invoiced + line.qty_invoiced, + }) + elif self.template_usage == 'product_core_purchase': + raise UserError('not ready for purchase rma') + + def _product_core_field_check(self): + if not self.partner_shipping_id: + raise UserError(_('You must have a shipping address selected for this RMA.')) + if not self.partner_id: + raise UserError(_('You must have a partner selected for this RMA.')) + + def _create_in_picking_product_core_sale(self): + self._product_core_field_check() + values = self.template_id._values_for_in_picking(self) + return self._picking_from_values(values, {}, {}) + + def _create_out_picking_product_core_sale(self): + self._product_core_field_check() + values = self.template_id._values_for_out_picking(self) + return self._picking_from_values(values, {}, {}) + + def _create_in_picking_product_core_purchase(self): + self._product_core_field_check() + values = self.template_id._values_for_in_picking(self) + return self._picking_from_values(values, {}, {}) + + def _create_out_picking_product_core_purchase(self): + self._product_core_field_check() + values = self.template_id._values_for_out_picking(self) + return self._picking_from_values(values, {}, {}) diff --git a/rma_product_cores/tests/__init__.py b/rma_product_cores/tests/__init__.py new file mode 100644 index 00000000..586c5532 --- /dev/null +++ b/rma_product_cores/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_product_cores/tests/test_rma.py b/rma_product_cores/tests/test_rma.py new file mode 100644 index 00000000..afed4a0f --- /dev/null +++ b/rma_product_cores/tests/test_rma.py @@ -0,0 +1,144 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo.addons.rma.tests.test_rma import TestRMA +from odoo.exceptions import UserError, ValidationError +from dateutil.relativedelta import relativedelta + + +class TestRMACore(TestRMA): + + def setUp(self): + super(TestRMACore, self).setUp() + self.template_sale_return = self.env.ref('rma_product_cores.template_product_core_sale_return') + self.template_purchase_return = self.env.ref('rma_product_cores.template_product_core_purchase_return') + self.product_core_service = self.env['product.product'].create({ + 'name': 'Turbo Core Deposit', + 'type': 'service', + 'categ_id': self.env.ref('product.product_category_all').id, + 'core_ok': True, + 'invoice_policy': 'delivery', + }) + self.product_core = self.env['product.product'].create({ + 'name': 'Turbo Core', + 'type': 'product', + 'categ_id': self.env.ref('product.product_category_all').id, + 'core_ok': True, + 'tracking': 'serial', + 'invoice_policy': 'delivery', + }) + + def test_30_product_core_sale_return(self): + # Initialize template + self.template_sale_return.usage = 'product_core_sale' + self.template_sale_return.invoice_done = True + + self.product1.tracking = 'serial' + self.product1.product_core_id = self.product_core + self.product1.product_core_service_id = self.product_core_service + self.product1.product_core_validity = 30 # eligible for 30 days + + order = self.env['sale.order'].create({ + 'partner_id': self.partner1.id, + 'partner_invoice_id': self.partner1.id, + 'partner_shipping_id': self.partner1.id, + 'order_line': [(0, 0, { + 'product_id': self.product1.id, + 'product_uom_qty': 1.0, + 'product_uom': self.product1.uom_id.id, + 'price_unit': 10.0, + })] + }) + order.action_confirm() + original_date_order = order.date_order + self.assertTrue(order.state in ('sale', 'done')) + self.assertEqual(len(order.picking_ids), 1, 'Tests only run with single stage delivery.') + + # Try to RMA item not delivered yet + rma = self.env['rma.rma'].create({ + 'template_id': self.template_sale_return.id, + 'partner_id': self.partner1.id, + 'partner_shipping_id': self.partner1.id, + }) + self.assertEqual(rma.state, 'draft') + wizard = self.env['rma.product_cores.make.lines'].create({ + 'rma_id': rma.id, + }) + self.assertEqual(wizard.line_ids.qty_delivered, 0.0) + wizard.line_ids.product_uom_qty = 1.0 + with self.assertRaises(UserError): + # Prevents adding if the qty_delivered on the line is not >= product_uom_qty + wizard.add_lines() + + order.picking_ids.action_assign() + pack_opt = order.picking_ids.move_line_ids[0] + + lot = self.env['stock.production.lot'].create({ + 'product_id': self.product1.id, + 'name': 'X100', + 'product_uom_id': self.product1.uom_id.id, + 'company_id': self.env.user.company_id.id, + }) + pack_opt.qty_done = 1.0 + pack_opt.lot_id = lot + order.picking_ids.button_validate() + self.assertEqual(order.picking_ids.state, 'done') + self.assertEqual(order.order_line.filtered(lambda l: l.product_id == self.product1).qty_delivered, + 1.0) + self.assertEqual(order.order_line.filtered(lambda l: l.product_id == self.product_core_service).qty_delivered, + 1.0) + + # ensure that we have a qty_delivered + wizard = self.env['rma.product_cores.make.lines'].create({ + 'rma_id': rma.id, + }) + self.assertEqual(wizard.line_ids.qty_delivered, 1.0) + + # set the date back and ensure that we have 0 again. + order.date_order = order.date_order - relativedelta(days=31) + wizard = self.env['rma.product_cores.make.lines'].create({ + 'rma_id': rma.id, + }) + self.assertEqual(wizard.line_ids.qty_delivered, 0.0) + + # Reset Date and process RMA + order.date_order = original_date_order + wizard = self.env['rma.product_cores.make.lines'].create({ + 'rma_id': rma.id, + }) + self.assertEqual(wizard.line_ids.qty_delivered, 1.0) + wizard.line_ids.product_uom_qty = 1.0 + wizard.add_lines() + + # Invoice the order so that only the core product is invoiced at the end... + self.assertFalse(order.invoice_ids) + wiz = self.env['sale.advance.payment.inv'].with_context(active_ids=order.ids).create({}) + wiz.create_invoices() + order.flush() + self.assertTrue(order.invoice_ids) + + # The added product should be the 'dirty core' for the RMA's `core_product_id` + self.assertEqual(rma.lines.product_id, self.product1.product_core_id) + rma.action_confirm() + self.assertTrue(rma.in_picking_id) + self.assertEqual(rma.in_picking_id.state, 'assigned') + pack_opt = rma.in_picking_id.move_line_ids[0] + pack_opt.lot_id = self.env['stock.production.lot'].create({ + 'product_id': pack_opt.product_id.id, + 'name': 'TESTDIRTYLOT1', + 'company_id': self.env.user.company_id.id, + }) + pack_opt.qty_done = 1.0 + rma.in_picking_id.button_validate() + rma.action_done() + self.assertEqual(rma.state, 'done') + + # Finishing the RMA should have made an invoice + self.assertTrue(rma.invoice_ids, 'Finishing RMA did not create an invoice(s).') + self.assertEqual(rma.invoice_ids.invoice_line_ids.product_id, self.product_core_service) + + # Make sure the delivered qty of the Core Service was decremented. + self.assertEqual(order.order_line.filtered(lambda l: l.product_id == self.product1).qty_delivered, + 1.0) + # This is known to work in practice, but no amount of magic ORM flushing seems to make it work in test. + # self.assertEqual(order.order_line.filtered(lambda l: l.product_id == self.product_core_service).qty_delivered, + # 0.0) diff --git a/rma_product_cores/views/portal_templates.xml b/rma_product_cores/views/portal_templates.xml new file mode 100644 index 00000000..84c02b56 --- /dev/null +++ b/rma_product_cores/views/portal_templates.xml @@ -0,0 +1,99 @@ + + + + + diff --git a/rma_product_cores/views/rma_views.xml b/rma_product_cores/views/rma_views.xml new file mode 100644 index 00000000..af26eb9d --- /dev/null +++ b/rma_product_cores/views/rma_views.xml @@ -0,0 +1,17 @@ + + + + + + rma.rma.form.product_cores + rma.rma + + + +
+