Repository: inventree/InvenTree

Issue:
No Pricing information found when fractional amount is used
I have a part which uses a cable as a sub-part. The cable comes in rolls of 3 meters, so I created a supplier part where 1 unit costs a third of the manual prize. 

If I use a fractional amount of 0.25 (representing a 25 cm piece of ribbon cable in my case) I get a hint that says there is no pricing information available:  
![2020-12-20 14_08_59-Window](https://user-images.githubusercontent.com/6620266/102714243-9cb88780-42cd-11eb-9909-2bbce786ea89.png)  

If I use anything bigger than 1 it works as expected, even with 1.5:  
![2020-12-20 14_11_22-Window](https://user-images.githubusercontent.com/6620266/102714254-afcb5780-42cd-11eb-8824-4f742105ee9c.png)  
![2020-12-20 14_12_29-Window](https://user-images.githubusercontent.com/6620266/102714266-b659cf00-42cd-11eb-801a-dd54a71e3565.png)  

I also tried to enter a Prize-break for an amount <1, but this is not allowed. Expected behaviour would be that fractional amounts <1 cost a fraction of the prize.



Discussion:
@atoav This is an interesting use-case which isn't supported yet. I also think that price for fractional quantity should be allowed 👍 

If you are comfortable with Django/Python you can submit a PR 😃 

Resolving patch:
diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py
--- a/InvenTree/company/models.py
+++ b/InvenTree/company/models.py
@@ -6,7 +6,7 @@
 from __future__ import unicode_literals
 
 import os
-
+import decimal
 import math
 
 from django.utils.translation import ugettext_lazy as _
@@ -566,12 +566,15 @@ def get_price(self, quantity, moq=True, multiples=True, currency=None):
         - If order multiples are to be observed, then we need to calculate based on that, too
         """
 
-        price_breaks = self.price_breaks.filter(quantity__lte=quantity)
+        price_breaks = self.price_breaks.all()
 
         # No price break information available?
         if len(price_breaks) == 0:
             return None
 
+        # Check if quantity is fraction and disable multiples
+        multiples = (quantity % 1 == 0)
+
         # Order multiples
         if multiples:
             quantity = int(math.ceil(quantity / self.multiple) * self.multiple)
@@ -584,7 +587,12 @@ def get_price(self, quantity, moq=True, multiples=True, currency=None):
             # Default currency selection
             currency = common.models.InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY')
 
+        pb_min = None
         for pb in self.price_breaks.all():
+            # Store smallest price break
+            if not pb_min:
+                pb_min = pb
+
             # Ignore this pricebreak (quantity is too high)
             if pb.quantity > quantity:
                 continue
@@ -598,6 +606,17 @@ def get_price(self, quantity, moq=True, multiples=True, currency=None):
                 # Convert everything to the selected currency
                 pb_cost = pb.convert_to(currency)
 
+        # Use smallest price break
+        if not pb_found and pb_min:
+            # Update price break information
+            pb_quantity = pb_min.quantity
+            pb_cost = pb_min.convert_to(currency)
+            # Trigger cost calculation using smallest price break
+            pb_found = True
+        
+        # Convert quantity to decimal.Decimal format
+        quantity = decimal.Decimal(f'{quantity}')
+
         if pb_found:
             cost = pb_cost * quantity
             return normalize(cost + self.base_cost)
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -1957,6 +1957,11 @@ class PartPricing(AjaxView):
 
     role_required = ['sales_order.view', 'part.view']
     
+    def get_quantity(self):
+        """ Return set quantity in decimal format """
+        
+        return Decimal(self.request.POST.get('quantity', 1))
+
     def get_part(self):
         try:
             return Part.objects.get(id=self.kwargs['pk'])
@@ -1965,12 +1970,12 @@ def get_part(self):
 
     def get_pricing(self, quantity=1, currency=None):
 
-        try:
-            quantity = int(quantity)
-        except ValueError:
-            quantity = 1
+        # try:
+        #     quantity = int(quantity)
+        # except ValueError:
+        #     quantity = 1
 
-        if quantity < 1:
+        if quantity <= 0:
             quantity = 1
 
         # TODO - Capacity for price comparison in different currencies
@@ -2000,16 +2005,19 @@ def get_pricing(self, quantity=1, currency=None):
                 min_buy_price /= scaler
                 max_buy_price /= scaler
 
+                min_unit_buy_price = round(min_buy_price / quantity, 3)
+                max_unit_buy_price = round(max_buy_price / quantity, 3)
+
                 min_buy_price = round(min_buy_price, 3)
                 max_buy_price = round(max_buy_price, 3)
 
                 if min_buy_price:
                     ctx['min_total_buy_price'] = min_buy_price
-                    ctx['min_unit_buy_price'] = min_buy_price / quantity
+                    ctx['min_unit_buy_price'] = min_unit_buy_price
 
                 if max_buy_price:
                     ctx['max_total_buy_price'] = max_buy_price
-                    ctx['max_unit_buy_price'] = max_buy_price / quantity
+                    ctx['max_unit_buy_price'] = max_unit_buy_price
 
         # BOM pricing information
         if part.bom_count > 0:
@@ -2022,16 +2030,19 @@ def get_pricing(self, quantity=1, currency=None):
                 min_bom_price /= scaler
                 max_bom_price /= scaler
 
+                min_unit_bom_price = round(min_bom_price / quantity, 3)
+                max_unit_bom_price = round(max_bom_price / quantity, 3)
+
                 min_bom_price = round(min_bom_price, 3)
                 max_bom_price = round(max_bom_price, 3)
 
                 if min_bom_price:
                     ctx['min_total_bom_price'] = min_bom_price
-                    ctx['min_unit_bom_price'] = min_bom_price / quantity
+                    ctx['min_unit_bom_price'] = min_unit_bom_price
                 
                 if max_bom_price:
                     ctx['max_total_bom_price'] = max_bom_price
-                    ctx['max_unit_bom_price'] = max_bom_price / quantity
+                    ctx['max_unit_bom_price'] = max_unit_bom_price
 
         return ctx
 
@@ -2043,10 +2054,11 @@ def post(self, request, *args, **kwargs):
 
         currency = None
 
-        try:
-            quantity = int(self.request.POST.get('quantity', 1))
-        except ValueError:
-            quantity = 1
+        quantity = self.get_quantity()
+
+        # Retain quantity value set by user
+        form = self.form_class()
+        form.fields['quantity'].initial = quantity
 
         # TODO - How to handle pricing in different currencies?
         currency = None
@@ -2056,7 +2068,7 @@ def post(self, request, *args, **kwargs):
             'form_valid': False,
         }
 
-        return self.renderJsonResponse(request, self.form_class(), data=data, context=self.get_pricing(quantity, currency))
+        return self.renderJsonResponse(request, form, data=data, context=self.get_pricing(quantity, currency))
 
 
 class PartParameterTemplateCreate(AjaxCreateView):


Test patch:
diff --git a/InvenTree/company/tests.py b/InvenTree/company/tests.py
--- a/InvenTree/company/tests.py
+++ b/InvenTree/company/tests.py
@@ -5,6 +5,7 @@
 from django.core.exceptions import ValidationError
 
 import os
+from decimal import Decimal
 
 from .models import Company, Contact, ManufacturerPart, SupplierPart
 from .models import rename_company_image
@@ -103,8 +104,9 @@ def test_quantity_pricing(self):
         self.assertEqual(p(100), 350)
 
         p = self.acme0002.get_price
-        self.assertEqual(p(1), None)
-        self.assertEqual(p(2), None)
+        self.assertEqual(p(0.5), 3.5)
+        self.assertEqual(p(1), 7)
+        self.assertEqual(p(2), 14)
         self.assertEqual(p(5), 35)
         self.assertEqual(p(45), 315)
         self.assertEqual(p(55), 68.75)
@@ -112,6 +114,7 @@ def test_quantity_pricing(self):
     def test_part_pricing(self):
         m2x4 = Part.objects.get(name='M2x4 LPHS')
 
+        self.assertEqual(m2x4.get_price_info(5.5), "38.5 - 41.25")
         self.assertEqual(m2x4.get_price_info(10), "70 - 75")
         self.assertEqual(m2x4.get_price_info(100), "125 - 350")
 
@@ -121,7 +124,8 @@ def test_part_pricing(self):
         
         m3x12 = Part.objects.get(name='M3x12 SHCS')
 
-        self.assertIsNone(m3x12.get_price_info(3))
+        self.assertEqual(m3x12.get_price_info(0.3), Decimal('2.4'))
+        self.assertEqual(m3x12.get_price_info(3), Decimal('24'))
         self.assertIsNotNone(m3x12.get_price_info(50))
 
     def test_currency_validation(self):