From 3685ca4b95c43900717937fb3fca780c2ee82fc8 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 27 Apr 2020 11:32:20 +1000 Subject: [PATCH] Add some unit testing for the SalesOrder model --- .../migrations/0032_auto_20200427_0044.py | 18 +++ InvenTree/order/models.py | 19 ++- InvenTree/order/serializers.py | 6 +- .../templates/order/sales_order_detail.html | 12 +- InvenTree/order/test_sales_order.py | 138 ++++++++++++++++++ 5 files changed, 182 insertions(+), 11 deletions(-) create mode 100644 InvenTree/order/migrations/0032_auto_20200427_0044.py create mode 100644 InvenTree/order/test_sales_order.py diff --git a/InvenTree/order/migrations/0032_auto_20200427_0044.py b/InvenTree/order/migrations/0032_auto_20200427_0044.py new file mode 100644 index 0000000000..4648ca911b --- /dev/null +++ b/InvenTree/order/migrations/0032_auto_20200427_0044.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.5 on 2020-04-27 00:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0035_auto_20200406_0045'), + ('order', '0031_auto_20200426_0612'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='salesorderlineitem', + unique_together={('order', 'part')}, + ), + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 35661d60fe..0b2ebd3707 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -24,7 +24,7 @@ from stock import models as stock_models from company.models import Company, SupplierPart from InvenTree.fields import RoundingDecimalField -from InvenTree.helpers import decimal2string, normalize +from InvenTree.helpers import decimal2string from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus from InvenTree.models import InvenTreeAttachment @@ -461,6 +461,11 @@ class SalesOrderLineItem(OrderLineItem): part = models.ForeignKey('part.Part', on_delete=models.SET_NULL, related_name='sales_order_line_items', null=True, help_text=_('Part'), limit_choices_to={'salable': True}) + class Meta: + unique_together = [ + ('order', 'part'), + ] + def fulfilled_quantity(self): """ Return the total stock quantity fulfilled against this line item. @@ -482,6 +487,10 @@ class SalesOrderLineItem(OrderLineItem): def is_fully_allocated(self): """ Return True if this line item is fully allocated """ + + if self.order.status == SalesOrderStatus.SHIPPED: + return self.fulfilled_quantity() >= self.quantity + return self.allocated_quantity() >= self.quantity def is_over_allocated(self): @@ -561,12 +570,8 @@ class SalesOrderAllocation(models.Model): quantity = RoundingDecimalField(max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], default=1, help_text=_('Enter stock allocation quantity')) - def get_allocated(self): - """ String representation of the allocated quantity """ - if self.item.serial and self.quantity == 1: - return "# {sn}".format(sn=self.item.serial) - else: - return normalize(self.quantity) + def get_serial(self): + return self.item.serial def get_location(self): return self.item.location.id if self.item.location else None diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 0738b9dfbe..e0ef57f802 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -170,7 +170,8 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer): location_path = serializers.CharField(source='get_location_path') location_id = serializers.IntegerField(source='get_location') - allocated = serializers.CharField(source='get_allocated') + serial = serializers.CharField(source='get_serial') + quantity = serializers.FloatField() class Meta: model = SalesOrderAllocation @@ -178,7 +179,8 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer): fields = [ 'pk', 'line', - 'allocated', + 'serial', + 'quantity', 'location_id', 'location_path', 'item', diff --git a/InvenTree/order/templates/order/sales_order_detail.html b/InvenTree/order/templates/order/sales_order_detail.html index 9f21d8fc35..0ca63882b2 100644 --- a/InvenTree/order/templates/order/sales_order_detail.html +++ b/InvenTree/order/templates/order/sales_order_detail.html @@ -62,7 +62,15 @@ function showAllocationSubTable(index, row, element) { field: 'allocated', title: 'Quantity', formatter: function(value, row, index, field) { - return renderLink(value, `/stock/item/${row.item}/`); + var text = ''; + + if (row.serial != null && row.quantity == 1) { + text = `{% trans "Serial Number" %}: ${row.serial}`; + } else { + text = `{% trans "Quantity" %}: ${row.quantity}`; + } + + return renderLink(text, `/stock/item/${row.item}/`); }, }, { @@ -138,7 +146,7 @@ function showFulfilledSubTable(index, row, element) { field: 'stock', formatter: function(value, row) { var text = ''; - if (row.serial) { + if (row.serial && row.quantity == 1) { text = `{% trans "Serial Number" %}: ${row.serial}`; } else { text = `{% trans "Quantity" %}: ${row.quantity}`; diff --git a/InvenTree/order/test_sales_order.py b/InvenTree/order/test_sales_order.py new file mode 100644 index 0000000000..d0d34a517c --- /dev/null +++ b/InvenTree/order/test_sales_order.py @@ -0,0 +1,138 @@ +from django.test import TestCase + +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError + +from company.models import Company +from stock.models import StockItem +from order.models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation +from part.models import Part +from InvenTree import status_codes as status + + +class SalesOrderTest(TestCase): + """ + Run tests to ensure that the SalesOrder model is working correctly. + + """ + + def setUp(self): + + # Create a Company to ship the goods to + self.customer = Company.objects.create(name="ABC Co", description="My customer", is_customer=True) + + # Create a Part to ship + self.part = Part.objects.create(name='Spanner', salable=True, description='A spanner that I sell') + + # Create some stock! + StockItem.objects.create(part=self.part, quantity=100) + StockItem.objects.create(part=self.part, quantity=200) + + # Create a SalesOrder to ship against + self.order = SalesOrder.objects.create( + customer=self.customer, + reference='1234', + customer_reference='ABC 55555' + ) + + # Create a line item + self.line = SalesOrderLineItem.objects.create(quantity=50, order=self.order, part=self.part) + + def test_empty_order(self): + self.assertEqual(self.line.quantity, 50) + self.assertEqual(self.line.allocated_quantity(), 0) + self.assertEqual(self.line.fulfilled_quantity(), 0) + self.assertFalse(self.line.is_fully_allocated()) + self.assertFalse(self.line.is_over_allocated()) + + self.assertTrue(self.order.is_pending) + self.assertFalse(self.order.is_fully_allocated()) + + def test_add_duplicate_line_item(self): + # Adding a duplicate line item to a SalesOrder must throw an error + + with self.assertRaises(IntegrityError): + SalesOrderLineItem.objects.create(order=self.order, part=self.part) + + def allocate_stock(self, full=True): + # Allocate stock to the order + SalesOrderAllocation.objects.create( + line=self.line, + item=StockItem.objects.get(pk=1), + quantity=25) + + SalesOrderAllocation.objects.create( + line=self.line, + item=StockItem.objects.get(pk=2), + quantity=25 if full else 20 + ) + + def test_allocate_partial(self): + # Partially allocate stock + self.allocate_stock(False) + + self.assertFalse(self.order.is_fully_allocated()) + self.assertFalse(self.line.is_fully_allocated()) + self.assertEqual(self.line.allocated_quantity(), 45) + self.assertEqual(self.line.fulfilled_quantity(), 0) + + def test_allocate_full(self): + # Fully allocate stock + self.allocate_stock(True) + + self.assertTrue(self.order.is_fully_allocated()) + self.assertTrue(self.line.is_fully_allocated()) + self.assertEqual(self.line.allocated_quantity(), 50) + + def test_order_cancel(self): + # Allocate line items then cancel the order + + self.allocate_stock(True) + + self.assertEqual(SalesOrderAllocation.objects.count(), 2) + self.assertEqual(self.order.status, status.SalesOrderStatus.PENDING) + + self.order.cancel_order() + self.assertEqual(SalesOrderAllocation.objects.count(), 0) + self.assertEqual(self.order.status, status.SalesOrderStatus.CANCELLED) + + # Now try to ship it - should fail + with self.assertRaises(ValidationError): + self.order.ship_order(None) + + def test_ship_order(self): + # Allocate line items, then ship the order + + # Assert some stuff before we run the test + # Initially there are two stock items + self.assertEqual(StockItem.objects.count(), 2) + + # Take 25 units from each StockItem + self.allocate_stock(True) + + self.assertEqual(SalesOrderAllocation.objects.count(), 2) + + self.order.ship_order(None) + + # There should now be 4 stock items + self.assertEqual(StockItem.objects.count(), 4) + + self.assertEqual(StockItem.objects.get(pk=1).quantity, 75) + self.assertEqual(StockItem.objects.get(pk=2).quantity, 175) + self.assertEqual(StockItem.objects.get(pk=3).quantity, 25) + self.assertEqual(StockItem.objects.get(pk=3).quantity, 25) + + self.assertEqual(StockItem.objects.get(pk=1).sales_order, None) + self.assertEqual(StockItem.objects.get(pk=2).sales_order, None) + self.assertEqual(StockItem.objects.get(pk=3).sales_order, self.order) + self.assertEqual(StockItem.objects.get(pk=4).sales_order, self.order) + + # And no allocations + self.assertEqual(SalesOrderAllocation.objects.count(), 0) + + self.assertEqual(self.order.status, status.SalesOrderStatus.SHIPPED) + + self.assertTrue(self.order.is_fully_allocated()) + self.assertTrue(self.line.is_fully_allocated()) + self.assertEqual(self.line.fulfilled_quantity(), 50) + self.assertEqual(self.line.allocated_quantity(), 0)