diff --git a/rma_account/models/rma_operation.py b/rma_account/models/rma_operation.py index 727bab40..26702309 100644 --- a/rma_account/models/rma_operation.py +++ b/rma_account/models/rma_operation.py @@ -27,6 +27,16 @@ class RmaOperation(models.Model): comodel_name="account.journal", compute="_compute_domain_valid_journal", ) + automated_refund = fields.Boolean( + help="In the scenario where a company uses anglo-saxon accounting, if " + "you receive products from a customer and don't expect to refund the customer " + "but send a replacement unit, mark this flag to be accounting consistent" + ) + refund_free_of_charge = fields.Boolean( + help="In case of automated refund you should mark this option as long automated" + "refunds mean to compensate Stock Interim accounts only without hitting" + "Accounts receivable" + ) @api.onchange("type") def _compute_domain_valid_journal(self): @@ -39,3 +49,9 @@ class RmaOperation(models.Model): rec.valid_refund_journal_ids = self.env["account.journal"].search( [("type", "=", "purchase")] ) + + @api.onchange("automated_refund") + def _onchange_automated_refund(self): + for rec in self: + if rec.automated_refund: + rec.refund_free_of_charge = True diff --git a/rma_account/models/rma_order_line.py b/rma_account/models/rma_order_line.py index f27e460e..fe30ddda 100644 --- a/rma_account/models/rma_order_line.py +++ b/rma_account/models/rma_order_line.py @@ -357,3 +357,39 @@ class RmaOrderLine(models.Model): # We get the cost from the original invoice line price_unit = self.account_move_line_id.price_unit return price_unit + + def _refund_at_zero_cost(self): + make_refund = ( + self.env["rma.refund"] + .with_context( + { + "customer": True, + "active_ids": self.ids, + "active_model": "rma.order.line", + } + ) + .create({"description": "RMA Anglosaxon Regularisation"}) + ) + for item in make_refund.item_ids: + item.qty_to_refund = item.line_id.qty_received - item.line_id.qty_refunded + action_refund = make_refund.invoice_refund() + refund_id = action_refund.get("res_id", False) + if refund_id: + refund = self.env["account.move"].browse(refund_id) + refund._post() + + def _check_refund_zero_cost(self): + """ + In the scenario where a company uses anglo-saxon accounting, if you receive + products from a customer and don't expect to refund the customer but send a + replacement unit you still need to create a debit entry on the + Stock Interim (Delivered) account. In order to do this the best approach is + to create a customer refund from the RMA, but set as free of charge + (price unit = 0). The refund will be 0, but the Stock Interim (Delivered) + account will be posted anyways. + """ + # For some reason api.depends on qty_received is not working. Using the + # _account_entry_move method in stock move as trigger then + for rec in self.filtered(lambda l: l.operation_id.automated_refund): + if rec.qty_received > rec.qty_refunded: + rec._refund_at_zero_cost() diff --git a/rma_account/models/stock_move.py b/rma_account/models/stock_move.py index c5872fa4..763c0297 100644 --- a/rma_account/models/stock_move.py +++ b/rma_account/models/stock_move.py @@ -28,4 +28,5 @@ class StockMove(models.Model): # Eventually reconcile together the invoice and valuation accounting # entries on the stock interim accounts self.rma_line_id._stock_account_anglo_saxon_reconcile_valuation() + self.rma_line_id._check_refund_zero_cost() return res diff --git a/rma_account/tests/test_rma_stock_account.py b/rma_account/tests/test_rma_stock_account.py index 1e160fee..8db54d37 100644 --- a/rma_account/tests/test_rma_stock_account.py +++ b/rma_account/tests/test_rma_stock_account.py @@ -25,6 +25,9 @@ class TestRmaStockAccount(TestRma): "rma_account.rma_operation_customer_refund" ) cls.rma_basic_user.write({"groups_id": [(4, cls.g_account_user.id)]}) + cls.customer_route = cls.env.ref("rma.route_rma_customer") + cls.input_location = cls.env.ref("stock.stock_location_company") + cls.output_location = cls.env.ref("stock.stock_location_output") # The product category created in the base module is not automated valuation # we have to create a new category here # Create account for Goods Received Not Invoiced @@ -248,3 +251,110 @@ class TestRmaStockAccount(TestRma): self.assertEqual(gdni_balance, 0.0) # The GDNI entries should be now reconciled self.assertEqual(all(gdni_amls.mapped("reconciled")), True) + + def test_08_cost_from_move_multi_step(self): + """ + Receive a product and then return it using a multi-step route. + The Goods Delivered Not Invoiced should result in 0 + """ + # Alter the customer RMA route to make it multi-step + # Get rid of the duplicated rule + self.env.ref("rma.rule_rma_customer_out_pull").active = False + self.env.ref("rma.rule_rma_customer_in_pull").active = False + cust_in_pull_rule = self.customer_route.rule_ids.filtered( + lambda r: r.location_id == self.stock_rma_location + ) + cust_in_pull_rule.location_id = self.input_location + cust_out_pull_rule = self.customer_route.rule_ids.filtered( + lambda r: r.location_src_id == self.env.ref("rma.location_rma") + ) + cust_out_pull_rule.location_src_id = self.output_location + cust_out_pull_rule.procure_method = "make_to_order" + self.env["stock.rule"].create( + { + "name": "RMA->Output", + "action": "pull", + "warehouse_id": self.wh.id, + "location_src_id": self.env.ref("rma.location_rma").id, + "location_id": self.output_location.id, + "procure_method": "make_to_stock", + "route_id": self.customer_route.id, + "picking_type_id": self.env.ref("stock.picking_type_internal").id, + } + ) + self.env["stock.rule"].create( + { + "name": "Customers->RMA", + "action": "pull", + "warehouse_id": self.wh.id, + "location_src_id": self.customer_location.id, + "location_id": self.env.ref("rma.location_rma").id, + "procure_method": "make_to_order", + "route_id": self.customer_route.id, + "picking_type_id": self.env.ref("stock.picking_type_in").id, + } + ) + # Set a standard price on the products + self.product_fifo_1.standard_price = 10 + self._create_inventory( + self.product_fifo_1, 20.0, self.env.ref("stock.stock_location_customers") + ) + products2move = [ + (self.product_fifo_1, 3), + ] + self.product_fifo_1.categ_id.rma_customer_operation_id = ( + self.rma_cust_replace_op_id + ) + rma_customer_id = self._create_rma_from_move( + products2move, + "customer", + self.env.ref("base.res_partner_2"), + dropship=False, + ) + # Set an incorrect price in the RMA (this should not affect cost) + rma = rma_customer_id.rma_line_ids + rma.price_unit = 999 + rma.action_rma_to_approve() + self._receive_rma(rma) + layers = rma.move_ids.sudo().stock_valuation_layer_ids + gdni_amls = layers.account_move_id.line_ids.filtered( + lambda l: l.account_id == self.account_gdni + ) + gdni_balance = sum(gdni_amls.mapped("balance")) + self.assertEqual(len(gdni_amls), 1) + # Balance should be -30, as we have only received + self.assertEqual(gdni_balance, -30.0) + self._deliver_rma(rma) + layers = rma.move_ids.sudo().stock_valuation_layer_ids + gdni_amls = layers.account_move_id.line_ids.filtered( + lambda l: l.account_id == self.account_gdni + ) + gdni_balance = sum(gdni_amls.mapped("balance")) + self.assertEqual(len(gdni_amls), 2) + # Balance should be 0, as we have received and shipped + self.assertEqual(gdni_balance, 0.0) + # The GDNI entries should be now reconciled + self.assertEqual(all(gdni_amls.mapped("reconciled")), True) + + def test_05_reconcile_grni_when_no_refund(self): + """ + Test that receive and send a replacement order leaves GDNI reconciled + """ + self.product_fifo_1.standard_price = 15 + rma_line = Form(self.rma_line) + rma_line.partner_id = self.partner_id + rma_line.product_id = self.product_fifo_1 + rma_line.operation_id.automated_refund = True + rma_line = rma_line.save() + rma_line.action_rma_to_approve() + # receiving should trigger the refund at zero cost + self._receive_rma(rma_line) + gdni_amls = self.env["account.move.line"].search( + [ + ("rma_line_id", "in", rma_line.ids), + ("account_id", "=", self.account_gdni.id), + ] + ) + rma_line.refund_line_ids.filtered( + lambda l: l.account_id == self.account_gdni + ) + self.assertEqual(all(gdni_amls.mapped("reconciled")), True) diff --git a/rma_account/views/rma_operation_view.xml b/rma_account/views/rma_operation_view.xml index 6d2cdbb0..922f52aa 100644 --- a/rma_account/views/rma_operation_view.xml +++ b/rma_account/views/rma_operation_view.xml @@ -21,6 +21,10 @@ + + + + diff --git a/rma_account/wizards/rma_refund.py b/rma_account/wizards/rma_refund.py index dfe569fb..825046a3 100644 --- a/rma_account/wizards/rma_refund.py +++ b/rma_account/wizards/rma_refund.py @@ -92,10 +92,6 @@ class RmaRefund(models.TransientModel): def invoice_refund(self): rma_line_ids = self.env["rma.order.line"].browse(self.env.context["active_ids"]) for line in rma_line_ids: - if line.refund_policy == "no": - raise ValidationError( - _("The operation is not refund for at least one line") - ) if line.state != "approved": raise ValidationError(_("RMA %s is not approved") % line.name) new_invoice = self.compute_refund() @@ -111,6 +107,8 @@ class RmaRefund(models.TransientModel): return result def _get_refund_price_unit(self, rma): + if rma.operation_id.refund_free_of_charge: + return 0.0 price_unit = rma.price_unit # If this references a previous invoice/bill, use the same unit price if rma.account_move_line_id: