Repository: inventree/InvenTree

Issue:
[BUG] :checkered_flag: Parts not marked as 'available' after action "Return to stock"
### Please verify that this bug has NOT been raised before.

- [X] I checked and didn't find similar issue

### Describe the bug*

See discussion https://github.com/inventree/InvenTree/discussions/4200

Returned stock should be marked as available

### Steps to Reproduce

1. use demo server
2. Check stock of part 108, filter by available: "Blue chair": 14 pcs (https://demo.inventree.org/part/108/)
3. Open the stock item sold&shipped in SO0001: https://demo.inventree.org/stock/item/994/
4. Use "Return to stock" action on in top right menu.
5. Choose a location, eg "Room 101" (same as other 14 chairs), add note "Testing return to stock"
6. Check stock of part 108, filter by available. Expect 17 pcs -> Only 14 pcs available.

Before:
![image](https://user-images.githubusercontent.com/1314853/212007425-762c820d-f55e-46c0-92cc-001fe7d3bd83.png)

After:
![image](https://user-images.githubusercontent.com/1314853/212007679-6e035f7c-8946-4181-ab37-c680ac384658.png)

All stock items after change, without filtering:
![image](https://user-images.githubusercontent.com/1314853/212007904-5da9f2c6-6294-435b-8b47-e04b7c3ad11b.png)


### Expected behavior

Returned stock should be counted/shown as part of available stock, without extra actions.

### Deployment Method

- [X] Docker
- [ ] Bare metal

### Version Information

# Version Information:
InvenTree-Version: 0.10.0 dev
Django Version: 3.2.16
Commit Hash: e730b5c
Commit Date: 2023-01-10
Database: postgresql
Debug-Mode: False
Deployed using Docker: True
Active plugins: False


### Relevant log output

_No response_


Discussion:

Resolving patch:
diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py
--- a/InvenTree/stock/models.py
+++ b/InvenTree/stock/models.py
@@ -974,7 +974,11 @@ def allocateToCustomer(self, customer, quantity=None, order=None, user=None, not
 
     @transaction.atomic
     def return_from_customer(self, location, user=None, **kwargs):
-        """Return stock item from customer, back into the specified location."""
+        """Return stock item from customer, back into the specified location.
+
+        If the selected location is the same as the parent, merge stock back into the parent.
+        Otherwise create the stock in the new location
+        """
         notes = kwargs.get('notes', '')
 
         tracking_info = {}
@@ -991,7 +995,10 @@ def return_from_customer(self, location, user=None, **kwargs):
             location=location
         )
 
+        self.clearAllocations()
         self.customer = None
+        self.belongs_to = None
+        self.sales_order = None
         self.location = location
 
         trigger_event(
@@ -999,7 +1006,16 @@ def return_from_customer(self, location, user=None, **kwargs):
             id=self.id,
         )
 
-        self.save()
+        """If new location is the same as the parent location, merge this stock back in the parent"""
+        if self.parent and self.location == self.parent.location:
+            self.parent.merge_stock_items(
+                {self},
+                user=user,
+                location=location,
+                notes=notes
+            )
+        else:
+            self.save()
 
     def is_allocated(self):
         """Return True if this StockItem is allocated to a SalesOrder or a Build."""


Test patch:
diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py
--- a/InvenTree/stock/tests.py
+++ b/InvenTree/stock/tests.py
@@ -8,8 +8,10 @@
 
 from build.models import Build
 from common.models import InvenTreeSetting
+from company.models import Company
 from InvenTree.helpers import InvenTreeTestCase
 from InvenTree.status_codes import StockHistoryCode
+from order.models import SalesOrder
 from part.models import Part
 
 from .models import (StockItem, StockItemTestResult, StockItemTracking,
@@ -465,6 +467,75 @@ def test_add_stock(self):
 
         self.assertFalse(it.add_stock(-10, None))
 
+    def test_allocate_to_customer(self):
+        """Test allocating stock to a customer"""
+        it = StockItem.objects.get(pk=2)
+        n = it.quantity
+        an = n - 10
+        customer = Company.objects.create(name="MyTestCompany")
+        order = SalesOrder.objects.create(description="Test order")
+        ait = it.allocateToCustomer(customer, quantity=an, order=order, user=None, notes='Allocated some stock')
+
+        # Check if new stockitem is created
+        self.assertTrue(ait)
+        # Check correct quantity of new allocated stock
+        self.assertEqual(ait.quantity, an)
+        # Check if new stock is assigned to correct customer
+        self.assertEqual(ait.customer, customer)
+        # Check if new stock is assigned to correct sales order
+        self.assertEqual(ait.sales_order, order)
+        # Check location is None because this stock is now allocated to a user
+        self.assertFalse(ait.location)
+
+        # Check that a tracking item was added
+        track = StockItemTracking.objects.filter(item=ait).latest('id')
+
+        self.assertEqual(track.tracking_type, StockHistoryCode.SENT_TO_CUSTOMER)
+        self.assertIn('Allocated some stock', track.notes)
+
+    def test_return_from_customer(self):
+        """Test removing previous allocated stock from customer"""
+
+        it = StockItem.objects.get(pk=2)
+
+        # First establish total stock for this part
+        allstock_before = StockItem.objects.filter(part=it.part).aggregate(Sum("quantity"))["quantity__sum"]
+
+        n = it.quantity
+        an = n - 10
+        customer = Company.objects.create(name="MyTestCompany")
+        order = SalesOrder.objects.create(description="Test order")
+
+        ait = it.allocateToCustomer(customer, quantity=an, order=order, user=None, notes='Allocated some stock')
+        ait.return_from_customer(it.location, None, notes="Stock removed from customer")
+
+        # When returned stock is returned to its original (parent) location, check that the parent has correct quantity
+        self.assertEqual(it.quantity, n)
+
+        ait = it.allocateToCustomer(customer, quantity=an, order=order, user=None, notes='Allocated some stock')
+        ait.return_from_customer(self.drawer3, None, notes="Stock removed from customer")
+
+        # Check correct assignment of the new location
+        self.assertEqual(ait.location, self.drawer3)
+        # We should be un allocated
+        self.assertFalse(ait.is_allocated())
+        # No customer should be assigned
+        self.assertFalse(ait.customer)
+        # We dont belong to anyone
+        self.assertFalse(ait.belongs_to)
+        # Assigned sales order should be None
+        self.assertFalse(ait.sales_order)
+
+        # Check that a tracking item was added
+        track = StockItemTracking.objects.filter(item=ait).latest('id')
+
+        self.assertEqual(track.tracking_type, StockHistoryCode.RETURNED_FROM_CUSTOMER)
+        self.assertIn('Stock removed from customer', track.notes)
+
+        # Establish total stock for the part after remove from customer to check that we still have the correct quantity in stock
+        allstock_after = StockItem.objects.filter(part=it.part).aggregate(Sum("quantity"))["quantity__sum"]
+        self.assertEqual(allstock_before, allstock_after)
+
     def test_take_stock(self):
         """Test stock removal."""
         it = StockItem.objects.get(pk=2)