From f00ec26efde9d37a0afb967f357668a6c85661b9 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 25 Oct 2021 13:09:06 +1100 Subject: [PATCH 01/54] Create SalesOrderShipment model --- .../migrations/0053_salesordershipment.py | 29 +++++++++ InvenTree/order/models.py | 62 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 InvenTree/order/migrations/0053_salesordershipment.py diff --git a/InvenTree/order/migrations/0053_salesordershipment.py b/InvenTree/order/migrations/0053_salesordershipment.py new file mode 100644 index 0000000000..c981e9c4c3 --- /dev/null +++ b/InvenTree/order/migrations/0053_salesordershipment.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2.5 on 2021-10-25 02:08 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import markdownx.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('order', '0052_auto_20211014_0631'), + ] + + operations = [ + migrations.CreateModel( + name='SalesOrderShipment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Shipped'), (40, 'Cancelled'), (50, 'Lost'), (60, 'Returned')], default=10, help_text='Shipment status', verbose_name='Status')), + ('shipment_date', models.DateField(blank=True, help_text='Date of shipment', null=True, verbose_name='Shipment Date')), + ('reference', models.CharField(blank=True, help_text='Shipment reference', max_length=100, verbose_name='Reference')), + ('notes', markdownx.models.MarkdownxField(blank=True, help_text='Shipment notes', verbose_name='Notes')), + ('checked_by', models.ForeignKey(blank=True, help_text='User who checked this shipment', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Checked By')), + ('order', models.ForeignKey(help_text='Sales Order', on_delete=django.db.models.deletion.CASCADE, related_name='shipments', to='order.salesorder', verbose_name='Order')), + ], + ), + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 0c45e3746a..be75cf9821 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -903,6 +903,68 @@ class SalesOrderLineItem(OrderLineItem): return self.allocated_quantity() > self.quantity +class SalesOrderShipment(models.Model): + """ + The SalesOrderShipment model represents a physical shipment made against a SalesOrder. + + - Points to a single SalesOrder object + - Multiple SalesOrderAllocation objects point to a particular SalesOrderShipment + - When a given SalesOrderShipment is "shipped", stock items are removed from stock + + Attributes: + order: SalesOrder reference + status: Status of this shipment (see SalesOrderStatus) + shipment_date: Date this shipment was "shipped" (or null) + checked_by: User reference field indicating who checked this order + reference: Custom reference text for this shipment (e.g. consignment number?) + notes: Custom notes field for this shipment + """ + + order = models.ForeignKey( + SalesOrder, + on_delete=models.CASCADE, + blank=False, null=False, + related_name='shipments', + verbose_name=_('Order'), + help_text=_('Sales Order'), + ) + + status = models.PositiveIntegerField( + default=SalesOrderStatus.PENDING, + choices=SalesOrderStatus.items(), + verbose_name=_('Status'), + help_text=_('Shipment status'), + ) + + shipment_date = models.DateField( + null=True, blank=True, + verbose_name=_('Shipment Date'), + help_text=_('Date of shipment'), + ) + + checked_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + blank=True, null=True, + verbose_name=_('Checked By'), + help_text=_('User who checked this shipment'), + related_name='+', + ) + + reference = models.CharField( + max_length=100, + blank=True, + verbose_name=('Reference'), + help_text=_('Shipment reference'), + ) + + notes = MarkdownxField( + blank=True, + verbose_name=_('Notes'), + help_text=_('Shipment notes'), + ) + + class SalesOrderAllocation(models.Model): """ This model is used to 'allocate' stock items to a SalesOrder. From 2f7e0974b7e8643e5d508f06f3e5e049f537f081 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 25 Oct 2021 17:42:56 +1100 Subject: [PATCH 02/54] Add 'shipment' foreign-key field to SalesOrderAllocation model --- .../0054_salesorderallocation_shipment.py | 19 +++++++++++++++++++ InvenTree/order/models.py | 14 ++++++++++++++ InvenTree/users/models.py | 3 ++- 3 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 InvenTree/order/migrations/0054_salesorderallocation_shipment.py diff --git a/InvenTree/order/migrations/0054_salesorderallocation_shipment.py b/InvenTree/order/migrations/0054_salesorderallocation_shipment.py new file mode 100644 index 0000000000..cfb74dd859 --- /dev/null +++ b/InvenTree/order/migrations/0054_salesorderallocation_shipment.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.5 on 2021-10-25 06:42 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0053_salesordershipment'), + ] + + operations = [ + migrations.AddField( + model_name='salesorderallocation', + name='shipment', + field=models.ForeignKey(blank=True, help_text='Sales order shipment reference', null=True, on_delete=django.db.models.deletion.CASCADE, to='order.salesordershipment', verbose_name='Shipment'), + ), + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index be75cf9821..0566ec9312 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -973,6 +973,7 @@ class SalesOrderAllocation(models.Model): Attributes: line: SalesOrderLineItem reference + shipment: SalesOrderShipment reference item: StockItem reference quantity: Quantity to take from the StockItem @@ -1028,6 +1029,11 @@ class SalesOrderAllocation(models.Model): if self.item.serial and not self.quantity == 1: errors['quantity'] = _('Quantity must be 1 for serialized stock item') + + + # TODO: Ensure that the "shipment" points to the same "order"! + + if len(errors) > 0: raise ValidationError(errors) @@ -1037,6 +1043,14 @@ class SalesOrderAllocation(models.Model): verbose_name=_('Line'), related_name='allocations') + shipment = models.ForeignKey( + SalesOrderShipment, + on_delete=models.CASCADE, + null=True, blank=True, + verbose_name=_('Shipment'), + help_text=_('Sales order shipment reference'), + ) + item = models.ForeignKey( 'stock.StockItem', on_delete=models.CASCADE, diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index d31f2a9905..88052fd40d 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -132,9 +132,10 @@ class RuleSet(models.Model): 'sales_order': [ 'company_company', 'order_salesorder', + 'order_salesorderallocation', 'order_salesorderattachment', 'order_salesorderlineitem', - 'order_salesorderallocation', + 'order_salesordershipment', ] } From ce5b47460a79c132235b4d9a722dac24095f4d47 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 25 Oct 2021 22:35:27 +1100 Subject: [PATCH 03/54] Added data migration for existing SalesOrder instances - If a SalesOrder is "PENDING" or there are allocations available, a shipment is created --- .../migrations/0053_salesordershipment.py | 1 - .../migrations/0055_auto_20211025_0645.py | 89 +++++++++++++++++++ InvenTree/order/models.py | 7 -- InvenTree/order/test_migrations.py | 47 ++++++++++ InvenTree/templates/js/translated/order.js | 1 + 5 files changed, 137 insertions(+), 8 deletions(-) create mode 100644 InvenTree/order/migrations/0055_auto_20211025_0645.py diff --git a/InvenTree/order/migrations/0053_salesordershipment.py b/InvenTree/order/migrations/0053_salesordershipment.py index c981e9c4c3..e20a95e3f8 100644 --- a/InvenTree/order/migrations/0053_salesordershipment.py +++ b/InvenTree/order/migrations/0053_salesordershipment.py @@ -18,7 +18,6 @@ class Migration(migrations.Migration): name='SalesOrderShipment', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('status', models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Shipped'), (40, 'Cancelled'), (50, 'Lost'), (60, 'Returned')], default=10, help_text='Shipment status', verbose_name='Status')), ('shipment_date', models.DateField(blank=True, help_text='Date of shipment', null=True, verbose_name='Shipment Date')), ('reference', models.CharField(blank=True, help_text='Shipment reference', max_length=100, verbose_name='Reference')), ('notes', markdownx.models.MarkdownxField(blank=True, help_text='Shipment notes', verbose_name='Notes')), diff --git a/InvenTree/order/migrations/0055_auto_20211025_0645.py b/InvenTree/order/migrations/0055_auto_20211025_0645.py new file mode 100644 index 0000000000..13ea184cfe --- /dev/null +++ b/InvenTree/order/migrations/0055_auto_20211025_0645.py @@ -0,0 +1,89 @@ +# Generated by Django 3.2.5 on 2021-10-25 06:45 + +from django.db import migrations + + +from InvenTree.status_codes import SalesOrderStatus + + +def add_shipment(apps, schema_editor): + """ + Create a SalesOrderShipment for each existing SalesOrder instance. + + Any "allocations" are marked against that shipment. + + For each existing SalesOrder instance, we create a default SalesOrderShipment, + and associate each SalesOrderAllocation with this shipment + """ + + Allocation = apps.get_model('order', 'salesorderallocation') + SalesOrder = apps.get_model('order', 'salesorder') + Shipment = apps.get_model('order', 'salesordershipment') + + n = 0 + + for order in SalesOrder.objects.all(): + + """ + We only create an automatic shipment for "PENDING" orders, + as SalesOrderAllocations were historically deleted for "SHIPPED" or "CANCELLED" orders + """ + + allocations = Allocation.objects.filter( + line__order=order + ) + + if allocations.count() == 0 and order.status != SalesOrderStatus.PENDING: + continue + + # Create a new Shipment instance against this order + shipment = Shipment.objects.create( + order=order, + ) + + shipment.save() + + # Iterate through each allocation associated with this order + for allocation in allocations: + allocation.shipment = shipment + allocation.save() + + n += 1 + + if n > 0: + print(f"\nCreated SalesOrderShipment for {n} SalesOrder instances") + + +def reverse_add_shipment(apps, schema_editor): + """ + Reverse the migration, delete and SalesOrderShipment instances + """ + + Allocation = apps.get_model('order', 'salesorderallocation') + + # First, ensure that all SalesOrderAllocation objects point to a null shipment + for allocation in Allocation.objects.exclude(shipment=None): + allocation.shipment = None + allocation.save() + + SOS = apps.get_model('order', 'salesordershipment') + + n = SOS.objects.count() + + print(f"Deleting {n} SalesOrderShipment instances") + + SOS.objects.all().delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0054_salesorderallocation_shipment'), + ] + + operations = [ + migrations.RunPython( + add_shipment, + reverse_code=reverse_add_shipment, + ) + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 0566ec9312..dcb63430a3 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -929,13 +929,6 @@ class SalesOrderShipment(models.Model): help_text=_('Sales Order'), ) - status = models.PositiveIntegerField( - default=SalesOrderStatus.PENDING, - choices=SalesOrderStatus.items(), - verbose_name=_('Status'), - help_text=_('Shipment status'), - ) - shipment_date = models.DateField( null=True, blank=True, verbose_name=_('Shipment Date'), diff --git a/InvenTree/order/test_migrations.py b/InvenTree/order/test_migrations.py index b7db1f1b70..6e4d6668d3 100644 --- a/InvenTree/order/test_migrations.py +++ b/InvenTree/order/test_migrations.py @@ -5,6 +5,7 @@ Unit tests for the 'order' model data migrations from django_test_migrations.contrib.unittest_case import MigratorTestCase from InvenTree import helpers +from InvenTree.status_codes import SalesOrderStatus class TestForwardMigrations(MigratorTestCase): @@ -57,3 +58,49 @@ class TestForwardMigrations(MigratorTestCase): # The integer reference field must have been correctly updated self.assertEqual(order.reference_int, ii) + + +class TestShipmentMigration(MigratorTestCase): + """ + Test data migration for the "SalesOrderShipment" model + """ + + migrate_from = ('order', '0051_auto_20211014_0623') + migrate_to = ('order', '0055_auto_20211025_0645') + + def prepare(self): + """ + Create an initial SalesOrder + """ + + Company = self.old_state.apps.get_model('company', 'company') + + customer = Company.objects.create( + name='My customer', + description='A customer we sell stuff too', + is_customer=True + ) + + SalesOrder = self.old_state.apps.get_model('order', 'salesorder') + + for ii in range(5): + order = SalesOrder.objects.create( + reference=f'SO{ii}', + customer=customer, + description='A sales order for stuffs', + status=SalesOrderStatus.PENDING, + ) + + order.save() + + def test_shipment_creation(self): + """ + Check that a SalesOrderShipment has been created + """ + + SalesOrder = self.new_state.apps.get_model('order', 'salesorder') + Shipment = self.new_state.apps.get_model('order', 'salesordershipment') + + # Check that the correct number of Shipments have been created + self.assertEqual(SalesOrder.objects.count(), 5) + self.assertEqual(Shipment.objects.count(), 5) diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 75f71e3c4c..e55371703c 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -1681,6 +1681,7 @@ function loadSalesOrderLineItemTable(table, options={}) { location_detail: true, in_stock: true, part: line_item.part, + include_variants: false, exclude_so_allocation: options.order, }, auto_fill: true, From d31f2be95564c8e83e1e7e771ca8f244d0fe6b34 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 25 Oct 2021 22:47:41 +1100 Subject: [PATCH 04/54] Make "shipment" field required for a SalesOrderAllocation - Deleting a "Shipment" will delete any "Allocation" objects which reference it - Improve existing data migration for new shipment model --- ...056_alter_salesorderallocation_shipment.py | 19 ++++++++++++++ InvenTree/order/models.py | 1 - InvenTree/order/test_migrations.py | 25 ++++++++++++++++--- 3 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 InvenTree/order/migrations/0056_alter_salesorderallocation_shipment.py diff --git a/InvenTree/order/migrations/0056_alter_salesorderallocation_shipment.py b/InvenTree/order/migrations/0056_alter_salesorderallocation_shipment.py new file mode 100644 index 0000000000..7a2c255be3 --- /dev/null +++ b/InvenTree/order/migrations/0056_alter_salesorderallocation_shipment.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.5 on 2021-10-25 11:40 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0055_auto_20211025_0645'), + ] + + operations = [ + migrations.AlterField( + model_name='salesorderallocation', + name='shipment', + field=models.ForeignKey(help_text='Sales order shipment reference', on_delete=django.db.models.deletion.CASCADE, to='order.salesordershipment', verbose_name='Shipment'), + ), + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index dcb63430a3..7277897488 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -1039,7 +1039,6 @@ class SalesOrderAllocation(models.Model): shipment = models.ForeignKey( SalesOrderShipment, on_delete=models.CASCADE, - null=True, blank=True, verbose_name=_('Shipment'), help_text=_('Sales order shipment reference'), ) diff --git a/InvenTree/order/test_migrations.py b/InvenTree/order/test_migrations.py index 6e4d6668d3..d62449613e 100644 --- a/InvenTree/order/test_migrations.py +++ b/InvenTree/order/test_migrations.py @@ -27,10 +27,12 @@ class TestForwardMigrations(MigratorTestCase): supplier = Company.objects.create( name='Supplier A', description='A great supplier!', - is_supplier=True + is_supplier=True, + is_customer=True, ) PurchaseOrder = self.old_state.apps.get_model('order', 'purchaseorder') + SalesOrder = self.old_state.apps.get_model('order', 'salesorder') # Create some orders for ii in range(10): @@ -45,19 +47,32 @@ class TestForwardMigrations(MigratorTestCase): with self.assertRaises(AttributeError): print(order.reference_int) + sales_order = SalesOrder.objects.create( + customer=supplier, + reference=f"{ii}-xyz", + description="A test sales order", + ) + + # Initially, the 'reference_int' field is unavailable + with self.assertRaises(AttributeError): + print(sales_order.reference_int) + def test_ref_field(self): """ Test that the 'reference_int' field has been created and is filled out correctly """ PurchaseOrder = self.new_state.apps.get_model('order', 'purchaseorder') + SalesOrder = self.new_state.apps.get_model('order', 'salesorder') for ii in range(10): - order = PurchaseOrder.objects.get(reference=f"{ii}-abcde") + po = PurchaseOrder.objects.get(reference=f"{ii}-abcde") + so = SalesOrder.objects.get(reference=f"{ii}-xyz") # The integer reference field must have been correctly updated - self.assertEqual(order.reference_int, ii) + self.assertEqual(po.reference_int, ii) + self.assertEqual(so.reference_int, ii) class TestShipmentMigration(MigratorTestCase): @@ -93,6 +108,10 @@ class TestShipmentMigration(MigratorTestCase): order.save() + # The "shipment" model does not exist yet + with self.assertRaises(LookupError): + self.old_state.apps.get_model('order', 'salesordershipment') + def test_shipment_creation(self): """ Check that a SalesOrderShipment has been created From 9fcc55d71df48a78002b8cacea5ecc30cc541440 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 25 Oct 2021 22:50:10 +1100 Subject: [PATCH 05/54] Admin page for new model --- InvenTree/order/admin.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/InvenTree/order/admin.py b/InvenTree/order/admin.py index 25b0922291..ed9bc74201 100644 --- a/InvenTree/order/admin.py +++ b/InvenTree/order/admin.py @@ -10,7 +10,7 @@ from import_export.fields import Field from .models import PurchaseOrder, PurchaseOrderLineItem from .models import SalesOrder, SalesOrderLineItem -from .models import SalesOrderAllocation +from .models import SalesOrderShipment, SalesOrderAllocation class PurchaseOrderLineItemInlineAdmin(admin.StackedInline): @@ -124,6 +124,15 @@ class SalesOrderLineItemAdmin(ImportExportModelAdmin): ) +class SalesOrderShipmentAdmin(ImportExportModelAdmin): + + list_display = [ + 'order', + 'shipment_date', + 'reference', + ] + + class SalesOrderAllocationAdmin(ImportExportModelAdmin): list_display = ( @@ -139,4 +148,5 @@ admin.site.register(PurchaseOrderLineItem, PurchaseOrderLineItemAdmin) admin.site.register(SalesOrder, SalesOrderAdmin) admin.site.register(SalesOrderLineItem, SalesOrderLineItemAdmin) +admin.site.register(SalesOrderShipment, SalesOrderShipmentAdmin) admin.site.register(SalesOrderAllocation, SalesOrderAllocationAdmin) From e9e4d135416e554bffea39d0997d8540087e5773 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 25 Oct 2021 23:34:58 +1100 Subject: [PATCH 06/54] Add list and detail API endpoints for SalesOrderShipment - Filter by order - Filter by "shipped" status - SalesOrderShipment serializer includes information on items allocated to that shipment --- InvenTree/order/api.py | 60 ++++++++++++++++++- ...056_alter_salesorderallocation_shipment.py | 2 +- InvenTree/order/models.py | 1 + InvenTree/order/serializers.py | 23 ++++++- 4 files changed, 82 insertions(+), 4 deletions(-) diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index f4ebff4dfb..80708c9922 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -26,10 +26,11 @@ from .models import PurchaseOrder, PurchaseOrderLineItem from .models import PurchaseOrderAttachment from .serializers import POSerializer, POLineItemSerializer, POAttachmentSerializer -from .models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation +from .models import SalesOrder, SalesOrderLineItem, SalesOrderShipment, SalesOrderAllocation from .models import SalesOrderAttachment + from .serializers import SalesOrderSerializer, SOLineItemSerializer, SOAttachmentSerializer -from .serializers import SalesOrderAllocationSerializer +from .serializers import SalesOrderShipmentSerializer, SalesOrderAllocationSerializer from .serializers import POReceiveSerializer @@ -700,6 +701,54 @@ class SOAllocationList(generics.ListCreateAPIView): ] +class SOShipmentFilter(rest_filters.FilterSet): + """ + Custom filterset for the SOShipmentList endpoint + """ + + shipped = rest_filters.BooleanFilter(label='shipped', method='filter_shipped') + + def filter_shipped(self, queryset, name, value): + + value = str2bool(value) + + if value: + queryset = queryset.exclude(shipment_date=None) + else: + queryset = queryset.filter(shipment_date=None) + + return queryset + + class Meta: + model = SalesOrderShipment + fields = [ + 'order', + ] + + +class SOShipmentList(generics.ListCreateAPIView): + """ + API list endpoint for SalesOrderShipment model + """ + + queryset = SalesOrderShipment.objects.all() + serializer_class = SalesOrderShipmentSerializer + filterset_class = SOShipmentFilter + + filter_backends = [ + rest_filters.DjangoFilterBackend, + ] + + +class SOShipmentDetail(generics.RetrieveUpdateAPIView): + """ + API detail endpooint for SalesOrderShipment model + """ + + queryset = SalesOrderShipment.objects.all() + serializer_class = SalesOrderShipmentSerializer + + class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin): """ API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload) @@ -760,6 +809,13 @@ order_api_urls = [ url(r'^.*$', SOAttachmentList.as_view(), name='api-so-attachment-list'), ])), + url(r'^shipment/', include([ + url(r'^(?P\d)+/', include([ + url(r'^.*$', SOShipmentDetail.as_view(), name='api-so-shipment-detail'), + ])), + url(r'^.*$', SOShipmentList.as_view(), name='api-so-shipment-list'), + ])), + url(r'^(?P\d+)/$', SODetail.as_view(), name='api-so-detail'), url(r'^.*$', SOList.as_view(), name='api-so-list'), ])), diff --git a/InvenTree/order/migrations/0056_alter_salesorderallocation_shipment.py b/InvenTree/order/migrations/0056_alter_salesorderallocation_shipment.py index 7a2c255be3..340a8d4ab5 100644 --- a/InvenTree/order/migrations/0056_alter_salesorderallocation_shipment.py +++ b/InvenTree/order/migrations/0056_alter_salesorderallocation_shipment.py @@ -14,6 +14,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='salesorderallocation', name='shipment', - field=models.ForeignKey(help_text='Sales order shipment reference', on_delete=django.db.models.deletion.CASCADE, to='order.salesordershipment', verbose_name='Shipment'), + field=models.ForeignKey(help_text='Sales order shipment reference', on_delete=django.db.models.deletion.CASCADE, related_name='allocations', to='order.salesordershipment', verbose_name='Shipment'), ), ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 7277897488..c6ff6dc5bd 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -1039,6 +1039,7 @@ class SalesOrderAllocation(models.Model): shipment = models.ForeignKey( SalesOrderShipment, on_delete=models.CASCADE, + related_name='allocations', verbose_name=_('Shipment'), help_text=_('Sales order shipment reference'), ) diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 40cd2def58..70bef86ae4 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -34,7 +34,7 @@ from stock.serializers import LocationBriefSerializer, StockItemSerializer, Loca from .models import PurchaseOrder, PurchaseOrderLineItem from .models import PurchaseOrderAttachment, SalesOrderAttachment from .models import SalesOrder, SalesOrderLineItem -from .models import SalesOrderAllocation +from .models import SalesOrderShipment, SalesOrderAllocation from common.settings import currency_code_mappings @@ -590,6 +590,27 @@ class SOLineItemSerializer(InvenTreeModelSerializer): ] +class SalesOrderShipmentSerializer(InvenTreeModelSerializer): + """ + Serializer for the SalesOrderShipment class + """ + + allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True) + + class Meta: + model = SalesOrderShipment + + fields = [ + 'pk', + 'order', + 'allocations', + 'shipment_date', + 'checked_by', + 'reference', + 'notes', + ] + + class SOAttachmentSerializer(InvenTreeAttachmentSerializer): """ Serializers for the SalesOrderAttachment model From e7c25126a48a893d7ac363924bf94d9bd2405994 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 26 Oct 2021 00:17:17 +1100 Subject: [PATCH 07/54] Construct table of "shipments" --- InvenTree/order/api.py | 2 +- .../migrations/0055_auto_20211025_0645.py | 3 + .../templates/order/sales_order_detail.html | 43 ++++++ .../order/templates/order/so_navbar.html | 7 + InvenTree/templates/js/translated/order.js | 122 +++++++++++++++++- 5 files changed, 170 insertions(+), 7 deletions(-) diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 80708c9922..954295ef8e 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -810,7 +810,7 @@ order_api_urls = [ ])), url(r'^shipment/', include([ - url(r'^(?P\d)+/', include([ + url(r'^(?P\d+)/', include([ url(r'^.*$', SOShipmentDetail.as_view(), name='api-so-shipment-detail'), ])), url(r'^.*$', SOShipmentList.as_view(), name='api-so-shipment-list'), diff --git a/InvenTree/order/migrations/0055_auto_20211025_0645.py b/InvenTree/order/migrations/0055_auto_20211025_0645.py index 13ea184cfe..d45e92aead 100644 --- a/InvenTree/order/migrations/0055_auto_20211025_0645.py +++ b/InvenTree/order/migrations/0055_auto_20211025_0645.py @@ -41,6 +41,9 @@ def add_shipment(apps, schema_editor): order=order, ) + if order.status == SalesOrderStatus.SHIPPED: + shipment.shipment_date = order.shipment_date + shipment.save() # Iterate through each allocation associated with this order diff --git a/InvenTree/order/templates/order/sales_order_detail.html b/InvenTree/order/templates/order/sales_order_detail.html index cddba74cd6..87b6d80acf 100644 --- a/InvenTree/order/templates/order/sales_order_detail.html +++ b/InvenTree/order/templates/order/sales_order_detail.html @@ -33,6 +33,32 @@ +
+ {% if order.is_pending %} +
+

{% trans "Pending Shipments" %}

+
+
+ {% if roles.sales_order.change %} +
+
+ +
+
+ {% endif %} +
+
+ {% endif %} +
+

{% trans "Completed Shipments" %}

+
+
+
+
+
+

{% trans "Build Orders" %}

@@ -79,6 +105,23 @@ {% block js_ready %} {{ block.super }} + // Callback when the "shipments" panel is first loaded + onPanelLoad('order-shipments', function() { + + {% if order.is_pending %} + loadSalesOrderShipmentTable('#pending-shipments-table', { + order: {{ order.pk }}, + shipped: false, + }); + {% endif %} + + loadSalesOrderShipmentTable('#completed-shipments-table', { + order: {{ order.pk }}, + shipped: true, + }); + + }); + $('#edit-notes').click(function() { constructForm('{% url "api-so-detail" order.pk %}', { fields: { diff --git a/InvenTree/order/templates/order/so_navbar.html b/InvenTree/order/templates/order/so_navbar.html index 710976ed3f..814df0fca6 100644 --- a/InvenTree/order/templates/order/so_navbar.html +++ b/InvenTree/order/templates/order/so_navbar.html @@ -16,6 +16,13 @@ +
  • + + + {% trans "Shipments" %} + +
  • +
  • diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index e55371703c..6e69eb6a02 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -26,6 +26,7 @@ loadPurchaseOrderTable, loadSalesOrderAllocationTable, loadSalesOrderLineItemTable, + loadSalesOrderShipmentTable, loadSalesOrderTable, newPurchaseOrderFromOrderWizard, newSupplierPartFromOrderWizard, @@ -1100,6 +1101,117 @@ function loadSalesOrderTable(table, options) { } +/* + * Load a table displaying Shipment information against a particular order + */ +function loadSalesOrderShipmentTable(table, options={}) { + + options.table = table; + + options.params = options.params || {}; + + // Filter by order + options.params.order = options.order; + + // Filter by "shipped" status + options.params.shipped = options.shipped || false; + + var filters = loadTableFilters('salesordershipment'); + + for (var key in options.params) { + filters[key] = options.params[key]; + } + + var todo = "Setup filter list for this table"; + + function makeShipmentActions(row) { + // Construct "actions" for the given shipment row + var pk = row.pk; + + var html = `
    `; + + html += makeIconButton('fa-edit icon-blue', 'button-shipment-edit', pk, '{% trans "Edit shipment" %}'); + + html += `
    `; + + return html; + + } + + function setupShipmentCallbacks() { + // Setup action button callbacks + + $(table).find('.button-shipment-edit').click(function() { + var pk = $(this).attr('pk'); + + constructForm(`/api/order/so/shipment/${pk}/`, { + fields: { + reference: {}, + }, + title: '{% trans "Edit Shipment" %}', + onSuccess: function() { + $(table).bootstrapTable('refresh'); + } + }); + }); + } + + $(table).inventreeTable({ + url: '{% url "api-so-shipment-list" %}', + queryParams: filters, + original: options.params, + name: options.name || 'salesordershipment', + search: false, + paginationVAlign: 'bottom', + showColumns: true, + detailView: true, + detailViewByClick: false, + detailFilter: function(index, row) { + return row.allocations.length > 0; + }, + detailFormatter: function(index, row, element) { + return showAllocationSubTable(index, row, element, options); + }, + onPostBody: setupShipmentCallbacks, + formatNoMatches: function() { + return '{% trans "No matching shipments found" %}'; + }, + columns: [ + { + visible: false, + checkbox: true, + switchable: false, + }, + { + field: 'reference', + title: '{% trans "Reference" %}', + switchable: false, + }, + { + field: 'shipment_date', + title: '{% trans "Shipment Date" %}', + visible: options.shipped, + switchable: false, + }, + { + field: 'notes', + title: '{% trans "Notes" %}', + visible: false, + // TODO: Implement 'notes' field + }, + { + title: '', + switchable: false, + formatter: function(value, row) { + return makeShipmentActions(row); + } + } + ], + }); +} + + + function loadSalesOrderAllocationTable(table, options={}) { /** * Load a table with SalesOrderAllocation items @@ -1123,7 +1235,7 @@ function loadSalesOrderAllocationTable(table, options={}) { $(table).inventreeTable({ url: '{% url "api-so-allocation-list" %}', queryParams: filters, - name: 'salesorderallocation', + name: options.name || 'salesorderallocation', groupBy: false, search: false, paginationVAlign: 'bottom', @@ -1198,16 +1310,14 @@ function showAllocationSubTable(index, row, element, options) { // Construct a sub-table element var html = `
    - -
    +
    `; element.html(html); var table = $(`#allocation-table-${row.pk}`); - // Is the parent SalesOrder pending? - var pending = options.status == {{ SalesOrderStatus.PENDING }}; + var shipped = options.shipped; function setupCallbacks() { // Add callbacks for 'edit' buttons @@ -1300,7 +1410,7 @@ function showAllocationSubTable(index, row, element, options) { var html = `
    `; var pk = row.pk; - if (pending) { + if (!shipped) { html += makeIconButton('fa-edit icon-blue', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}'); html += makeIconButton('fa-trash-alt icon-red', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}'); } From c90c224ed2c0a040e3b8608583751e4db0bb7dc9 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 26 Oct 2021 22:13:55 +1100 Subject: [PATCH 08/54] Update "reference" field for shipment model - Must be unique - Auto-incrementing default value - Updated migrations --- .../migrations/0053_salesordershipment.py | 5 +- InvenTree/order/models.py | 76 +++++++++---------- 2 files changed, 38 insertions(+), 43 deletions(-) diff --git a/InvenTree/order/migrations/0053_salesordershipment.py b/InvenTree/order/migrations/0053_salesordershipment.py index e20a95e3f8..b137e2dea8 100644 --- a/InvenTree/order/migrations/0053_salesordershipment.py +++ b/InvenTree/order/migrations/0053_salesordershipment.py @@ -3,6 +3,9 @@ from django.conf import settings from django.db import migrations, models import django.db.models.deletion + +import order.models + import markdownx.models @@ -19,7 +22,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('shipment_date', models.DateField(blank=True, help_text='Date of shipment', null=True, verbose_name='Shipment Date')), - ('reference', models.CharField(blank=True, help_text='Shipment reference', max_length=100, verbose_name='Reference')), + ('reference', models.CharField(default=order.models.get_next_shipment_number, unique=True, help_text='Shipment reference', max_length=100, verbose_name='Reference')), ('notes', markdownx.models.MarkdownxField(blank=True, help_text='Shipment notes', verbose_name='Notes')), ('checked_by', models.ForeignKey(blank=True, help_text='User who checked this shipment', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Checked By')), ('order', models.ForeignKey(help_text='Sales Order', on_delete=django.db.models.deletion.CASCADE, related_name='shipments', to='order.salesorder', verbose_name='Order')), diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index c6ff6dc5bd..b0b0d82221 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -37,7 +37,7 @@ def get_next_po_number(): """ if PurchaseOrder.objects.count() == 0: - return + return "001" order = PurchaseOrder.objects.exclude(reference=None).last() @@ -66,7 +66,7 @@ def get_next_so_number(): """ if SalesOrder.objects.count() == 0: - return + return "001" order = SalesOrder.objects.exclude(reference=None).last() @@ -107,45 +107,6 @@ class Order(ReferenceIndexingMixin): responsible: User (or group) responsible for managing the order """ - @classmethod - def getNextOrderNumber(cls): - """ - Try to predict the next order-number - """ - - if cls.objects.count() == 0: - return None - - # We will assume that the latest pk has the highest PO number - order = cls.objects.last() - ref = order.reference - - if not ref: - return None - - tries = set() - - tries.add(ref) - - while 1: - new_ref = increment(ref) - - print("Reference:", new_ref) - - if new_ref in tries: - # We are in a looping situation - simply return the original one - return ref - - # Check that the new ref does not exist in the database - if cls.objects.filter(reference=new_ref).exists(): - tries.add(new_ref) - new_ref = increment(new_ref) - - else: - break - - return new_ref - def save(self, *args, **kwargs): self.rebuild_reference_field() @@ -903,6 +864,35 @@ class SalesOrderLineItem(OrderLineItem): return self.allocated_quantity() > self.quantity +def get_next_shipment_number(): + """ + Returns the next available SalesOrderShipment reference number" + """ + + if SalesOrderShipment.objects.count() == 0: + return "001" + + shipment = SalesOrderShipment.objects.exclude(reference=None).last() + + attempts = set([shipment.reference]) + + reference = shipment.reference + + while 1: + reference = increment(reference) + + if reference in attempts: + # Escape infinite recursion + return reference + + if SalesOrderShipment.objects.filter(reference=reference).exists(): + attempts.add(reference) + else: + break + + return reference + + class SalesOrderShipment(models.Model): """ The SalesOrderShipment model represents a physical shipment made against a SalesOrder. @@ -946,9 +936,11 @@ class SalesOrderShipment(models.Model): reference = models.CharField( max_length=100, - blank=True, + blank=False, + unique=True, verbose_name=('Reference'), help_text=_('Shipment reference'), + default=get_next_shipment_number, ) notes = MarkdownxField( From 87154c0240d87e8ffe9d15ce8e96600f16665334 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 26 Oct 2021 22:17:31 +1100 Subject: [PATCH 09/54] Bump API version --- InvenTree/InvenTree/version.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index 48539713f2..28c623ba4a 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -12,11 +12,15 @@ import common.models INVENTREE_SW_VERSION = "0.6.0 dev" # InvenTree API version -INVENTREE_API_VERSION = 16 +INVENTREE_API_VERSION = 17 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v17 -> 2021-10-26 + - Adds support for multiple "Shipments" against a SalesOrder + - Refactors process for stock allocation against a SalesOrder + v16 -> 2021-10-17 - Adds API endpoint for completing build order outputs From dd5eeb7c61049b8991cb9388f7d55b82ff3ffe03 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 26 Oct 2021 22:46:30 +1100 Subject: [PATCH 10/54] Add breadcrumbs to purchase order and sales order pages --- InvenTree/order/templates/order/order_base.html | 11 +++++++++++ InvenTree/order/templates/order/sales_order_base.html | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html index ca7e70c4e3..d8fa4aafab 100644 --- a/InvenTree/order/templates/order/order_base.html +++ b/InvenTree/order/templates/order/order_base.html @@ -9,6 +9,17 @@ {% inventree_title %} | {% trans "Purchase Order" %} {% endblock %} +{% block pre_content %} + +{% endblock %} + {% block thumbnail %} + +
    +{% endblock %} + {% block below_thumbnail %}
    {% if order.status == SalesOrderStatus.PENDING and not order.is_fully_allocated %} From bff9f0828a7f33b76a50e32464ee24b99a80ffea Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 26 Oct 2021 23:51:36 +1100 Subject: [PATCH 11/54] Adds API endpoint to allocate stock items against a SalesOrder - SalesOrderAllocations are no longer created manually - API endpoint performs data validation - Multiple line items can be allocated at once - Adds unit testing for new API endpoint --- InvenTree/build/serializers.py | 2 +- InvenTree/order/api.py | 154 +++++++++++++---------- InvenTree/order/models.py | 24 +++- InvenTree/order/serializers.py | 203 ++++++++++++++++++++++++++---- InvenTree/order/test_api.py | 163 +++++++++++++++++++++--- InvenTree/part/fixtures/part.yaml | 4 + 6 files changed, 437 insertions(+), 113 deletions(-) diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index a18f58fb76..29257341b2 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -422,7 +422,7 @@ class BuildAllocationSerializer(serializers.Serializer): Validation """ - super().validate(data) + data = super().validate(data) items = data.get('items', []) diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 954295ef8e..037224a855 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -13,25 +13,17 @@ from rest_framework import generics from rest_framework import filters, status from rest_framework.response import Response +from company.models import SupplierPart from InvenTree.filters import InvenTreeOrderingFilter from InvenTree.helpers import str2bool from InvenTree.api import AttachmentMixin from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus +import order.models as models +import order.serializers as serializers + from part.models import Part -from company.models import SupplierPart - -from .models import PurchaseOrder, PurchaseOrderLineItem -from .models import PurchaseOrderAttachment -from .serializers import POSerializer, POLineItemSerializer, POAttachmentSerializer - -from .models import SalesOrder, SalesOrderLineItem, SalesOrderShipment, SalesOrderAllocation -from .models import SalesOrderAttachment - -from .serializers import SalesOrderSerializer, SOLineItemSerializer, SOAttachmentSerializer -from .serializers import SalesOrderShipmentSerializer, SalesOrderAllocationSerializer -from .serializers import POReceiveSerializer class POList(generics.ListCreateAPIView): @@ -41,8 +33,8 @@ class POList(generics.ListCreateAPIView): - POST: Create a new PurchaseOrder object """ - queryset = PurchaseOrder.objects.all() - serializer_class = POSerializer + queryset = models.PurchaseOrder.objects.all() + serializer_class = serializers.POSerializer def create(self, request, *args, **kwargs): """ @@ -79,7 +71,7 @@ class POList(generics.ListCreateAPIView): 'lines', ) - queryset = POSerializer.annotate_queryset(queryset) + queryset = serializers.POSerializer.annotate_queryset(queryset) return queryset @@ -108,9 +100,9 @@ class POList(generics.ListCreateAPIView): overdue = str2bool(overdue) if overdue: - queryset = queryset.filter(PurchaseOrder.OVERDUE_FILTER) + queryset = queryset.filter(models.PurchaseOrder.OVERDUE_FILTER) else: - queryset = queryset.exclude(PurchaseOrder.OVERDUE_FILTER) + queryset = queryset.exclude(models.PurchaseOrder.OVERDUE_FILTER) # Special filtering for 'status' field status = params.get('status', None) @@ -144,7 +136,7 @@ class POList(generics.ListCreateAPIView): max_date = params.get('max_date', None) if min_date is not None and max_date is not None: - queryset = PurchaseOrder.filterByDate(queryset, min_date, max_date) + queryset = models.PurchaseOrder.filterByDate(queryset, min_date, max_date) return queryset @@ -184,8 +176,8 @@ class POList(generics.ListCreateAPIView): class PODetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for detail view of a PurchaseOrder object """ - queryset = PurchaseOrder.objects.all() - serializer_class = POSerializer + queryset = models.PurchaseOrder.objects.all() + serializer_class = serializers.POSerializer def get_serializer(self, *args, **kwargs): @@ -208,7 +200,7 @@ class PODetail(generics.RetrieveUpdateDestroyAPIView): 'lines', ) - queryset = POSerializer.annotate_queryset(queryset) + queryset = serializers.POSerializer.annotate_queryset(queryset) return queryset @@ -226,9 +218,9 @@ class POReceive(generics.CreateAPIView): - A global location can also be specified """ - queryset = PurchaseOrderLineItem.objects.none() + queryset = models.PurchaseOrderLineItem.objects.none() - serializer_class = POReceiveSerializer + serializer_class = serializers.POReceiveSerializer def get_serializer_context(self): @@ -236,7 +228,7 @@ class POReceive(generics.CreateAPIView): # Pass the purchase order through to the serializer for validation try: - context['order'] = PurchaseOrder.objects.get(pk=self.kwargs.get('pk', None)) + context['order'] = models.PurchaseOrder.objects.get(pk=self.kwargs.get('pk', None)) except: pass @@ -251,7 +243,7 @@ class POLineItemFilter(rest_filters.FilterSet): """ class Meta: - model = PurchaseOrderLineItem + model = models.PurchaseOrderLineItem fields = [ 'order', 'part' @@ -285,15 +277,15 @@ class POLineItemList(generics.ListCreateAPIView): - POST: Create a new PurchaseOrderLineItem object """ - queryset = PurchaseOrderLineItem.objects.all() - serializer_class = POLineItemSerializer + queryset = models.PurchaseOrderLineItem.objects.all() + serializer_class = serializers.POLineItemSerializer filterset_class = POLineItemFilter def get_queryset(self, *args, **kwargs): queryset = super().get_queryset(*args, **kwargs) - queryset = POLineItemSerializer.annotate_queryset(queryset) + queryset = serializers.POLineItemSerializer.annotate_queryset(queryset) return queryset @@ -350,14 +342,14 @@ class POLineItemDetail(generics.RetrieveUpdateDestroyAPIView): Detail API endpoint for PurchaseOrderLineItem object """ - queryset = PurchaseOrderLineItem.objects.all() - serializer_class = POLineItemSerializer + queryset = models.PurchaseOrderLineItem.objects.all() + serializer_class = serializers.POLineItemSerializer def get_queryset(self): queryset = super().get_queryset() - queryset = POLineItemSerializer.annotate_queryset(queryset) + queryset = serializers.POLineItemSerializer.annotate_queryset(queryset) return queryset @@ -367,8 +359,8 @@ class SOAttachmentList(generics.ListCreateAPIView, AttachmentMixin): API endpoint for listing (and creating) a SalesOrderAttachment (file upload) """ - queryset = SalesOrderAttachment.objects.all() - serializer_class = SOAttachmentSerializer + queryset = models.SalesOrderAttachment.objects.all() + serializer_class = serializers.SOAttachmentSerializer filter_backends = [ rest_filters.DjangoFilterBackend, @@ -384,8 +376,8 @@ class SOAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin) Detail endpoint for SalesOrderAttachment """ - queryset = SalesOrderAttachment.objects.all() - serializer_class = SOAttachmentSerializer + queryset = models.SalesOrderAttachment.objects.all() + serializer_class = serializers.SOAttachmentSerializer class SOList(generics.ListCreateAPIView): @@ -396,8 +388,8 @@ class SOList(generics.ListCreateAPIView): - POST: Create a new SalesOrder """ - queryset = SalesOrder.objects.all() - serializer_class = SalesOrderSerializer + queryset = models.SalesOrder.objects.all() + serializer_class = serializers.SalesOrderSerializer def create(self, request, *args, **kwargs): """ @@ -434,7 +426,7 @@ class SOList(generics.ListCreateAPIView): 'lines' ) - queryset = SalesOrderSerializer.annotate_queryset(queryset) + queryset = serializers.SalesOrderSerializer.annotate_queryset(queryset) return queryset @@ -454,9 +446,9 @@ class SOList(generics.ListCreateAPIView): outstanding = str2bool(outstanding) if outstanding: - queryset = queryset.filter(status__in=SalesOrderStatus.OPEN) + queryset = queryset.filter(status__in=models.SalesOrderStatus.OPEN) else: - queryset = queryset.exclude(status__in=SalesOrderStatus.OPEN) + queryset = queryset.exclude(status__in=models.SalesOrderStatus.OPEN) # Filter by 'overdue' status overdue = params.get('overdue', None) @@ -465,9 +457,9 @@ class SOList(generics.ListCreateAPIView): overdue = str2bool(overdue) if overdue: - queryset = queryset.filter(SalesOrder.OVERDUE_FILTER) + queryset = queryset.filter(models.SalesOrder.OVERDUE_FILTER) else: - queryset = queryset.exclude(SalesOrder.OVERDUE_FILTER) + queryset = queryset.exclude(models.SalesOrder.OVERDUE_FILTER) status = params.get('status', None) @@ -490,7 +482,7 @@ class SOList(generics.ListCreateAPIView): max_date = params.get('max_date', None) if min_date is not None and max_date is not None: - queryset = SalesOrder.filterByDate(queryset, min_date, max_date) + queryset = models.SalesOrder.filterByDate(queryset, min_date, max_date) return queryset @@ -534,8 +526,8 @@ class SODetail(generics.RetrieveUpdateDestroyAPIView): API endpoint for detail view of a SalesOrder object. """ - queryset = SalesOrder.objects.all() - serializer_class = SalesOrderSerializer + queryset = models.SalesOrder.objects.all() + serializer_class = serializers.SalesOrderSerializer def get_serializer(self, *args, **kwargs): @@ -554,7 +546,7 @@ class SODetail(generics.RetrieveUpdateDestroyAPIView): queryset = queryset.prefetch_related('customer', 'lines') - queryset = SalesOrderSerializer.annotate_queryset(queryset) + queryset = serializers.SalesOrderSerializer.annotate_queryset(queryset) return queryset @@ -564,8 +556,8 @@ class SOLineItemList(generics.ListCreateAPIView): API endpoint for accessing a list of SalesOrderLineItem objects. """ - queryset = SalesOrderLineItem.objects.all() - serializer_class = SOLineItemSerializer + queryset = models.SalesOrderLineItem.objects.all() + serializer_class = serializers.SOLineItemSerializer def get_serializer(self, *args, **kwargs): @@ -624,8 +616,34 @@ class SOLineItemList(generics.ListCreateAPIView): class SOLineItemDetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for detail view of a SalesOrderLineItem object """ - queryset = SalesOrderLineItem.objects.all() - serializer_class = SOLineItemSerializer + queryset = models.SalesOrderLineItem.objects.all() + serializer_class = serializers.SOLineItemSerializer + + +class SalesOrderAllocate(generics.CreateAPIView): + """ + API endpoint to allocate stock items against a SalesOrder + + - The SalesOrder is specified in the URL + - See the SOShipmentAllocationSerializer class + """ + + queryset = models.SalesOrder.objects.none() + serializer_class = serializers.SOShipmentAllocationSerializer + + def get_serializer_context(self): + + ctx = super().get_serializer_context() + + # Pass through the SalesOrder object to the serializer + try: + ctx['order'] = models.SalesOrder.objects.get(pk=self.kwargs.get('pk', None)) + except: + pass + + ctx['request'] = self.request + + return ctx class SOAllocationDetail(generics.RetrieveUpdateDestroyAPIView): @@ -633,17 +651,17 @@ class SOAllocationDetail(generics.RetrieveUpdateDestroyAPIView): API endpoint for detali view of a SalesOrderAllocation object """ - queryset = SalesOrderAllocation.objects.all() - serializer_class = SalesOrderAllocationSerializer + queryset = models.SalesOrderAllocation.objects.all() + serializer_class = serializers.SalesOrderAllocationSerializer -class SOAllocationList(generics.ListCreateAPIView): +class SOAllocationList(generics.ListAPIView): """ API endpoint for listing SalesOrderAllocation objects """ - queryset = SalesOrderAllocation.objects.all() - serializer_class = SalesOrderAllocationSerializer + queryset = models.SalesOrderAllocation.objects.all() + serializer_class = serializers.SalesOrderAllocationSerializer def get_serializer(self, *args, **kwargs): @@ -720,7 +738,7 @@ class SOShipmentFilter(rest_filters.FilterSet): return queryset class Meta: - model = SalesOrderShipment + model = models.SalesOrderShipment fields = [ 'order', ] @@ -731,8 +749,8 @@ class SOShipmentList(generics.ListCreateAPIView): API list endpoint for SalesOrderShipment model """ - queryset = SalesOrderShipment.objects.all() - serializer_class = SalesOrderShipmentSerializer + queryset = models.SalesOrderShipment.objects.all() + serializer_class = serializers.SalesOrderShipmentSerializer filterset_class = SOShipmentFilter filter_backends = [ @@ -745,8 +763,8 @@ class SOShipmentDetail(generics.RetrieveUpdateAPIView): API detail endpooint for SalesOrderShipment model """ - queryset = SalesOrderShipment.objects.all() - serializer_class = SalesOrderShipmentSerializer + queryset = models.SalesOrderShipment.objects.all() + serializer_class = serializers.SalesOrderShipmentSerializer class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin): @@ -754,8 +772,8 @@ class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin): API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload) """ - queryset = PurchaseOrderAttachment.objects.all() - serializer_class = POAttachmentSerializer + queryset = models.PurchaseOrderAttachment.objects.all() + serializer_class = serializers.POAttachmentSerializer filter_backends = [ rest_filters.DjangoFilterBackend, @@ -771,8 +789,8 @@ class POAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin) Detail endpoint for a PurchaseOrderAttachment """ - queryset = PurchaseOrderAttachment.objects.all() - serializer_class = POAttachmentSerializer + queryset = models.PurchaseOrderAttachment.objects.all() + serializer_class = serializers.POAttachmentSerializer order_api_urls = [ @@ -816,7 +834,13 @@ order_api_urls = [ url(r'^.*$', SOShipmentList.as_view(), name='api-so-shipment-list'), ])), - url(r'^(?P\d+)/$', SODetail.as_view(), name='api-so-detail'), + # Sales order detail view + url(r'^(?P\d+)/', include([ + url(r'^allocate/', SalesOrderAllocate.as_view(), name='api-so-allocate'), + url(r'^.*$', SODetail.as_view(), name='api-so-detail'), + ])), + + # Sales order list view url(r'^.*$', SOList.as_view(), name='api-so-list'), ])), diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index b0b0d82221..05eabe3788 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -567,6 +567,16 @@ class SalesOrder(Order): def is_pending(self): return self.status == SalesOrderStatus.PENDING + @property + def stock_allocations(self): + """ + Return a queryset containing all allocations for this order + """ + + return SalesOrderAllocation.objects.filter( + line__in=[line.pk for line in self.lines.all()] + ) + def is_fully_allocated(self): """ Return True if all line items are fully allocated """ @@ -910,6 +920,10 @@ class SalesOrderShipment(models.Model): notes: Custom notes field for this shipment """ + @staticmethod + def get_api_url(): + return reverse('api-so-shipment-list') + order = models.ForeignKey( SalesOrder, on_delete=models.CASCADE, @@ -1014,10 +1028,9 @@ class SalesOrderAllocation(models.Model): if self.item.serial and not self.quantity == 1: errors['quantity'] = _('Quantity must be 1 for serialized stock item') - - - # TODO: Ensure that the "shipment" points to the same "order"! - + if self.line.order != self.shipment.order: + errors['line'] = _('Sales order does not match shipment') + errors['shipment'] = _('Shipment does not match sales order') if len(errors) > 0: raise ValidationError(errors) @@ -1026,7 +1039,8 @@ class SalesOrderAllocation(models.Model): SalesOrderLineItem, on_delete=models.CASCADE, verbose_name=_('Line'), - related_name='allocations') + related_name='allocations' + ) shipment = models.ForeignKey( SalesOrderShipment, diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 70bef86ae4..34032d758b 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -17,30 +17,29 @@ from rest_framework.serializers import ValidationError from sql_util.utils import SubqueryCount +from common.settings import currency_code_mappings + +from company.serializers import CompanyBriefSerializer, SupplierPartSerializer + +from InvenTree.helpers import normalize from InvenTree.serializers import InvenTreeModelSerializer from InvenTree.serializers import InvenTreeAttachmentSerializer from InvenTree.serializers import InvenTreeMoneySerializer from InvenTree.serializers import InvenTreeAttachmentSerializerField - from InvenTree.status_codes import StockStatus -from company.serializers import CompanyBriefSerializer, SupplierPartSerializer +import order.models from part.serializers import PartBriefSerializer import stock.models -from stock.serializers import LocationBriefSerializer, StockItemSerializer, LocationSerializer - -from .models import PurchaseOrder, PurchaseOrderLineItem -from .models import PurchaseOrderAttachment, SalesOrderAttachment -from .models import SalesOrder, SalesOrderLineItem -from .models import SalesOrderShipment, SalesOrderAllocation - -from common.settings import currency_code_mappings +import stock.serializers class POSerializer(InvenTreeModelSerializer): - """ Serializer for a PurchaseOrder object """ + """ + Serializer for a PurchaseOrder object + """ def __init__(self, *args, **kwargs): @@ -67,7 +66,7 @@ class POSerializer(InvenTreeModelSerializer): queryset = queryset.annotate( overdue=Case( When( - PurchaseOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()), + order.models.PurchaseOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()), ), default=Value(False, output_field=BooleanField()) ) @@ -86,7 +85,7 @@ class POSerializer(InvenTreeModelSerializer): reference = serializers.CharField(required=True) class Meta: - model = PurchaseOrder + model = order.models.PurchaseOrder fields = [ 'pk', @@ -160,7 +159,7 @@ class POLineItemSerializer(InvenTreeModelSerializer): purchase_price_string = serializers.CharField(source='purchase_price', read_only=True) - destination_detail = LocationBriefSerializer(source='get_destination', read_only=True) + destination_detail = stock.serializers.LocationBriefSerializer(source='get_destination', read_only=True) purchase_price_currency = serializers.ChoiceField( choices=currency_code_mappings(), @@ -168,7 +167,7 @@ class POLineItemSerializer(InvenTreeModelSerializer): ) class Meta: - model = PurchaseOrderLineItem + model = order.models.PurchaseOrderLineItem fields = [ 'pk', @@ -195,7 +194,7 @@ class POLineItemReceiveSerializer(serializers.Serializer): """ line_item = serializers.PrimaryKeyRelatedField( - queryset=PurchaseOrderLineItem.objects.all(), + queryset=order.models.PurchaseOrderLineItem.objects.all(), many=False, allow_null=False, required=True, @@ -376,7 +375,7 @@ class POAttachmentSerializer(InvenTreeAttachmentSerializer): attachment = InvenTreeAttachmentSerializerField(required=True) class Meta: - model = PurchaseOrderAttachment + model = order.models.PurchaseOrderAttachment fields = [ 'pk', @@ -422,7 +421,7 @@ class SalesOrderSerializer(InvenTreeModelSerializer): queryset = queryset.annotate( overdue=Case( When( - SalesOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()), + order.models.SalesOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()), ), default=Value(False, output_field=BooleanField()) ) @@ -441,7 +440,7 @@ class SalesOrderSerializer(InvenTreeModelSerializer): reference = serializers.CharField(required=True) class Meta: - model = SalesOrder + model = order.models.SalesOrder fields = [ 'pk', @@ -484,8 +483,8 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer): # Extra detail fields order_detail = SalesOrderSerializer(source='line.order', many=False, read_only=True) part_detail = PartBriefSerializer(source='item.part', many=False, read_only=True) - item_detail = StockItemSerializer(source='item', many=False, read_only=True) - location_detail = LocationSerializer(source='item.location', many=False, read_only=True) + item_detail = stock.serializers.StockItemSerializer(source='item', many=False, read_only=True) + location_detail = stock.serializers.LocationSerializer(source='item.location', many=False, read_only=True) def __init__(self, *args, **kwargs): @@ -509,7 +508,7 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer): self.fields.pop('location_detail') class Meta: - model = SalesOrderAllocation + model = order.models.SalesOrderAllocation fields = [ 'pk', @@ -570,7 +569,7 @@ class SOLineItemSerializer(InvenTreeModelSerializer): ) class Meta: - model = SalesOrderLineItem + model = order.models.SalesOrderLineItem fields = [ 'pk', @@ -598,7 +597,7 @@ class SalesOrderShipmentSerializer(InvenTreeModelSerializer): allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True) class Meta: - model = SalesOrderShipment + model = order.models.SalesOrderShipment fields = [ 'pk', @@ -611,6 +610,160 @@ class SalesOrderShipmentSerializer(InvenTreeModelSerializer): ] +class SOShipmentAllocationItemSerializer(serializers.Serializer): + """ + A serializer for allocating a single stock-item against a SalesOrder shipment + """ + + class Meta: + fields = [ + 'line_item', + 'stock_item', + 'quantity', + ] + + line_item = serializers.PrimaryKeyRelatedField( + queryset=order.models.SalesOrderLineItem.objects.all(), + many=False, + allow_null=False, + required=True, + label=_('Stock Item'), + ) + + def validate_line_item(self, line_item): + + order = self.context['order'] + + # Ensure that the line item points to the correct order + if line_item.order != order: + raise ValidationError(_("Line item is not associated with this order")) + + return line_item + + stock_item = serializers.PrimaryKeyRelatedField( + queryset=stock.models.StockItem.objects.all(), + many=False, + allow_null=False, + required=True, + label=_('Stock Item'), + ) + + quantity = serializers.DecimalField( + max_digits=15, + decimal_places=5, + min_value=0, + required=True + ) + + def validate_quantity(self, quantity): + + if quantity <= 0: + raise ValidationError(_("Quantity must be positive")) + + return quantity + + def validate(self, data): + + super().validate(data) + + stock_item = data['stock_item'] + quantity = data['quantity'] + + if stock_item.serialized and quantity != 1: + raise ValidationError({ + 'quantity': _("Quantity must be 1 for serialized stock item"), + }) + + q = normalize(stock_item.unallocated_quantity()) + + if quantity > q: + raise ValidationError({ + 'quantity': _(f"Available quantity ({q}) exceeded") + }) + + return data + + +class SOShipmentAllocationSerializer(serializers.Serializer): + """ + DRF serializer for allocation of stock items against a sales order / shipment + """ + + class Meta: + fields = [ + 'items', + 'shipment', + ] + + items = SOShipmentAllocationItemSerializer(many=True) + + shipment = serializers.PrimaryKeyRelatedField( + queryset=order.models.SalesOrderShipment.objects.all(), + many=False, + allow_null=False, + required=True, + label=_('Shipment'), + ) + + def validate_shipment(self, shipment): + """ + Run validation against the provided shipment instance + """ + + order = self.context['order'] + + if shipment.shipment_date is not None: + raise ValidationError(_("Shipment has already been shipped")) + + if shipment.order != order: + raise ValidationError(_("Shipment is not associated with this order")) + + return shipment + + def validate(self, data): + """ + Serializer validation + """ + + data = super().validate(data) + + # Extract SalesOrder from serializer context + # order = self.context['order'] + + items = data.get('items', []) + + if len(items) == 0: + raise ValidationError(_('Allocation items must be provided')) + + return data + + def save(self): + """ + Perform the allocation of items against this order + """ + + data = self.validated_data + + items = data['items'] + shipment = data['shipment'] + + with transaction.atomic(): + for entry in items: + + # Create a new SalesOrderAllocation + order.models.SalesOrderAllocation.objects.create( + line=entry.get('line_item'), + item=entry.get('stock_item'), + quantity=entry.get('quantity'), + shipment=shipment, + ) + + try: + pass + except (ValidationError, DjangoValidationError) as exc: + raise ValidationError(detail=serializers.as_serializer_error(exc)) + + class SOAttachmentSerializer(InvenTreeAttachmentSerializer): """ Serializers for the SalesOrderAttachment model @@ -619,7 +772,7 @@ class SOAttachmentSerializer(InvenTreeAttachmentSerializer): attachment = InvenTreeAttachmentSerializerField(required=True) class Meta: - model = SalesOrderAttachment + model = order.models.SalesOrderAttachment fields = [ 'pk', diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index 899fa9a6fc..0f29e5bd16 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -11,9 +11,10 @@ from django.urls import reverse from InvenTree.api_tester import InvenTreeAPITestCase from InvenTree.status_codes import PurchaseOrderStatus +from part.models import Part from stock.models import StockItem -from .models import PurchaseOrder, PurchaseOrderLineItem, SalesOrder +import order.models as models class OrderTest(InvenTreeAPITestCase): @@ -85,7 +86,7 @@ class PurchaseOrderTest(OrderTest): self.filter({'overdue': True}, 0) self.filter({'overdue': False}, 7) - order = PurchaseOrder.objects.get(pk=1) + order = models.PurchaseOrder.objects.get(pk=1) order.target_date = datetime.now().date() - timedelta(days=10) order.save() @@ -118,7 +119,7 @@ class PurchaseOrderTest(OrderTest): Test that we can create / edit and delete a PurchaseOrder via the API """ - n = PurchaseOrder.objects.count() + n = models.PurchaseOrder.objects.count() url = reverse('api-po-list') @@ -135,7 +136,7 @@ class PurchaseOrderTest(OrderTest): ) # And no new PurchaseOrder objects should have been created - self.assertEqual(PurchaseOrder.objects.count(), n) + self.assertEqual(models.PurchaseOrder.objects.count(), n) # Ok, now let's give this user the correct permission self.assignRole('purchase_order.add') @@ -152,7 +153,7 @@ class PurchaseOrderTest(OrderTest): expected_code=201 ) - self.assertEqual(PurchaseOrder.objects.count(), n + 1) + self.assertEqual(models.PurchaseOrder.objects.count(), n + 1) pk = response.data['pk'] @@ -167,7 +168,7 @@ class PurchaseOrderTest(OrderTest): expected_code=400 ) - self.assertEqual(PurchaseOrder.objects.count(), n + 1) + self.assertEqual(models.PurchaseOrder.objects.count(), n + 1) url = reverse('api-po-detail', kwargs={'pk': pk}) @@ -198,7 +199,7 @@ class PurchaseOrderTest(OrderTest): response = self.delete(url, expected_code=204) # Number of PurchaseOrder objects should have decreased - self.assertEqual(PurchaseOrder.objects.count(), n) + self.assertEqual(models.PurchaseOrder.objects.count(), n) # And if we try to access the detail view again, it has gone response = self.get(url, expected_code=404) @@ -237,7 +238,7 @@ class PurchaseOrderReceiveTest(OrderTest): self.n = StockItem.objects.count() # Mark the order as "placed" so we can receive line items - order = PurchaseOrder.objects.get(pk=1) + order = models.PurchaseOrder.objects.get(pk=1) order.status = PurchaseOrderStatus.PLACED order.save() @@ -409,8 +410,8 @@ class PurchaseOrderReceiveTest(OrderTest): Test receipt of valid data """ - line_1 = PurchaseOrderLineItem.objects.get(pk=1) - line_2 = PurchaseOrderLineItem.objects.get(pk=2) + line_1 = models.PurchaseOrderLineItem.objects.get(pk=1) + line_2 = models.PurchaseOrderLineItem.objects.get(pk=2) self.assertEqual(StockItem.objects.filter(supplier_part=line_1.part).count(), 0) self.assertEqual(StockItem.objects.filter(supplier_part=line_2.part).count(), 0) @@ -437,7 +438,7 @@ class PurchaseOrderReceiveTest(OrderTest): # Before posting "valid" data, we will mark the purchase order as "pending" # In this case we do expect an error! - order = PurchaseOrder.objects.get(pk=1) + order = models.PurchaseOrder.objects.get(pk=1) order.status = PurchaseOrderStatus.PENDING order.save() @@ -463,8 +464,8 @@ class PurchaseOrderReceiveTest(OrderTest): # There should be two newly created stock items self.assertEqual(self.n + 2, StockItem.objects.count()) - line_1 = PurchaseOrderLineItem.objects.get(pk=1) - line_2 = PurchaseOrderLineItem.objects.get(pk=2) + line_1 = models.PurchaseOrderLineItem.objects.get(pk=1) + line_2 = models.PurchaseOrderLineItem.objects.get(pk=2) self.assertEqual(line_1.received, 50) self.assertEqual(line_2.received, 250) @@ -519,7 +520,7 @@ class SalesOrderTest(OrderTest): self.filter({'overdue': False}, 5) for pk in [1, 2]: - order = SalesOrder.objects.get(pk=pk) + order = models.SalesOrder.objects.get(pk=pk) order.target_date = datetime.now().date() - timedelta(days=10) order.save() @@ -547,7 +548,7 @@ class SalesOrderTest(OrderTest): Test that we can create / edit and delete a SalesOrder via the API """ - n = SalesOrder.objects.count() + n = models.SalesOrder.objects.count() url = reverse('api-so-list') @@ -577,7 +578,7 @@ class SalesOrderTest(OrderTest): ) # Check that the new order has been created - self.assertEqual(SalesOrder.objects.count(), n + 1) + self.assertEqual(models.SalesOrder.objects.count(), n + 1) # Grab the PK for the newly created SalesOrder pk = response.data['pk'] @@ -620,7 +621,7 @@ class SalesOrderTest(OrderTest): response = self.delete(url, expected_code=204) # Check that the number of sales orders has decreased - self.assertEqual(SalesOrder.objects.count(), n) + self.assertEqual(models.SalesOrder.objects.count(), n) # And the resource should no longer be available response = self.get(url, expected_code=404) @@ -641,3 +642,131 @@ class SalesOrderTest(OrderTest): }, expected_code=201 ) + + +class SalesOrderAllocateTest(OrderTest): + """ + Unit tests for allocating stock items against a SalesOrder + """ + + def setUp(self): + super().setUp() + + self.assignRole('sales_order.add') + + self.url = reverse('api-so-allocate', kwargs={'pk': 1}) + + self.order = models.SalesOrder.objects.get(pk=1) + + # Create some line items for this purchase order + parts = Part.objects.filter(salable=True) + + for part in parts: + + # Create a new line item + models.SalesOrderLineItem.objects.create( + order=self.order, + part=part, + quantity=5, + ) + + # Ensure we have stock! + StockItem.objects.create( + part=part, + quantity=100, + ) + + # Create a new shipment against this SalesOrder + self.shipment = models.SalesOrderShipment.objects.create( + order=self.order, + ) + + def test_invalid(self): + """ + Test POST with invalid data + """ + + # No data + response = self.post(self.url, {}, expected_code=400) + + self.assertIn('This field is required', str(response.data['items'])) + self.assertIn('This field is required', str(response.data['shipment'])) + + # Test with a single line items + line = self.order.lines.first() + part = line.part + + # Valid stock_item, but quantity is invalid + data = { + 'items': [{ + "line_item": line.pk, + "stock_item": part.stock_items.last().pk, + "quantity": 0, + }], + } + + response = self.post(self.url, data, expected_code=400) + + self.assertIn('Quantity must be positive', str(response.data['items'])) + + # Valid stock item, too much quantity + data['items'][0]['quantity'] = 250 + + response = self.post(self.url, data, expected_code=400) + + self.assertIn('Available quantity (100) exceeded', str(response.data['items'])) + + # Valid stock item, valid quantity + data['items'][0]['quantity'] = 50 + + # Invalid shipment! + data['shipment'] = 9999 + + response = self.post(self.url, data, expected_code=400) + + self.assertIn('does not exist', str(response.data['shipment'])) + + # Valid shipment, but points to the wrong order + shipment = models.SalesOrderShipment.objects.create( + order=models.SalesOrder.objects.get(pk=2), + ) + + data['shipment'] = shipment.pk + + response = self.post(self.url, data, expected_code=400) + + self.assertIn('Shipment is not associated with this order', str(response.data['shipment'])) + + def test_allocate(self): + """ + Test the the allocation endpoint acts as expected, + when provided with valid data! + """ + + # First, check that there are no line items allocated against this SalesOrder + self.assertEqual(self.order.stock_allocations.count(), 0) + + data = { + "items": [], + "shipment": self.shipment.pk, + } + + for line in self.order.lines.all(): + stock_item = line.part.stock_items.last() + + # Fully-allocate each line + data['items'].append({ + "line_item": line.pk, + "stock_item": stock_item.pk, + "quantity": 5 + }) + + self.post(self.url, data, expected_code=201) + + # There should have been 1 stock item allocated against each line item + n_lines = self.order.lines.count() + + self.assertEqual(self.order.stock_allocations.count(), n_lines) + + for line in self.order.lines.all(): + self.assertEqual(line.allocations.count(), 1) diff --git a/InvenTree/part/fixtures/part.yaml b/InvenTree/part/fixtures/part.yaml index 3c24690efc..77e808fd7f 100644 --- a/InvenTree/part/fixtures/part.yaml +++ b/InvenTree/part/fixtures/part.yaml @@ -69,6 +69,7 @@ name: 'Widget' description: 'A watchamacallit' category: 7 + salable: true assembly: true trackable: true tree_id: 0 @@ -83,6 +84,7 @@ name: 'Orphan' description: 'A part without a category' category: null + salable: true tree_id: 0 level: 0 lft: 0 @@ -95,6 +97,7 @@ name: 'Bob' description: 'Can we build it?' assembly: true + salable: true purchaseable: false category: 7 active: False @@ -113,6 +116,7 @@ description: 'A chair' is_template: True trackable: true + salable: true category: 7 tree_id: 1 level: 0 From 7252b299f70334a113fc93a0daebc994cb67b276 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 27 Oct 2021 00:41:12 +1100 Subject: [PATCH 12/54] Add modal API form to allocate stock items against a SalesOrder - Added model renderer for SalesOrderShipment - Some refactorin' --- InvenTree/order/serializers.py | 3 + InvenTree/templates/js/translated/build.js | 20 +- InvenTree/templates/js/translated/forms.js | 3 + .../js/translated/model_renderers.js | 17 + InvenTree/templates/js/translated/order.js | 315 ++++++++++++++++++ 5 files changed, 348 insertions(+), 10 deletions(-) diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 34032d758b..c872e558a0 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -596,12 +596,15 @@ class SalesOrderShipmentSerializer(InvenTreeModelSerializer): allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True) + order_detail = SalesOrderSerializer(source='order', read_only=True, many=False) + class Meta: model = order.models.SalesOrderShipment fields = [ 'pk', 'order', + 'order_detail', 'allocations', 'shipment_date', 'checked_by', diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index b6c98fc49e..92cdea7452 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -1216,7 +1216,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { * * options: * - output: ID / PK of the associated build output (or null for untracked items) - * - source_location: ID / PK of the top-level StockLocation to take parts from (or null) + * - source_location: ID / PK of the top-level StockLocation to source stock from (or null) */ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { @@ -1329,7 +1329,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { var html = ``; - // Render a "take from" input + // Render a "source location" input html += constructField( 'take_from', { @@ -1387,6 +1387,13 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { options, ); + // Add callback to "clear" button for take_from field + addClearCallback( + 'take_from', + take_from_field, + options, + ); + // Initialize stock item fields bom_items.forEach(function(bom_item) { initializeRelatedField( @@ -1446,14 +1453,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { ); }); - // Add callback to "clear" button for take_from field - addClearCallback( - 'take_from', - take_from_field, - options, - ); - - // Add button callbacks + // Add remove-row button callbacks $(options.modal).find('.button-row-remove').click(function() { var pk = $(this).attr('pk'); diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index db2c8e46cc..2818b00534 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -1576,6 +1576,9 @@ function renderModelData(name, model, data, parameters, options) { case 'salesorder': renderer = renderSalesOrder; break; + case 'salesordershipment': + renderer = renderSalesOrderShipment; + break; case 'manufacturerpart': renderer = renderManufacturerPart; break; diff --git a/InvenTree/templates/js/translated/model_renderers.js b/InvenTree/templates/js/translated/model_renderers.js index bf3628d656..b2495e207f 100644 --- a/InvenTree/templates/js/translated/model_renderers.js +++ b/InvenTree/templates/js/translated/model_renderers.js @@ -241,6 +241,23 @@ function renderSalesOrder(name, data, parameters, options) { } +// Renderer for "SalesOrderShipment" model +// eslint-disable-next-line no-unused-vars +function renderSalesOrderShipment(name, data, parameters, options) { + + var so_prefix = global_settings.SALESORDER_REFERENCE_PREFIX; + + var html = ` + ${so_prefix}${data.order_detail.reference} - {% trans "Shipment" %} ${data.reference} + + {% trans "Shipment ID" %}: ${data.pk} + + `; + + return html; +} + + // Renderer for "PartCategory" model // eslint-disable-next-line no-unused-vars function renderPartCategory(name, data, parameters, options) { diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 6e69eb6a02..b02c757df4 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -19,6 +19,7 @@ */ /* exported + allocateStockToSalesOrder, createSalesOrder, editPurchaseOrderLineItem, exportOrder, @@ -1211,6 +1212,306 @@ function loadSalesOrderShipmentTable(table, options={}) { } +/** + * Allocate stock items against a SalesOrder + * + * arguments: + * - order_id: The ID / PK value for the SalesOrder + * - lines: A list of SalesOrderLineItem objects to be allocated + * + * options: + * - source_location: ID / PK of the top-level StockLocation to source stock from (or null) + */ +function allocateStockToSalesOrder(order_id, line_items, options={}) { + + function renderLineItemRow(line_item, quantity) { + // Function to render a single line_item row + + var pk = line_item.pk; + + var part = line_item.part_detail; + + var thumb = thumbnailImage(part.thumbnail || part.image); + + var delete_button = `
    `; + + delete_button += makeIconButton( + 'fa-times icon-red', + 'button-row-remove', + pk, + '{% trans "Remove row" %}', + ); + + delete_button += '
    '; + + var quantity_input = constructField( + `items_quantity_${pk}`, + { + type: 'decimal', + min_value: 0, + value: quantity || 0, + title: '{% trans "Specify stock allocation quantity" %}', + required: true, + }, + { + hideLabels: true, + } + ); + + var stock_input = constructField( + `items_stock_item_${pk}`, + { + type: 'related field', + required: 'true', + }, + { + hideLabels: true, + } + ); + + var html = ` + + + ${thumb} ${part.full_name} + + + ${stock_input} + + + ${quantity_input} + + + + + {% trans "Part" %} + {% trans "Stock Item" %} + {% trans "Quantity" %} + + + + ${table_entries} + + `; + + constructForm(`/api/order/so/${order_id}/allocate/`, { + method: 'POST', + fields: { + shipment: { + filters: { + order: order_id, + shipped: false, + }, + value: options.shipment || null, + auto_fill: true, + } + }, + preFormContent: html, + confirm: true, + confirmMessage: '{% trans "Confirm stock allocation" %}', + title: '{% trans "Allocate Stock Items to Sales Order" %}', + afterRender: function(fields, opts) { + + // Initialize source location field + var take_from_field = { + name: 'take_from', + model: 'stocklocation', + api_url: '{% url "api-location-list" %}', + required: false, + type: 'related field', + value: options.source_location || null, + noResults: function(query) { + return '{% trans "No matching stock locations" %}'; + }, + }; + + initializeRelatedField( + take_from_field, + null, + opts + ); + + // Add callback to "clear" button for take_from field + addClearCallback( + 'take_from', + take_from_field, + opts, + ); + + // Initialize fields for each line item + line_items.forEach(function(line_item) { + var pk = line_item.pk; + + initializeRelatedField( + { + name: `items_stock_item_${pk}`, + api_url: '{% url "api-stock-list" %}', + filters: { + part: line_item.part, + in_stock: true, + part_detail: true, + location_detail: true, + }, + model: 'stockitem', + required: true, + render_part_detail: true, + render_location_detail: true, + auto_fill: true, + onSelect: function(data, field, opts) { + // Adjust the 'quantity' field based on availability + + var todo = "actually do this"; + }, + adjustFilters: function(filters) { + // Restrict query to the selected location + var location = getFormFieldValue( + 'take_from', + {}, + { + modal: opts.modal, + } + ); + + filters.location = location; + filters.cascade = true; + + return filters; + }, + noResults: function(query) { + return '{% trans "No matching stock items" %}'; + } + }, + null, + opts + ); + }); + + // Add remove-row button callbacks + $(opts.modal).find('.button-row-remove').click(function() { + var pk = $(this).attr('pk'); + + $(opts.modal).find(`#allocation_row_${pk}`).remove(); + }); + }, + onSubmit: function(fields, opts) { + // Extract data elements from the form + var data = { + items: [], + shipment: getFormFieldValue( + 'shipment', + {}, + opts + ) + }; + + var item_pk_values = []; + + line_items.forEach(function(item) { + + var pk = item.pk; + + var quantity = getFormFieldValue( + `items_quantity_${pk}`, + {}, + opts + ); + + var stock_item = getFormFieldValue( + `items_stock_item_${pk}`, + {}, + opts + ); + + if (quantity != null) { + data.items.push({ + line_item: pk, + stock_item: stock_item, + quantity: quantity, + }); + + item_pk_values.push(pk); + } + }); + + // Provide nested values + opts.nested = { + 'items': item_pk_values + }; + + inventreePut( + opts.url, + data, + { + method: 'POST', + success: function(response) { + $(opts.modal).modal('hide'); + + if (options.success) { + options.success(response); + } + }, + error: function(xhr) { + switch (xhr.status) { + case 400: + handleFormErrors(xhr.responseJSON, fields, opts); + break; + default: + $(opts.modal).modal('hide'); + showApiError(xhr); + break; + } + } + } + ); + }, + }); +} + function loadSalesOrderAllocationTable(table, options={}) { /** @@ -1772,6 +2073,20 @@ function loadSalesOrderLineItemTable(table, options={}) { var line_item = $(table).bootstrapTable('getRowByUniqueId', pk); + allocateStockToSalesOrder( + options.order, + [ + line_item + ], + { + success: function() { + $(table).bootstrapTable('refresh'); + } + } + ); + + return; + // Quantity remaining to be allocated var remaining = (line_item.quantity || 0) - (line_item.allocated || 0); From 4d0f905afcd04e48f55e0d1ca1499d315cdc0e59 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 27 Oct 2021 00:47:15 +1100 Subject: [PATCH 13/54] Auto-fill the "quantity" field for the salesorder allocation table --- InvenTree/templates/js/translated/order.js | 85 ++++++---------------- 1 file changed, 23 insertions(+), 62 deletions(-) diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index b02c757df4..9d6db122cc 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -1296,6 +1296,8 @@ function allocateStockToSalesOrder(order_id, line_items, options={}) { var todo = "auto-calculate remaining quantity"; + var todo = "see how it is done for the build order allocation system!"; + var remaining = 0; table_entries += renderLineItemRow(line_item, remaining); @@ -1405,7 +1407,22 @@ function allocateStockToSalesOrder(order_id, line_items, options={}) { onSelect: function(data, field, opts) { // Adjust the 'quantity' field based on availability - var todo = "actually do this"; + if (!('quantity' in data)) { + return; + } + + // Calculate the available quantity + var available = Math.max((data.quantity || 0) - (data.allocated || 0), 0); + + // Remaining quantity to be allocated? + var todo = "fix this calculation!"; + var remaining = opts.quantity || available; + + // Maximum amount that we need + var desired = Math.min(available, remaining); + + updateFieldValue(`items_quantity_${pk}`, desired, {}, opts); + }, adjustFilters: function(filters) { // Restrict query to the selected location @@ -1420,6 +1437,11 @@ function allocateStockToSalesOrder(order_id, line_items, options={}) { filters.location = location; filters.cascade = true; + // Exclude expired stock? + if (global_settings.STOCK_ENABLE_EXPIRY && !global_settings.STOCK_ALLOW_EXPIRED_SALE) { + fields.item.filters.expired = false; + } + return filters; }, noResults: function(query) { @@ -2084,67 +2106,6 @@ function loadSalesOrderLineItemTable(table, options={}) { } } ); - - return; - - // Quantity remaining to be allocated - var remaining = (line_item.quantity || 0) - (line_item.allocated || 0); - - if (remaining < 0) { - remaining = 0; - } - - var fields = { - // SalesOrderLineItem reference - line: { - hidden: true, - value: pk, - }, - item: { - filters: { - part_detail: true, - location_detail: true, - in_stock: true, - part: line_item.part, - include_variants: false, - exclude_so_allocation: options.order, - }, - auto_fill: true, - onSelect: function(data, field, opts) { - // Quantity available from this stock item - - if (!('quantity' in data)) { - return; - } - - // Calculate the available quantity - var available = Math.max((data.quantity || 0) - (data.allocated || 0), 0); - - // Maximum amount that we need - var desired = Math.min(available, remaining); - - updateFieldValue('quantity', desired, {}, opts); - } - }, - quantity: { - value: remaining, - }, - }; - - // Exclude expired stock? - if (global_settings.STOCK_ENABLE_EXPIRY && !global_settings.STOCK_ALLOW_EXPIRED_SALE) { - fields.item.filters.expired = false; - } - - constructForm( - `/api/order/so-allocation/`, - { - method: 'POST', - fields: fields, - title: '{% trans "Allocate Stock Item" %}', - onSuccess: reloadTable, - } - ); }); // Callback for creating a new build From 2eb93b5a499ea96772370d10593bf01ebbc67c35 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 27 Oct 2021 00:57:47 +1100 Subject: [PATCH 14/54] Add functionality to create a new sales order shipment - From the "New Shipment" button - As a secondary modal from the stock allocation dialgo --- InvenTree/order/models.py | 1 - .../templates/order/sales_order_detail.html | 10 +++++ InvenTree/templates/js/translated/order.js | 40 +++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 05eabe3788..89585e218f 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -913,7 +913,6 @@ class SalesOrderShipment(models.Model): Attributes: order: SalesOrder reference - status: Status of this shipment (see SalesOrderStatus) shipment_date: Date this shipment was "shipped" (or null) checked_by: User reference field indicating who checked this order reference: Custom reference text for this shipment (e.g. consignment number?) diff --git a/InvenTree/order/templates/order/sales_order_detail.html b/InvenTree/order/templates/order/sales_order_detail.html index 87b6d80acf..a70f932466 100644 --- a/InvenTree/order/templates/order/sales_order_detail.html +++ b/InvenTree/order/templates/order/sales_order_detail.html @@ -113,6 +113,16 @@ order: {{ order.pk }}, shipped: false, }); + + $('#new-shipment').click(function() { + createSalesOrderShipment({ + order: {{ order.pk }}, + onSuccess: function(data) { + $('#pending-shipments-table').bootstrapTable('refresh'); + } + }); + }); + {% endif %} loadSalesOrderShipmentTable('#completed-shipments-table', { diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 9d6db122cc..1d53824c7f 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -35,6 +35,38 @@ removePurchaseOrderLineItem, */ + +function salesOrderShipmentFields(options={}) { + var fields = { + order: {}, + reference: {}, + }; + + // If order is specified, hide the order field + if (options.order) { + fields.order.value = options.order; + fields.order.hidden = true; + } + + return fields; +} + + +// Open a dialog to create a new sales order shipment +function createSalesOrderShipment(options={}) { + constructForm('{% url "api-so-shipment-list" %}', { + method: 'POST', + fields: salesOrderShipmentFields(options), + title: '{% trans "Create New Shipment" %}', + onSuccess: function(data) { + if (options.onSuccess) { + options.onSuccess(data); + } + } + }); +} + + // Create a new SalesOrder function createSalesOrder(options={}) { @@ -1351,6 +1383,14 @@ function allocateStockToSalesOrder(order_id, line_items, options={}) { }, value: options.shipment || null, auto_fill: true, + secondary: { + title: '{% trans "New Shipment" %}', + fields: function() { + return salesOrderShipmentFields({ + order: order_id + }); + } + } } }, preFormContent: html, From 96be11edd4fe57de6574d9b63d7b0d6c7503fbc4 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 27 Oct 2021 01:05:10 +1100 Subject: [PATCH 15/54] Add 'status' field to shipment table - Is not yet implemented in the db model --- InvenTree/templates/js/translated/order.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 1d53824c7f..ce31549df4 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -1220,6 +1220,10 @@ function loadSalesOrderShipmentTable(table, options={}) { title: '{% trans "Reference" %}', switchable: false, }, + { + field: 'status', + title: '{% trans "Status" %}', + }, { field: 'shipment_date', title: '{% trans "Shipment Date" %}', From f32dfb01a2902d0fe6d261906c02b8726ebc7cb3 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 27 Oct 2021 01:15:39 +1100 Subject: [PATCH 16/54] Add breadcrumbs for build order page --- InvenTree/build/templates/build/build_base.html | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/InvenTree/build/templates/build/build_base.html b/InvenTree/build/templates/build/build_base.html index 1731e00403..3a33a57cdb 100644 --- a/InvenTree/build/templates/build/build_base.html +++ b/InvenTree/build/templates/build/build_base.html @@ -9,6 +9,17 @@ {% inventree_title %} | {% trans "Build Order" %} - {{ build }} {% endblock %} +{% block pre_content %} + +{% endblock %} + {% block below_thumbnail %}
    From 361f4498df0cdd05810344841589a9c113b34977 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 30 Oct 2021 23:51:59 +1100 Subject: [PATCH 17/54] Fix broken tables removed by conflict --- .../templates/order/sales_order_detail.html | 56 +++++++++++++++++++ .../order/templates/order/so_navbar.html | 47 ---------------- .../order/templates/order/so_sidebar.html | 1 + 3 files changed, 57 insertions(+), 47 deletions(-) delete mode 100644 InvenTree/order/templates/order/so_navbar.html diff --git a/InvenTree/order/templates/order/sales_order_detail.html b/InvenTree/order/templates/order/sales_order_detail.html index 5d4db70a34..109811452a 100644 --- a/InvenTree/order/templates/order/sales_order_detail.html +++ b/InvenTree/order/templates/order/sales_order_detail.html @@ -38,6 +38,35 @@
    +
    + {% if order.is_pending %} +
    +
    +

    {% trans "Pending Shipments" %}

    + {% include "spacer.html" %} +
    + +
    +
    +
    +
    + {% if roles.sales_order.change %} +
    +
    + {% endif %} +
    +
    + {% endif %} +
    +

    {% trans "Completed Shipments" %}

    +
    +
    +
    +
    +
    +

    {% trans "Build Orders" %}

    @@ -90,6 +119,33 @@ {% block js_ready %} {{ block.super }} + // Callback when the "shipments" panel is first loaded + onPanelLoad('order-shipments', function() { + + {% if order.is_pending %} + loadSalesOrderShipmentTable('#pending-shipments-table', { + order: {{ order.pk }}, + shipped: false, + }); + + $('#new-shipment').click(function() { + createSalesOrderShipment({ + order: {{ order.pk }}, + onSuccess: function(data) { + $('#pending-shipments-table').bootstrapTable('refresh'); + } + }); + }); + + {% endif %} + + loadSalesOrderShipmentTable('#completed-shipments-table', { + order: {{ order.pk }}, + shipped: true, + }); + + }); + $('#edit-notes').click(function() { constructForm('{% url "api-so-detail" order.pk %}', { fields: { diff --git a/InvenTree/order/templates/order/so_navbar.html b/InvenTree/order/templates/order/so_navbar.html deleted file mode 100644 index 814df0fca6..0000000000 --- a/InvenTree/order/templates/order/so_navbar.html +++ /dev/null @@ -1,47 +0,0 @@ -{% load i18n %} -{% load static %} -{% load inventree_extras %} - - \ No newline at end of file diff --git a/InvenTree/order/templates/order/so_sidebar.html b/InvenTree/order/templates/order/so_sidebar.html index be924eb37b..6ee68949ee 100644 --- a/InvenTree/order/templates/order/so_sidebar.html +++ b/InvenTree/order/templates/order/so_sidebar.html @@ -3,6 +3,7 @@ {% load inventree_extras %} {% include "sidebar_item.html" with label='order-items' text="Line Items" icon="fa-list-ol" %} +{% include "sidebar_item.html" with label='order-shipments' text="Shipments" icon="fa-truck" %} {% include "sidebar_item.html" with label='order-builds' text="Build Orders" icon="fa-tools" %} {% include "sidebar_item.html" with label='order-attachments' text="Attachments" icon="fa-paperclip" %} {% include "sidebar_item.html" with label='order-notes' text="Notes" icon="fa-clipboard" %} From 2d9f7364fd768164edf765d3fe9ecb9bd07aacba Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 17 Nov 2021 21:10:32 +1100 Subject: [PATCH 18/54] Fix action buttons for "company" page --- .../templates/company/company_base.html | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/InvenTree/company/templates/company/company_base.html b/InvenTree/company/templates/company/company_base.html index b9cd650395..488ed2bc80 100644 --- a/InvenTree/company/templates/company/company_base.html +++ b/InvenTree/company/templates/company/company_base.html @@ -23,15 +23,20 @@ {% endif %} -{% if perms.company.change_company %} - -{% endif %} -{% if perms.company.delete_company %} - +{% if perms.company.change_company or perms.company.delete_company %} +
    + + +
    {% endif %} {% endblock %} From 1c8b134ede23146af502b80d3a2bb20049a08af4 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 17 Nov 2021 22:29:59 +1100 Subject: [PATCH 19/54] Add part category link to stock item detail page --- InvenTree/stock/templates/stock/item_base.html | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 0f8d81203a..ff117cd071 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -232,6 +232,15 @@ {% endif %} + {% if item.part.category %} + + + {% trans "Part Category" %} + + {{ item.part.category }} + + + {% endif %} {% if item.serialized %} From d3b9adc87a693e70263c02f883888ed89444b32b Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 26 Nov 2021 10:50:43 +1100 Subject: [PATCH 20/54] Separate "completed shipments" onto its own tab --- InvenTree/order/templates/order/sales_order_detail.html | 7 +++++-- InvenTree/order/templates/order/so_sidebar.html | 8 ++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/InvenTree/order/templates/order/sales_order_detail.html b/InvenTree/order/templates/order/sales_order_detail.html index bc2c481eae..00314e8fe1 100644 --- a/InvenTree/order/templates/order/sales_order_detail.html +++ b/InvenTree/order/templates/order/sales_order_detail.html @@ -37,8 +37,8 @@
    +{% if order.is_pending %}
    - {% if order.is_pending %}

    {% trans "Pending Shipments" %}

    @@ -57,7 +57,10 @@ {% endif %}
    - {% endif %} +
    +{% endif %} + +

    {% trans "Completed Shipments" %}

    diff --git a/InvenTree/order/templates/order/so_sidebar.html b/InvenTree/order/templates/order/so_sidebar.html index c18c766e6c..c43e0537c5 100644 --- a/InvenTree/order/templates/order/so_sidebar.html +++ b/InvenTree/order/templates/order/so_sidebar.html @@ -4,8 +4,12 @@ {% trans "Line Items" as text %} {% include "sidebar_item.html" with label='order-items' text=text icon="fa-list-ol" %} -{% trans "Shipments" as text %} -{% include "sidebar_item.html" with label='order-shipments' text=text icon="fa-truck" %} +{% if order.is_pending %} +{% trans "Pending Shipments" as text %} +{% include "sidebar_item.html" with label='order-shipments' text=text icon="fa-truck-loading" %} +{% endif %} +{% trans "Completed Shipments" as text %} +{% include "sidebar_item.html" with label='order-shipments-complete' text=text icon="fa-truck" %} {% trans "Build Orders" as text %} {% include "sidebar_item.html" with label='order-builds' text=text icon="fa-tools" %} {% trans "Attachments" as text %} From 136fc67675d9d2210a0ae4cfa63cd3800d402c6d Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 26 Nov 2021 11:25:11 +1100 Subject: [PATCH 21/54] Adds data toolbar --- InvenTree/order/templates/order/sales_order_detail.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/InvenTree/order/templates/order/sales_order_detail.html b/InvenTree/order/templates/order/sales_order_detail.html index 00314e8fe1..9b7dba7bc9 100644 --- a/InvenTree/order/templates/order/sales_order_detail.html +++ b/InvenTree/order/templates/order/sales_order_detail.html @@ -65,7 +65,9 @@

    {% trans "Completed Shipments" %}

    -
    +
    +
    +
    From 64d2674c04f4950eda6aee572f46190fa8d41373 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 26 Nov 2021 20:38:37 +1100 Subject: [PATCH 22/54] Add action menu (hide for now) --- InvenTree/order/templates/order/sales_order_detail.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/InvenTree/order/templates/order/sales_order_detail.html b/InvenTree/order/templates/order/sales_order_detail.html index 9b7dba7bc9..44521e809f 100644 --- a/InvenTree/order/templates/order/sales_order_detail.html +++ b/InvenTree/order/templates/order/sales_order_detail.html @@ -44,6 +44,14 @@

    {% trans "Pending Shipments" %}

    {% include "spacer.html" %}
    + {% if 0 %} + + + {% endif %} From d5e74896237e2327632c3bd6f0c66fa557f96260 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 26 Nov 2021 22:31:25 +1100 Subject: [PATCH 23/54] Table filters --- .../templates/order/sales_order_detail.html | 19 +++++++++++++++++-- InvenTree/templates/js/translated/order.js | 6 +++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/InvenTree/order/templates/order/sales_order_detail.html b/InvenTree/order/templates/order/sales_order_detail.html index 44521e809f..67c4f951e4 100644 --- a/InvenTree/order/templates/order/sales_order_detail.html +++ b/InvenTree/order/templates/order/sales_order_detail.html @@ -61,6 +61,9 @@
    {% if roles.sales_order.change %}
    +
    + {% include "filter_list.html" with id="pending-shipments" %} +
    {% endif %}
    @@ -74,6 +77,9 @@
    +
    + {% include "filter_list.html" with id="completed-shipments" %} +
    @@ -84,7 +90,12 @@

    {% trans "Build Orders" %}

    -
    +
    +
    + {% include "filter_list.html" with id='build' %} +
    +
    +
  • @@ -138,6 +149,7 @@ loadSalesOrderShipmentTable('#pending-shipments-table', { order: {{ order.pk }}, shipped: false, + filter_target: '#filter-list-pending-shipments', }); $('#new-shipment').click(function() { @@ -151,11 +163,14 @@ {% endif %} + }); + + onPanelLoad('order-shipments-complete', function() { loadSalesOrderShipmentTable('#completed-shipments-table', { order: {{ order.pk }}, shipped: true, + filter_target: '#filter-list-completed-shipments', }); - }); $('#edit-notes').click(function() { diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 5fe4754e4a..a403d68fed 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -1156,7 +1156,7 @@ function loadSalesOrderShipmentTable(table, options={}) { filters[key] = options.params[key]; } - var todo = "Setup filter list for this table"; + setupFilterList('salesordershipment', $(table), options.filter_target); function makeShipmentActions(row) { // Construct "actions" for the given shipment row @@ -1733,6 +1733,10 @@ function showAllocationSubTable(index, row, element, options) { data: row.allocations, showHeader: false, columns: [ + { + field: 'part', + title: '{% trans "Part" %}', + }, { field: 'allocated', title: '{% trans "Quantity" %}', From c943b320e6596aa8a9596f36cdab7aceae673afb Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 26 Nov 2021 23:02:29 +1100 Subject: [PATCH 24/54] shipment table tweaks --- InvenTree/order/serializers.py | 2 +- InvenTree/templates/js/translated/order.js | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 42e08fe9cb..f75d373d13 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -489,7 +489,7 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer): def __init__(self, *args, **kwargs): order_detail = kwargs.pop('order_detail', False) - part_detail = kwargs.pop('part_detail', False) + part_detail = kwargs.pop('part_detail', True) item_detail = kwargs.pop('item_detail', False) location_detail = kwargs.pop('location_detail', False) diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index a403d68fed..6646abafee 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -1206,7 +1206,11 @@ function loadSalesOrderShipmentTable(table, options={}) { detailFormatter: function(index, row, element) { return showAllocationSubTable(index, row, element, options); }, - onPostBody: setupShipmentCallbacks, + onPostBody: function() { + setupShipmentCallbacks(); + + $(table).bootstrapTable('expandAllRows'); + }, formatNoMatches: function() { return '{% trans "No matching shipments found" %}'; }, @@ -1218,7 +1222,7 @@ function loadSalesOrderShipmentTable(table, options={}) { }, { field: 'reference', - title: '{% trans "Reference" %}', + title: '{% trans "Shipment" %}', switchable: false, }, { @@ -1734,8 +1738,11 @@ function showAllocationSubTable(index, row, element, options) { showHeader: false, columns: [ { - field: 'part', + field: 'part_detail', title: '{% trans "Part" %}', + formatter: function(part, row) { + return imageHoverIcon(part.thumbnail) + renderLink(part.full_name, `/part/${part.pk}/`); + } }, { field: 'allocated', From 8aed68a1d1b44877f217251a31207d54dc86c472 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 26 Nov 2021 23:20:27 +1100 Subject: [PATCH 25/54] Adds "shipped" field to SalesOrderLineItem - This is an internal tracker of quantity of items shipped - Updated by the database logic (not by the user) - Keeps track of how many items have been shipped against a lineitem - Does not matter if the actual stock items are later removed from the database --- .../0057_salesorderlineitem_shipped.py | 20 ++++++ .../migrations/0058_auto_20211126_1210.py | 62 +++++++++++++++++++ InvenTree/order/models.py | 9 +++ InvenTree/order/serializers.py | 3 + 4 files changed, 94 insertions(+) create mode 100644 InvenTree/order/migrations/0057_salesorderlineitem_shipped.py create mode 100644 InvenTree/order/migrations/0058_auto_20211126_1210.py diff --git a/InvenTree/order/migrations/0057_salesorderlineitem_shipped.py b/InvenTree/order/migrations/0057_salesorderlineitem_shipped.py new file mode 100644 index 0000000000..e11eb62cbb --- /dev/null +++ b/InvenTree/order/migrations/0057_salesorderlineitem_shipped.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.5 on 2021-11-26 12:06 + +import InvenTree.fields +import django.core.validators +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0056_alter_salesorderallocation_shipment'), + ] + + operations = [ + migrations.AddField( + model_name='salesorderlineitem', + name='shipped', + field=InvenTree.fields.RoundingDecimalField(decimal_places=5, default=0, help_text='Shipped quantity', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Shipped'), + ), + ] diff --git a/InvenTree/order/migrations/0058_auto_20211126_1210.py b/InvenTree/order/migrations/0058_auto_20211126_1210.py new file mode 100644 index 0000000000..2736416e66 --- /dev/null +++ b/InvenTree/order/migrations/0058_auto_20211126_1210.py @@ -0,0 +1,62 @@ +# Generated by Django 3.2.5 on 2021-11-26 12:10 + +from django.db import migrations + +from InvenTree.status_codes import SalesOrderStatus + + +def calculate_shipped_quantity(apps, schema_editor): + """ + In migration 0057 we added a new field 'shipped' to the SalesOrderLineItem model. + + This field is used to record the number of items shipped, + even if the actual stock items get deleted from the database. + + For existing orders in the database, we calculate this as follows: + + - If the order is "shipped" then we use the total quantity + - Otherwise, we use the "fulfilled" calculated quantity + + """ + + StockItem = apps.get_model('stock', 'stockitem') + SalesOrderLineItem = apps.get_model('order', 'salesorderlineitem') + + for item in SalesOrderLineItem.objects.all(): + + if item.order.status == SalesOrderStatus.SHIPPED: + item.shipped = item.quantity + else: + # Calculate total stock quantity of items allocated to this order? + items = StockItem.objects.filter( + sales_order=item.order, + part=item.part + ) + + q = sum([item.quantity for item in items]) + + item.shipped = q + + item.save() + + +def reverse_calculate_shipped_quantity(apps, schema_editor): + """ + Provided only for reverse migration compatibility. + This function does nothing. + """ + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0057_salesorderlineitem_shipped'), + ] + + operations = [ + migrations.RunPython( + calculate_shipped_quantity, + reverse_code=reverse_calculate_shipped_quantity + ) + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index f87e337b35..73c507b38d 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -814,6 +814,7 @@ class SalesOrderLineItem(OrderLineItem): order: Link to the SalesOrder that this line item belongs to part: Link to a Part object (may be null) sale_price: The unit sale price for this OrderLineItem + shipped: The number of items which have actually shipped against this line item """ @staticmethod @@ -838,6 +839,14 @@ class SalesOrderLineItem(OrderLineItem): help_text=_('Unit sale price'), ) + shipped = RoundingDecimalField( + verbose_name=_('Shipped'), + help_text=_('Shipped quantity'), + default=0, + max_digits=15, decimal_places=5, + validators=[MinValueValidator(0)] + ) + class Meta: unique_together = [ ] diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index f75d373d13..6a139a32a9 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -555,6 +555,8 @@ class SOLineItemSerializer(InvenTreeModelSerializer): allocated = serializers.FloatField(source='allocated_quantity', read_only=True) fulfilled = serializers.FloatField(source='fulfilled_quantity', read_only=True) + shipped = InvenTreeDecimalField(read_only=True) + sale_price = InvenTreeMoneySerializer( allow_null=True ) @@ -584,6 +586,7 @@ class SOLineItemSerializer(InvenTreeModelSerializer): 'sale_price', 'sale_price_currency', 'sale_price_string', + 'shipped', ] From 0b997dc7848b5c1570ac6e0167276ab9724fa89c Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 26 Nov 2021 23:30:34 +1100 Subject: [PATCH 26/54] Display both 'allocated' and 'fulfilled' quantity values in salesorder table --- InvenTree/order/serializers.py | 2 - InvenTree/templates/js/translated/order.js | 92 ++++++++++++++-------- 2 files changed, 59 insertions(+), 35 deletions(-) diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 6a139a32a9..8cb1e8a699 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -553,7 +553,6 @@ class SOLineItemSerializer(InvenTreeModelSerializer): quantity = InvenTreeDecimalField() allocated = serializers.FloatField(source='allocated_quantity', read_only=True) - fulfilled = serializers.FloatField(source='fulfilled_quantity', read_only=True) shipped = InvenTreeDecimalField(read_only=True) @@ -576,7 +575,6 @@ class SOLineItemSerializer(InvenTreeModelSerializer): 'allocated', 'allocations', 'quantity', - 'fulfilled', 'reference', 'notes', 'order', diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 6646abafee..5766b7216d 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -1918,7 +1918,7 @@ function loadSalesOrderLineItemTable(table, options={}) { */ { sortable: true, - sortName: 'part__name', + sortName: 'part_detail.name', field: 'part', title: '{% trans "Part" %}', switchable: false, @@ -2015,40 +2015,66 @@ function loadSalesOrderLineItemTable(table, options={}) { }, }, ); + + columns.push( + { + field: 'allocated', + title: '{% trans "Allocated" %}', + switchable: false, + sortable: true, + formatter: function(value, row, index, field) { + return makeProgressBar(row.allocated, row.quantity, { + id: `order-line-progress-${row.pk}`, + }); + }, + sorter: function(valA, valB, rowA, rowB) { + + var A = rowA.allocated; + var B = rowB.allocated; + + if (A == 0 && B == 0) { + return (rowA.quantity > rowB.quantity) ? 1 : -1; + } + + var progressA = parseFloat(A) / rowA.quantity; + var progressB = parseFloat(B) / rowB.quantity; + + return (progressA < progressB) ? 1 : -1; + } + }, + ); } - columns.push( - { - field: 'allocated', - title: pending ? '{% trans "Allocated" %}' : '{% trans "Fulfilled" %}', - switchable: false, - formatter: function(value, row, index, field) { + columns.push({ + field: 'shipped', + title: '{% trans "Shipped" %}', + switchable: false, + sortable: true, + formatter: function(value, row) { + return makeProgressBar(row.shipped, row.quantity, { + id: `order-line-shipped-${row.pk}` + }); + }, + sorter: function(valA, valB, rowA, rowB) { + var A = rowA.shipped; + var B = rowB.shipped; - var quantity = pending ? row.allocated : row.fulfilled; - return makeProgressBar(quantity, row.quantity, { - id: `order-line-progress-${row.pk}`, - }); - }, - sorter: function(valA, valB, rowA, rowB) { - - var A = pending ? rowA.allocated : rowA.fulfilled; - var B = pending ? rowB.allocated : rowB.fulfilled; - - if (A == 0 && B == 0) { - return (rowA.quantity > rowB.quantity) ? 1 : -1; - } - - var progressA = parseFloat(A) / rowA.quantity; - var progressB = parseFloat(B) / rowB.quantity; - - return (progressA < progressB) ? 1 : -1; + if (A == 0 && B == 0) { + return (rowA.quantity > rowB.quantity) ? 1 : -1; } - }, - { - field: 'notes', - title: '{% trans "Notes" %}', - }, - ); + + var progressA = parseFloat(A) / rowA.quantity; + var progressB = parseFloat(B) / rowB.quantity; + + return (progressA < progressB) ? 1 : -1; + } + }); + + columns.push( + { + field: 'notes', + title: '{% trans "Notes" %}', + }); if (pending) { columns.push({ @@ -2230,7 +2256,7 @@ function loadSalesOrderLineItemTable(table, options={}) { $(table).inventreeTable({ onPostBody: setupCallbacks, name: 'salesorderlineitems', - sidePagination: 'server', + sidePagination: 'client', formatNoMatches: function() { return '{% trans "No matching line items" %}'; }, @@ -2246,7 +2272,7 @@ function loadSalesOrderLineItemTable(table, options={}) { // Order is pending return row.allocated > 0; } else { - return row.fulfilled > 0; + return row.shipped > 0; } }, detailFormatter: function(index, row, element) { From c6b11b5e3865400b9b669839f964a60db81f116a Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 29 Nov 2021 23:11:21 +1100 Subject: [PATCH 27/54] New logic for completing a SalesOrderShipment --- InvenTree/order/models.py | 52 ++++++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 866ecd967a..37092fd0e3 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -595,6 +595,14 @@ class SalesOrder(Order): return False + def is_completed(self): + """ + Check if this order is "shipped" (all line items delivered), + and mark it as "shipped" if so. + """ + + return all([line.is_completed() for line in self.lines.all()]) + @transaction.atomic def ship_order(self, user): """ Mark this order as 'shipped' """ @@ -786,13 +794,15 @@ class PurchaseOrderLineItem(OrderLineItem): ) def get_destination(self): - """Show where the line item is or should be placed""" - # NOTE: If a line item gets split when recieved, only an arbitrary - # stock items location will be reported as the location for the - # entire line. - for stock in stock_models.StockItem.objects.filter( - supplier_part=self.part, purchase_order=self.order - ): + """ + Show where the line item is or should be placed + + NOTE: If a line item gets split when recieved, only an arbitrary + stock items location will be reported as the location for the + entire line. + """ + + for stock in stock_models.StockItem.objects.filter(supplier_part=self.part, purchase_order=self.order): if stock.location: return stock.location if self.destination: @@ -882,6 +892,13 @@ class SalesOrderLineItem(OrderLineItem): """ Return True if this line item is over allocated """ return self.allocated_quantity() > self.quantity + def is_completed(self): + """ + Return True if this line item is completed (has been fully shipped) + """ + + return self.shipped >= self.quantity + def get_next_shipment_number(): """ @@ -980,7 +997,7 @@ class SalesOrderShipment(models.Model): ) @transaction.atomic - def complete_shipment(self): + def complete_shipment(self, user): """ Complete this particular shipment: @@ -989,16 +1006,25 @@ class SalesOrderShipment(models.Model): 3. Set the "shipment_date" to now """ + if self.shipment_date: + # Ignore, shipment has already been sent! + return + # Iterate through each stock item assigned to this shipment for allocation in self.allocations.all(): - pass - - + + # Mark the allocation as "complete" + allocation.complete_allocation(user) # Update the "shipment" date self.shipment_date = datetime.now() + self.shipped_by = user self.save() + # Finally, check if the order is fully shipped + if self.order.is_completed(): + self.order.status = SalesOrderStatus.SHIPPED + self.order.save() class SalesOrderAllocation(models.Model): @@ -1134,6 +1160,10 @@ class SalesOrderAllocation(models.Model): user=user ) + # Update the 'shipped' quantity + self.line.shipped += self.quantity + self.line.save() + # Update our own reference to the StockItem # (It may have changed if the stock was split) self.item = item From f3f3030b3773bcd9570c0e26a302cdd5630badd2 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 30 Nov 2021 00:02:03 +1100 Subject: [PATCH 28/54] Adds API endpoint to "ship" a sales order shipment --- InvenTree/order/api.py | 27 ++++++++++++++ InvenTree/order/models.py | 18 +++++++--- InvenTree/order/serializers.py | 41 +++++++++++++++++++++- InvenTree/templates/js/translated/order.js | 39 +++++++++++++++++--- InvenTree/templates/js/translated/stock.js | 3 ++ 5 files changed, 119 insertions(+), 9 deletions(-) diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 2ee9a7694d..f4e10bff08 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -767,6 +767,32 @@ class SOShipmentDetail(generics.RetrieveUpdateAPIView): serializer_class = serializers.SalesOrderShipmentSerializer +class SOShipmentComplete(generics.CreateAPIView): + """ + API endpoint for completing (shipping) a SalesOrderShipment + """ + + queryset = models.SalesOrderShipment.objects.all() + serializer_class = serializers.SalesOrderShipmentCompleteSerializer + + def get_serializer_context(self): + """ + Pass the request object to the serializer + """ + + ctx = super().get_serializer_context() + ctx['request'] = self.request + + try: + ctx['shipment'] = models.SalesOrderShipment.objects.get( + pk=self.kwargs.get('pk', None) + ) + except: + pass + + return ctx + + class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin): """ API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload) @@ -829,6 +855,7 @@ order_api_urls = [ url(r'^shipment/', include([ url(r'^(?P\d+)/', include([ + url(r'^ship/$', SOShipmentComplete.as_view(), name='api-so-shipment-ship'), url(r'^.*$', SOShipmentDetail.as_view(), name='api-so-shipment-detail'), ])), url(r'^.*$', SOShipmentList.as_view(), name='api-so-shipment-list'), diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 37092fd0e3..10b0a45f62 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -996,6 +996,15 @@ class SalesOrderShipment(models.Model): help_text=_('Shipment tracking information'), ) + def check_can_complete(self): + + if self.shipment_date: + # Shipment has already been sent! + raise ValidationError(_("Shipment has already been sent")) + + if self.allocations.count() == 0: + raise ValidationError(_("Shipment has no allocated stock items")) + @transaction.atomic def complete_shipment(self, user): """ @@ -1006,12 +1015,13 @@ class SalesOrderShipment(models.Model): 3. Set the "shipment_date" to now """ - if self.shipment_date: - # Ignore, shipment has already been sent! - return + # Check if the shipment can be completed (throw error if not) + self.check_can_complete() + + allocations = self.allocations.all() # Iterate through each stock item assigned to this shipment - for allocation in self.allocations.all(): + for allocation in allocations: # Mark the allocation as "complete" allocation.complete_allocation(user) diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index d2179d16b7..cffdbaec32 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -25,7 +25,6 @@ from InvenTree.helpers import normalize from InvenTree.serializers import InvenTreeModelSerializer from InvenTree.serializers import InvenTreeDecimalField from InvenTree.serializers import InvenTreeMoneySerializer -from InvenTree.serializers import InvenTreeAttachmentSerializerField from InvenTree.status_codes import StockStatus import order.models @@ -617,6 +616,46 @@ class SalesOrderShipmentSerializer(InvenTreeModelSerializer): ] +class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer): + """ + Serializer for completing (shipping) a SalesOrderShipment + """ + + class Meta: + model = order.models.SalesOrderShipment + + fields = [ + 'tracking_number', + ] + + def validate(self, data): + + data = super().validate(data) + + shipment = self.context.get('shipment', None) + + if not shipment: + raise ValidationError(_("No shipment details provided")) + + shipment.check_can_complete() + + return data + + def save(self): + + shipment = self.context.get('shipment', None) + + if not shipment: + return + + data = self.validated_data + + request = self.context['request'] + user = request.user + + shipment.complete_shipment(user) + + class SOShipmentAllocationItemSerializer(serializers.Serializer): """ A serializer for allocating a single stock-item against a SalesOrder shipment diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 478bccc2a5..50d6b4444e 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -20,6 +20,7 @@ /* exported allocateStockToSalesOrder, + completeShipment, createSalesOrder, editPurchaseOrderLineItem, exportOrder, @@ -52,6 +53,26 @@ function salesOrderShipmentFields(options={}) { } +/* + * Complete a shipment + */ +function completeShipment(shipment_id) { + + constructForm(`/api/order/so/shipment/${shipment_id}/ship/`, { + method: 'POST', + title: '{% trans "Complete Shipment" %}', + fields: { + tracking_number: {}, + }, + confirm: true, + confirmMessage: '{% trans "Confirm Shipment" %}', + onSuccess: function(data) { + // TODO + } + }); +} + + // Open a dialog to create a new sales order shipment function createSalesOrderShipment(options={}) { constructForm('{% url "api-so-shipment-list" %}', { @@ -1183,6 +1204,8 @@ function loadSalesOrderShipmentTable(table, options={}) { html += makeIconButton('fa-edit icon-blue', 'button-shipment-edit', pk, '{% trans "Edit shipment" %}'); + html += makeIconButton('fa-truck icon-green', 'button-shipment-ship', pk, '{% trans "Complete shipment" %}'); + html += `
    `; return html; @@ -1205,6 +1228,12 @@ function loadSalesOrderShipmentTable(table, options={}) { } }); }); + + $(table).find('.button-shipment-ship').click(function() { + var pk = $(this).attr('pk'); + + completeShipment(pk); + }); } $(table).inventreeTable({ @@ -1505,7 +1534,7 @@ function allocateStockToSalesOrder(order_id, line_items, options={}) { // Exclude expired stock? if (global_settings.STOCK_ENABLE_EXPIRY && !global_settings.STOCK_ALLOW_EXPIRED_SALE) { - fields.item.filters.expired = false; + filters.expired = false; } return filters; @@ -1781,14 +1810,16 @@ function showAllocationSubTable(index, row, element, options) { title: '{% trans "Location" %}', formatter: function(value, row, index, field) { - // Location specified - if (row.location) { + if (shipped) { + return `{% trans "Shipped to customer" %}`; + } else if (row.location) { + // Location specified return renderLink( row.location_detail.pathstring || '{% trans "Location" %}', `/stock/location/${row.location}/` ); } else { - return `{% trans "Stock location not specified" %}`; + return `{% trans "Stock location not specified" %}`; } }, }, diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 5e92299f03..35a3a307ce 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -94,6 +94,9 @@ function serializeStockItem(pk, options={}) { }); } + options.confirm = true; + options.confirmMessage = '{% trans "Confirm Stock Serialization" %}'; + constructForm(url, options); } From 3f9b280e1724791161306c5bd05e05bddb35fef3 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 30 Nov 2021 00:42:30 +1100 Subject: [PATCH 29/54] Allow shipment numbers to be non-unique for different sales orders - must be unique for a given sales order --- .../migrations/0053_salesordershipment.py | 2 +- .../migrations/0060_auto_20211129_1339.py | 22 ++++++ InvenTree/order/models.py | 42 +++-------- .../templates/order/sales_order_detail.html | 1 + InvenTree/order/test_sales_order.py | 10 ++- InvenTree/templates/js/translated/order.js | 75 +++++++++++++++---- 6 files changed, 100 insertions(+), 52 deletions(-) create mode 100644 InvenTree/order/migrations/0060_auto_20211129_1339.py diff --git a/InvenTree/order/migrations/0053_salesordershipment.py b/InvenTree/order/migrations/0053_salesordershipment.py index c2cc40f4db..f36895c5ee 100644 --- a/InvenTree/order/migrations/0053_salesordershipment.py +++ b/InvenTree/order/migrations/0053_salesordershipment.py @@ -22,7 +22,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('shipment_date', models.DateField(blank=True, help_text='Date of shipment', null=True, verbose_name='Shipment Date')), - ('reference', models.CharField(default=order.models.get_next_shipment_number, unique=True, help_text='Shipment reference', max_length=100, verbose_name='Reference')), + ('reference', models.CharField(default='1', help_text='Shipment reference', max_length=100, verbose_name='Reference')), ('notes', markdownx.models.MarkdownxField(blank=True, help_text='Shipment notes', verbose_name='Notes')), ('checked_by', models.ForeignKey(blank=True, help_text='User who checked this shipment', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Checked By')), ('order', models.ForeignKey(help_text='Sales Order', on_delete=django.db.models.deletion.CASCADE, related_name='shipments', to='order.salesorder', verbose_name='Order')), diff --git a/InvenTree/order/migrations/0060_auto_20211129_1339.py b/InvenTree/order/migrations/0060_auto_20211129_1339.py new file mode 100644 index 0000000000..2166ec45ad --- /dev/null +++ b/InvenTree/order/migrations/0060_auto_20211129_1339.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.5 on 2021-11-29 13:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0059_salesordershipment_tracking_number'), + ] + + operations = [ + migrations.AlterField( + model_name='salesordershipment', + name='reference', + field=models.CharField(default='1', help_text='Shipment number', max_length=100, verbose_name='Shipment'), + ), + migrations.AlterUniqueTogether( + name='salesordershipment', + unique_together={('order', 'reference')}, + ), + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 10b0a45f62..a7cadbf7a8 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -900,35 +900,6 @@ class SalesOrderLineItem(OrderLineItem): return self.shipped >= self.quantity -def get_next_shipment_number(): - """ - Returns the next available SalesOrderShipment reference number" - """ - - if SalesOrderShipment.objects.count() == 0: - return "001" - - shipment = SalesOrderShipment.objects.exclude(reference=None).last() - - attempts = set([shipment.reference]) - - reference = shipment.reference - - while 1: - reference = increment(reference) - - if reference in attempts: - # Escape infinite recursion - return reference - - if SalesOrderShipment.objects.filter(reference=reference).exists(): - attempts.add(reference) - else: - break - - return reference - - class SalesOrderShipment(models.Model): """ The SalesOrderShipment model represents a physical shipment made against a SalesOrder. @@ -945,6 +916,12 @@ class SalesOrderShipment(models.Model): notes: Custom notes field for this shipment """ + class Meta: + # Shipment reference must be unique for a given sales order + unique_together = [ + 'order', 'reference', + ] + @staticmethod def get_api_url(): return reverse('api-so-shipment-list') @@ -976,10 +953,9 @@ class SalesOrderShipment(models.Model): reference = models.CharField( max_length=100, blank=False, - unique=True, - verbose_name=('Reference'), - help_text=_('Shipment reference'), - default=get_next_shipment_number, + verbose_name=('Shipment'), + help_text=_('Shipment number'), + default='1', ) notes = MarkdownxField( diff --git a/InvenTree/order/templates/order/sales_order_detail.html b/InvenTree/order/templates/order/sales_order_detail.html index bb90f4386f..7977274155 100644 --- a/InvenTree/order/templates/order/sales_order_detail.html +++ b/InvenTree/order/templates/order/sales_order_detail.html @@ -155,6 +155,7 @@ $('#new-shipment').click(function() { createSalesOrderShipment({ order: {{ order.pk }}, + reference: '{{ order.reference }}', onSuccess: function(data) { $('#pending-shipments-table').bootstrapTable('refresh'); } diff --git a/InvenTree/order/test_sales_order.py b/InvenTree/order/test_sales_order.py index 8337ff4b57..76f9275dac 100644 --- a/InvenTree/order/test_sales_order.py +++ b/InvenTree/order/test_sales_order.py @@ -7,11 +7,15 @@ from django.core.exceptions import ValidationError from datetime import datetime, timedelta 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 +from order.models import SalesOrder, SalesOrderLineItem, SalesOrderShipment, SalesOrderAllocation + +from part.models import Part + +from stock.models import StockItem + class SalesOrderTest(TestCase): """ diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 50d6b4444e..6327ef81e9 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -75,16 +75,56 @@ function completeShipment(shipment_id) { // Open a dialog to create a new sales order shipment function createSalesOrderShipment(options={}) { - constructForm('{% url "api-so-shipment-list" %}', { - method: 'POST', - fields: salesOrderShipmentFields(options), - title: '{% trans "Create New Shipment" %}', - onSuccess: function(data) { - if (options.onSuccess) { - options.onSuccess(data); + + // Work out the next shipment number for the given order + inventreeGet( + '{% url "api-so-shipment-list" %}', + { + order: options.order, + }, + { + success: function(results) { + // "predict" the next reference number + var ref = results.length + 1; + + var found = false; + + while (!found) { + + var no_match = true; + + for (var ii = 0; ii < results.length; ii++) { + if (ref.toString() == results[ii].reference.toString()) { + no_match = false; + break; + } + } + + if (no_match) { + break; + } else { + ref++; + } + } + + var fields = salesOrderShipmentFields(options); + + fields.reference.value = ref; + fields.reference.prefix = global_settings.SALESORDER_REFERENCE_PREFIX + options.reference; + + constructForm('{% url "api-so-shipment-list" %}', { + method: 'POST', + fields: fields, + title: '{% trans "Create New Shipment" %}', + onSuccess: function(data) { + if (options.onSuccess) { + options.onSuccess(data); + } + } + }); } } - }); + ); } @@ -1271,15 +1311,20 @@ function loadSalesOrderShipmentTable(table, options={}) { title: '{% trans "Shipment" %}', switchable: false, }, - { - field: 'status', - title: '{% trans "Status" %}', - }, { field: 'shipment_date', title: '{% trans "Shipment Date" %}', - visible: options.shipped, - switchable: false, + formatter: function(value, row) { + if (value) { + return value; + } else { + return '{% trans "Not shipped" %}'; + } + } + }, + { + field: 'tracking_number', + title: '{% trans "Tracking" %}', }, { field: 'notes', @@ -1711,7 +1756,7 @@ function loadSalesOrderAllocationTable(table, options={}) { field: 'quantity', title: '{% trans "Quantity" %}', sortable: true, - } + }, ] }); } From 6963503d0237dc959aeee822f2504c4d3b628e95 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 30 Nov 2021 00:44:42 +1100 Subject: [PATCH 30/54] Delete old SalesOrderShip view --- InvenTree/order/forms.py | 11 ----- .../templates/order/sales_order_ship.html | 30 ------------- InvenTree/order/urls.py | 1 - InvenTree/order/views.py | 45 ++----------------- 4 files changed, 3 insertions(+), 84 deletions(-) delete mode 100644 InvenTree/order/templates/order/sales_order_ship.html diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index 6f0bc43c46..a5a3ddc0f1 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -65,17 +65,6 @@ class CancelSalesOrderForm(HelperForm): ] -class ShipSalesOrderForm(HelperForm): - - confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Ship order')) - - class Meta: - model = SalesOrder - fields = [ - 'confirm', - ] - - class AllocateSerialsToSalesOrderForm(forms.Form): """ Form for assigning stock to a sales order, diff --git a/InvenTree/order/templates/order/sales_order_ship.html b/InvenTree/order/templates/order/sales_order_ship.html deleted file mode 100644 index 763d3fca57..0000000000 --- a/InvenTree/order/templates/order/sales_order_ship.html +++ /dev/null @@ -1,30 +0,0 @@ -{% extends "modal_form.html" %} - -{% load i18n %} - -{% block pre_form_content %} - -{% if not order.is_fully_allocated %} -
    -

    {% trans "Warning" %}

    - {% trans "This order has not been fully allocated. If the order is marked as shipped, it can no longer be adjusted." %} -
    - {% trans "Ensure that the order allocation is correct before shipping the order." %} -
    -{% endif %} - -{% if order.is_over_allocated %} -
    - {% trans "Some line items in this order have been over-allocated" %} -
    - {% trans "Ensure that this is correct before shipping the order." %} -
    -{% endif %} - -
    - {% trans "Sales Order" %} {{ order.reference }} - {{ order.customer.name }} -
    - {% trans "Shipping this order means that the order will no longer be editable." %} -
    - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/urls.py b/InvenTree/order/urls.py index afc689cc23..8cf472e0ae 100644 --- a/InvenTree/order/urls.py +++ b/InvenTree/order/urls.py @@ -35,7 +35,6 @@ purchase_order_urls = [ sales_order_detail_urls = [ url(r'^cancel/', views.SalesOrderCancel.as_view(), name='so-cancel'), - url(r'^ship/', views.SalesOrderShip.as_view(), name='so-ship'), url(r'^export/', views.SalesOrderExport.as_view(), name='so-export'), url(r'^.*$', views.SalesOrderDetail.as_view(), name='so-detail'), diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index 8aea60f694..2c3e125dc0 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -213,48 +213,6 @@ class PurchaseOrderComplete(AjaxUpdateView): } -class SalesOrderShip(AjaxUpdateView): - """ View for 'shipping' a SalesOrder """ - form_class = order_forms.ShipSalesOrderForm - model = SalesOrder - context_object_name = 'order' - ajax_template_name = 'order/sales_order_ship.html' - ajax_form_title = _('Ship Order') - - def post(self, request, *args, **kwargs): - - self.request = request - - order = self.get_object() - self.object = order - - form = self.get_form() - - confirm = str2bool(request.POST.get('confirm', False)) - - valid = False - - if not confirm: - form.add_error('confirm', _('Confirm order shipment')) - else: - valid = True - - if valid: - if not order.ship_order(request.user): - form.add_error(None, _('Could not ship order')) - valid = False - - data = { - 'form_valid': valid, - } - - context = self.get_context_data() - - context['order'] = order - - return self.renderJsonResponse(request, form, data, context) - - class PurchaseOrderUpload(FileManagementFormView): ''' PurchaseOrder: Upload file, match to fields and parts (using multi-Step form) ''' @@ -834,6 +792,9 @@ class OrderParts(AjaxView): order.add_line_item(supplier_part, quantity, purchase_price=purchase_price) + +#### TODO: This class MUST be converted to the API forms! +#### TODO: We MUST select the shipment class SalesOrderAssignSerials(AjaxView, FormMixin): """ View for assigning stock items to a sales order, From bce69b7733fbc181fbfad474608ad5436f716172 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 30 Nov 2021 00:45:40 +1100 Subject: [PATCH 31/54] Removes ship_order function --- InvenTree/order/models.py | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index a7cadbf7a8..73f20e77ff 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -603,32 +603,6 @@ class SalesOrder(Order): return all([line.is_completed() for line in self.lines.all()]) - @transaction.atomic - def ship_order(self, user): - """ Mark this order as 'shipped' """ - - # The order can only be 'shipped' if the current status is PENDING - if not self.status == SalesOrderStatus.PENDING: - raise ValidationError({'status': _("SalesOrder cannot be shipped as it is not currently pending")}) - - # Complete the allocation for each allocated StockItem - for line in self.lines.all(): - for allocation in line.allocations.all(): - allocation.complete_allocation(user) - - # Remove the allocation from the database once it has been 'fulfilled' - if allocation.item.sales_order == self: - allocation.delete() - else: - raise ValidationError("Could not complete order - allocation item not fulfilled") - - # Ensure the order status is marked as "Shipped" - self.status = SalesOrderStatus.SHIPPED - self.shipment_date = datetime.now().date() - self.shipped_by = user - self.save() - - return True def can_cancel(self): """ From 9a9f3118edfcdce5d0190ba7df8e3154c09065ff Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 2 Dec 2021 20:53:07 +1100 Subject: [PATCH 32/54] Re-add package.json --- .gitignore | 1 - package.json | 27 +++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 package.json diff --git a/.gitignore b/.gitignore index 420524d06f..74669a5756 100644 --- a/.gitignore +++ b/.gitignore @@ -78,5 +78,4 @@ locale_stats.json # node.js package-lock.json -package.json node_modules/ \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000000..1cb39f938e --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "inventree", + "version": "1.0.0", + "description": "\"InvenTree\"", + "main": "index.js", + "dependencies": { + "eslint": "^7.32.0", + "eslint-config-google": "^0.14.0", + "html-lint": "^2.4.2", + "htmllint": "^0.8.0", + "markuplint": "^1.11.3" + }, + "devDependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/SchrodingersGat/InvenTree.git" + }, + "author": "", + "license": "ISC", + "bugs": { + "url": "https://github.com/SchrodingersGat/InvenTree/issues" + }, + "homepage": "https://github.com/SchrodingersGat/InvenTree#readme" +} From 5eccc828fa5b6dc9079fdffc11101c320bbaf779 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 2 Dec 2021 20:59:26 +1100 Subject: [PATCH 33/54] Revert "Re-add package.json" This reverts commit 9a9f3118edfcdce5d0190ba7df8e3154c09065ff. --- .gitignore | 1 + package.json | 27 --------------------------- 2 files changed, 1 insertion(+), 27 deletions(-) delete mode 100644 package.json diff --git a/.gitignore b/.gitignore index 74669a5756..420524d06f 100644 --- a/.gitignore +++ b/.gitignore @@ -78,4 +78,5 @@ locale_stats.json # node.js package-lock.json +package.json node_modules/ \ No newline at end of file diff --git a/package.json b/package.json deleted file mode 100644 index 1cb39f938e..0000000000 --- a/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "inventree", - "version": "1.0.0", - "description": "\"InvenTree\"", - "main": "index.js", - "dependencies": { - "eslint": "^7.32.0", - "eslint-config-google": "^0.14.0", - "html-lint": "^2.4.2", - "htmllint": "^0.8.0", - "markuplint": "^1.11.3" - }, - "devDependencies": {}, - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/SchrodingersGat/InvenTree.git" - }, - "author": "", - "license": "ISC", - "bugs": { - "url": "https://github.com/SchrodingersGat/InvenTree/issues" - }, - "homepage": "https://github.com/SchrodingersGat/InvenTree#readme" -} From 123aab89bcb5d0c6ca9f074f7af2161991abdd6b Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 2 Dec 2021 21:39:49 +1100 Subject: [PATCH 34/54] Adds an "available" filter for stock item API --- InvenTree/stock/api.py | 20 ++++++++++++++++++- .../templates/js/translated/table_filters.js | 5 +++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 8385041209..760a18c72c 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -10,7 +10,7 @@ from datetime import datetime, timedelta from django.core.exceptions import ValidationError as DjangoValidationError from django.conf.urls import url, include from django.http import JsonResponse -from django.db.models import Q +from django.db.models import Q, F from django.db import transaction from django.utils.translation import ugettext_lazy as _ @@ -304,6 +304,24 @@ class StockFilter(rest_filters.FilterSet): return queryset + available = rest_filters.BooleanFilter(label='Available', method='filter_available') + + def filter_available(self, queryset, name, value): + """ + Filter by whether the StockItem is "available" or not. + + Here, "available" means that the allocated quantity is less than the total quantity + """ + + if str2bool(value): + # The 'quantity' field is greater than the calculated 'allocated' field + queryset = queryset.filter(Q(quantity__gt=F('allocated'))) + else: + # The 'quantity' field is less than (or equal to) the calculated 'allocated' field + queryset = queryset.filter(Q(quantity__lte=F('allocated'))) + + return queryset + batch = rest_filters.CharFilter(label="Batch code filter (case insensitive)", lookup_expr='iexact') batch_regex = rest_filters.CharFilter(label="Batch code filter (regex)", field_name='batch', lookup_expr='iregex') diff --git a/InvenTree/templates/js/translated/table_filters.js b/InvenTree/templates/js/translated/table_filters.js index 409192f74d..e7f5dc2a4d 100644 --- a/InvenTree/templates/js/translated/table_filters.js +++ b/InvenTree/templates/js/translated/table_filters.js @@ -173,6 +173,11 @@ function getAvailableTableFilters(tableKey) { title: '{% trans "Is allocated" %}', description: '{% trans "Item has been allocated" %}', }, + available: { + type: 'bool', + title: '{% trans "Available" %}', + description: '{% trans "Stock is available for use" %}', + }, cascade: { type: 'bool', title: '{% trans "Include sublocations" %}', From 586d38fb61b9f3e89d3db93a5c7c9cf9d31e4bc8 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 2 Dec 2021 21:40:21 +1100 Subject: [PATCH 35/54] Add item count to shipment table --- InvenTree/templates/js/translated/order.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index c267bf6877..6dae5cbe79 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -1316,9 +1316,23 @@ function loadSalesOrderShipmentTable(table, options={}) { title: '{% trans "Shipment" %}', switchable: false, }, + { + field: 'allocations', + title: '{% trans "Items" %}', + switchable: false, + sortable: true, + formatter: function(value, row) { + if (row && row.allocations) { + return row.allocations.length; + } else { + return '-'; + } + } + }, { field: 'shipment_date', title: '{% trans "Shipment Date" %}', + sortable: true, formatter: function(value, row) { if (value) { return value; From d5ace1a8da6c6e107261caf27a8539d22acddc76 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 2 Dec 2021 21:46:05 +1100 Subject: [PATCH 36/54] Differentiate between "fully allocated" and "partially allocated" in stock item table --- InvenTree/order/serializers.py | 1 + InvenTree/stock/templates/stock/item_base.html | 4 ++-- InvenTree/templates/js/translated/stock.js | 9 ++++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index cffdbaec32..0dddece000 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -526,6 +526,7 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer): 'order_detail', 'part', 'part_detail', + 'shipment', ] diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 0d5ab272d6..949c172e9e 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -252,7 +252,7 @@
    {% object_link 'so-detail' allocation.line.order.id allocation.line.order as link %} {% decimal allocation.quantity as qty %} - {% blocktrans %}This stock item is allocated to Sales Order {{ link }} (Quantity: {{ qty }}){% endblocktrans %} + {% trans "This stock item is allocated to Sales Order" %} {{ link }} {% if qty < item.quantity %}({% trans "Quantity" %}: {{ qty }}){% endif %}
    {% endfor %} @@ -260,7 +260,7 @@
    {% object_link 'build-detail' allocation.build.id allocation.build %} {% decimal allocation.quantity as qty %} - {% blocktrans %}This stock item is allocated to Build {{ link }} (Quantity: {{ qty }}){% endblocktrans %} + {% trans "This stock item is allocated to Build Order" %} {{ link }} {% if qty < item.quantity %}({% trans "Quantity" %}: {{ qty }}){% endif %}
    {% endfor %} diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 79fe19b120..0314fff107 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -1278,7 +1278,14 @@ function loadStockTable(table, options) { } if (row.allocated) { - html += makeIconBadge('fa-bookmark', '{% trans "Stock item has been allocated" %}'); + + if (row.serial != null && row.quantity == 1) { + html += makeIconBadge('fa-bookmark icon-yellow', '{% trans "Serialized stock item has been allocated" %}'); + } else if (row.allocated >= row.quantity) { + html += makeIconBadge('fa-bookmark icon-red', '{% trans "Stock item has been fully allocated" %}'); + } else { + html += makeIconBadge('fa-bookmark', '{% trans "Stock item has been partially allocated" %}'); + } } if (row.belongs_to) { From e74e7138a92c9b5780dbeb7d3cba05b5c4841db1 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 2 Dec 2021 21:59:59 +1100 Subject: [PATCH 37/54] More stuff: - Pass tracking number through when completing a shipment - Reload tables automatically when certain actions are performed - Limit stock items to only those with available stock --- InvenTree/order/models.py | 9 ++++++++- InvenTree/order/serializers.py | 5 ++++- InvenTree/templates/js/translated/order.js | 19 ++++++++++++++++--- InvenTree/templates/js/translated/stock.js | 2 +- 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 73f20e77ff..0e5b10cf98 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -956,7 +956,7 @@ class SalesOrderShipment(models.Model): raise ValidationError(_("Shipment has no allocated stock items")) @transaction.atomic - def complete_shipment(self, user): + def complete_shipment(self, user, **kwargs): """ Complete this particular shipment: @@ -979,6 +979,13 @@ class SalesOrderShipment(models.Model): # Update the "shipment" date self.shipment_date = datetime.now() self.shipped_by = user + + # Was a tracking number provided? + tracking_number = kwargs.get('tracking_number', None) + + if tracking_number is not None: + self.tracking_number = tracking_number + self.save() # Finally, check if the order is fully shipped diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 0dddece000..f86aae8163 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -654,7 +654,10 @@ class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer): request = self.context['request'] user = request.user - shipment.complete_shipment(user) + # Extract provided tracking number (optional) + tracking_number = data.get('tracking_number', None) + + shipment.complete_shipment(user, tracking_number=tracking_number) class SOShipmentAllocationItemSerializer(serializers.Serializer): diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 6dae5cbe79..e44eed9589 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -67,7 +67,10 @@ function completeShipment(shipment_id) { confirm: true, confirmMessage: '{% trans "Confirm Shipment" %}', onSuccess: function(data) { - // TODO + // Reload tables + $('#so-lines-table').bootstrapTable('refresh'); + $('#pending-shipments-table').bootstrapTable('refresh'); + $('#completed-shipments-table').bootstrapTable('refresh'); } }); } @@ -1249,7 +1252,9 @@ function loadSalesOrderShipmentTable(table, options={}) { html += makeIconButton('fa-edit icon-blue', 'button-shipment-edit', pk, '{% trans "Edit shipment" %}'); - html += makeIconButton('fa-truck icon-green', 'button-shipment-ship', pk, '{% trans "Complete shipment" %}'); + if (!options.shipped) { + html += makeIconButton('fa-truck icon-green', 'button-shipment-ship', pk, '{% trans "Complete shipment" %}'); + } html += ``; @@ -1300,7 +1305,10 @@ function loadSalesOrderShipmentTable(table, options={}) { onPostBody: function() { setupShipmentCallbacks(); - $(table).bootstrapTable('expandAllRows'); + // Auto-expand rows on the "pending" table + if (!options.shipped) { + $(table).bootstrapTable('expandAllRows'); + } }, formatNoMatches: function() { return '{% trans "No matching shipments found" %}'; @@ -1557,6 +1565,7 @@ function allocateStockToSalesOrder(order_id, line_items, options={}) { in_stock: true, part_detail: true, location_detail: true, + available: true, }, model: 'stockitem', required: true, @@ -2296,7 +2305,11 @@ function loadSalesOrderLineItemTable(table, options={}) { ], { success: function() { + // Reload this table $(table).bootstrapTable('refresh'); + + // Reload the pending shipment table + $('#pending-shipments-table').bootstrapTable('refresh'); } } ); diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 0314fff107..d6de4fdd45 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -1282,7 +1282,7 @@ function loadStockTable(table, options) { if (row.serial != null && row.quantity == 1) { html += makeIconBadge('fa-bookmark icon-yellow', '{% trans "Serialized stock item has been allocated" %}'); } else if (row.allocated >= row.quantity) { - html += makeIconBadge('fa-bookmark icon-red', '{% trans "Stock item has been fully allocated" %}'); + html += makeIconBadge('fa-bookmark icon-yellow', '{% trans "Stock item has been fully allocated" %}'); } else { html += makeIconBadge('fa-bookmark', '{% trans "Stock item has been partially allocated" %}'); } From e1668c86620b88f6329b10dcd60374edde46d249 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 2 Dec 2021 23:52:53 +1100 Subject: [PATCH 38/54] More stuffs: - Allow filtering of salesorderlineitem by "completed" status - Allow deletion of (empty) shipment - Show which items are going to be shipped --- InvenTree/order/api.py | 59 ++++++- InvenTree/order/models.py | 48 +++++- InvenTree/order/serializers.py | 17 ++ .../order/templates/order/order_base.html | 12 ++ .../order/purchase_order_detail.html | 6 +- .../templates/order/sales_order_base.html | 29 ++-- InvenTree/templates/js/translated/build.js | 1 + InvenTree/templates/js/translated/order.js | 157 +++++++++++++----- .../templates/js/translated/table_filters.js | 10 ++ 9 files changed, 283 insertions(+), 56 deletions(-) diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index f4e10bff08..4973c15785 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -551,6 +551,39 @@ class SODetail(generics.RetrieveUpdateDestroyAPIView): return queryset +class SOLineItemFilter(rest_filters.FilterSet): + """ + Custom filters for SOLineItemList endpoint + """ + + class Meta: + model = models.SalesOrderLineItem + fields = [ + 'order', + 'part', + ] + + completed = rest_filters.BooleanFilter(label='completed', method='filter_completed') + + def filter_completed(self, queryset, name, value): + """ + Filter by lines which are "completed" + + A line is completed when shipped >= quantity + """ + + value = str2bool(value) + + q = Q(shipped__gte=F('quantity')) + + if value: + queryset = queryset.filter(q) + else: + queryset = queryset.exclude(q) + + return queryset + + class SOLineItemList(generics.ListCreateAPIView): """ API endpoint for accessing a list of SalesOrderLineItem objects. @@ -558,6 +591,7 @@ class SOLineItemList(generics.ListCreateAPIView): queryset = models.SalesOrderLineItem.objects.all() serializer_class = serializers.SOLineItemSerializer + filterset_class = SOLineItemFilter def get_serializer(self, *args, **kwargs): @@ -620,6 +654,28 @@ class SOLineItemDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = serializers.SOLineItemSerializer +class SalesOrderComplete(generics.CreateAPIView): + """ + API endpoint for manually marking a SalesOrder as "complete". + """ + + queryset = models.SalesOrder.objects.all() + serializer_class = serializers.SalesOrderShipmentCompleteSerializer + + def get_serializer_context(self): + + ctx = super().get_serializer_context() + + ctx['request'] = self.request + + try: + ctx['order'] = models.SalesOrder.objects.get(pk=self.kwargs.get('pk', None)) + except: + pass + + return ctx + + class SalesOrderAllocate(generics.CreateAPIView): """ API endpoint to allocate stock items against a SalesOrder @@ -758,7 +814,7 @@ class SOShipmentList(generics.ListCreateAPIView): ] -class SOShipmentDetail(generics.RetrieveUpdateAPIView): +class SOShipmentDetail(generics.RetrieveUpdateDestroyAPIView): """ API detail endpooint for SalesOrderShipment model """ @@ -863,6 +919,7 @@ order_api_urls = [ # Sales order detail view url(r'^(?P\d+)/', include([ + url(r'^complete/', SalesOrderComplete.as_view(), name='api-so-complete'), url(r'^allocate/', SalesOrderAllocate.as_view(), name='api-so-allocate'), url(r'^.*$', SODetail.as_view(), name='api-so-detail'), ])), diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 0e5b10cf98..8c21c311f1 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -363,11 +363,30 @@ class PurchaseOrder(Order): return self.lines.filter(quantity__gt=F('received')) + def completed_line_items(self): + """ + Return a list of completed line items against this order + """ + return self.lines.filter(quantity__lte=F('received')) + + @property + def line_count(self): + return self.lines.count() + + @property + def completed_line_count(self): + + return self.completed_line_items().count() + + @property + def pending_line_count(self): + return self.pending_line_items().count() + @property def is_complete(self): """ Return True if all line items have been received """ - return self.pending_line_items().count() == 0 + return self.lines.count() > 0 and self.pending_line_items().count() == 0 @transaction.atomic def receive_line_item(self, line, location, quantity, user, status=StockStatus.OK, purchase_price=None, **kwargs): @@ -601,8 +620,7 @@ class SalesOrder(Order): and mark it as "shipped" if so. """ - return all([line.is_completed() for line in self.lines.all()]) - + return self.lines.count() > 0 and all([line.is_completed() for line in self.lines.all()]) def can_cancel(self): """ @@ -635,6 +653,30 @@ class SalesOrder(Order): return True + @property + def line_count(self): + return self.lines.count() + + def completed_line_items(self): + """ + Return a queryset of the completed line items for this order + """ + return self.lines.filter(shipped__gte=F('quantity')) + + def pending_line_items(self): + """ + Return a queryset of the pending line items for this order + """ + return self.lines.filter(shipped__lt=F('quantity')) + + @property + def completed_line_count(self): + return self.completed_line_items().count() + + @property + def pending_line_count(self): + return self.pending_line_items().count() + class PurchaseOrderAttachment(InvenTreeAttachment): """ diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index f86aae8163..3b33d4a16f 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -489,6 +489,8 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer): item_detail = stock.serializers.StockItemSerializer(source='item', many=False, read_only=True) location_detail = stock.serializers.LocationSerializer(source='item.location', many=False, read_only=True) + shipment_date = serializers.DateField(source='shipment.shipment_date', read_only=True) + def __init__(self, *args, **kwargs): order_detail = kwargs.pop('order_detail', False) @@ -527,6 +529,7 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer): 'part', 'part_detail', 'shipment', + 'shipment_date', ] @@ -734,6 +737,20 @@ class SOShipmentAllocationItemSerializer(serializers.Serializer): return data +class SalesOrderCompleteSerializer(serializers.Serializer): + """ + DRF serializer for manually marking a sales order as complete + """ + + def save(self): + + request = self.context['request'] + order = self.context['order'] + data = self.validated_data + + + + class SOShipmentAllocationSerializer(serializers.Serializer): """ DRF serializer for allocation of stock items against a sales order / shipment diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html index da78b83561..195f2273a3 100644 --- a/InvenTree/order/templates/order/order_base.html +++ b/InvenTree/order/templates/order/order_base.html @@ -119,6 +119,18 @@ src="{% static 'img/blank_image.png' %}" {{ order.supplier_reference }}{% include "clip.html"%} {% endif %} + + + {% trans "Completed Line Items" %} + + {{ order.completed_line_count }} / {{ order.line_count }} + {% if order.is_complete %} + {% trans "Complete" %} + {% else %} + {% trans "Incomplete" %} + {% endif %} + + {% if order.link %} diff --git a/InvenTree/order/templates/order/purchase_order_detail.html b/InvenTree/order/templates/order/purchase_order_detail.html index 90a105caf1..d0215777bb 100644 --- a/InvenTree/order/templates/order/purchase_order_detail.html +++ b/InvenTree/order/templates/order/purchase_order_detail.html @@ -37,7 +37,7 @@
    - {% include "filter_list.html" with id="order-lines" %} + {% include "filter_list.html" with id="purchase-order-lines" %}
    @@ -190,6 +190,10 @@ $('#new-po-line').click(function() { $('#receive-selected-items').click(function() { var items = $("#po-line-table").bootstrapTable('getSelections'); + if (items.length == 0) { + items = $("#po-line-table").bootstrapTable('getData'); + } + receivePurchaseOrderItems( {{ order.id }}, items, diff --git a/InvenTree/order/templates/order/sales_order_base.html b/InvenTree/order/templates/order/sales_order_base.html index 2a5a79a161..80ff5bbd97 100644 --- a/InvenTree/order/templates/order/sales_order_base.html +++ b/InvenTree/order/templates/order/sales_order_base.html @@ -63,8 +63,8 @@ src="{% static 'img/blank_image.png' %}" {% if order.status == SalesOrderStatus.PENDING %} - {% endif %} {% endif %} @@ -123,6 +123,18 @@ src="{% static 'img/blank_image.png' %}" {% endif %} + + + + + {% if order.link %} @@ -149,13 +161,6 @@ src="{% static 'img/blank_image.png' %}" {% endif %} - {% if order.status == PurchaseOrderStatus.COMPLETE %} - - - - - - {% endif %} {% if order.responsible %} @@ -203,10 +208,8 @@ $("#cancel-order").click(function() { }); }); -$("#ship-order").click(function() { - launchModalForm("{% url 'so-ship' order.id %}", { - reload: true, - }); +$("#complete-order").click(function() { + completeSalesOrder({{ order.pk }}); }); {% if report_enabled %} diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index c19a5b69d2..02b2ff5321 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -1413,6 +1413,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { filters: { bom_item: bom_item.pk, in_stock: true, + available: true, part_detail: true, location_detail: true, }, diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index e44eed9589..64c4f97645 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -41,6 +41,9 @@ function salesOrderShipmentFields(options={}) { var fields = { order: {}, reference: {}, + tracking_number: { + icon: 'fa-hashtag', + }, }; // If order is specified, hide the order field @@ -58,19 +61,75 @@ function salesOrderShipmentFields(options={}) { */ function completeShipment(shipment_id) { - constructForm(`/api/order/so/shipment/${shipment_id}/ship/`, { - method: 'POST', - title: '{% trans "Complete Shipment" %}', - fields: { - tracking_number: {}, - }, - confirm: true, - confirmMessage: '{% trans "Confirm Shipment" %}', - onSuccess: function(data) { - // Reload tables - $('#so-lines-table').bootstrapTable('refresh'); - $('#pending-shipments-table').bootstrapTable('refresh'); - $('#completed-shipments-table').bootstrapTable('refresh'); + // Request the list of stock items which will be shipped + inventreeGet(`/api/order/so/shipment/${shipment_id}/`, {}, { + success: function(shipment) { + var allocations = shipment.allocations; + + var html = ''; + + if (!allocations || allocations.length == 0) { + html = ` +
    + {% trans "No stock items have been allocated to this shipment" %} +
    + `; + } else { + html = ` + {% trans "The following stock items will be shipped" %} +
    {{ order.customer_reference }}{% include "clip.html"%}
    {% trans "Completed Line Items" %} + {{ order.completed_line_count }} / {{ order.line_count }} + {% if order.is_completed %} + {% trans "Complete" %} + {% else %} + {% trans "Incomplete" %} + {% endif %} +
    {{ order.shipment_date }}{{ order.shipped_by }}
    {% trans "Received" %}{{ order.complete_date }}{{ order.received_by }}
    + + + + + + + + `; + + allocations.forEach(function(allocation) { + + var part = allocation.part_detail; + var thumb = thumbnailImage(part.thumbnail || part.image); + + var stock = ''; + + if (allocation.serial) { + stock = `{% trans "Serial Number" %}: ${allocation.serial}`; + } else { + stock = `{% trans "Quantity" %}: ${allocation.quantity}`; + } + + html += ` + + + + + `; + }); + + html += ` + +
    {% trans "Part" %}{% trans "Stock Item" %}
    ${thumb} ${part.full_name}${stock}
    + `; + } + + constructForm(`/api/order/so/shipment/${shipment_id}/ship/`, { + method: 'POST', + title: '{% trans "Complete Shipment" %}', + fields: { + tracking_number: {}, + }, + preFormContent: html, + confirm: true, + confirmMessage: '{% trans "Confirm Shipment" %}', + onSuccess: function(data) { + // Reload tables + $('#so-lines-table').bootstrapTable('refresh'); + $('#pending-shipments-table').bootstrapTable('refresh'); + $('#completed-shipments-table').bootstrapTable('refresh'); + } + }); } }); } @@ -393,7 +452,9 @@ function newPurchaseOrderFromOrderWizard(e) { */ function receivePurchaseOrderItems(order_id, line_items, options={}) { + // Zero items selected? if (line_items.length == 0) { + showAlertDialog( '{% trans "Select Line Items" %}', '{% trans "At least one line item must be selected" %}', @@ -1256,6 +1317,10 @@ function loadSalesOrderShipmentTable(table, options={}) { html += makeIconButton('fa-truck icon-green', 'button-shipment-ship', pk, '{% trans "Complete shipment" %}'); } + var enable_delete = row.allocations && row.allocations.length == 0; + + html += makeIconButton('fa-trash-alt icon-red', 'button-shipment-delete', pk, '{% trans "Delete shipment" %}', {disabled: !enable_delete}); + html += `
    `; return html; @@ -1268,10 +1333,12 @@ function loadSalesOrderShipmentTable(table, options={}) { $(table).find('.button-shipment-edit').click(function() { var pk = $(this).attr('pk'); + var fields = salesOrderShipmentFields(); + + delete fields.order; + constructForm(`/api/order/so/shipment/${pk}/`, { - fields: { - reference: {}, - }, + fields: fields, title: '{% trans "Edit Shipment" %}', onSuccess: function() { $(table).bootstrapTable('refresh'); @@ -1284,6 +1351,18 @@ function loadSalesOrderShipmentTable(table, options={}) { completeShipment(pk); }); + + $(table).find('.button-shipment-delete').click(function() { + var pk = $(this).attr('pk'); + + constructForm(`/api/order/so/shipment/${pk}/`, { + title: '{% trans "Delete Shipment" %}', + method: 'DELETE', + onSuccess: function() { + $(table).bootstrapTable('refresh'); + } + }); + }); } $(table).inventreeTable({ @@ -1510,14 +1589,6 @@ function allocateStockToSalesOrder(order_id, line_items, options={}) { }, value: options.shipment || null, auto_fill: true, - secondary: { - title: '{% trans "New Shipment" %}', - fields: function() { - return salesOrderShipmentFields({ - order: order_id - }); - } - } } }, preFormContent: html, @@ -1854,7 +1925,7 @@ function showAllocationSubTable(index, row, element, options) { table.bootstrapTable({ onPostBody: setupCallbacks, data: row.allocations, - showHeader: false, + showHeader: true, columns: [ { field: 'part_detail', @@ -1865,7 +1936,7 @@ function showAllocationSubTable(index, row, element, options) { }, { field: 'allocated', - title: '{% trans "Quantity" %}', + title: '{% trans "Stock Item" %}', formatter: function(value, row, index, field) { var text = ''; @@ -1883,8 +1954,8 @@ function showAllocationSubTable(index, row, element, options) { title: '{% trans "Location" %}', formatter: function(value, row, index, field) { - if (shipped) { - return `{% trans "Shipped to customer" %}`; + if (row.shipment_date) { + return `{% trans "Shipped to customer" %} - ${row.shipment_date}`; } else if (row.location) { // Location specified return renderLink( @@ -1896,21 +1967,17 @@ function showAllocationSubTable(index, row, element, options) { } }, }, - // TODO: ?? What is 'po' field all about? - /* - { - field: 'po' - }, - */ { field: 'buttons', - title: '{% trans "Actions" %}', + title: '{% trans "" %}', formatter: function(value, row, index, field) { var html = `
    `; var pk = row.pk; - if (!shipped) { + if (row.shipment_date) { + html += `{% trans "Shipped" %}`; + } else { html += makeIconButton('fa-edit icon-blue', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}'); html += makeIconButton('fa-trash-alt icon-red', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}'); } @@ -2017,7 +2084,7 @@ function loadSalesOrderLineItemTable(table, options={}) { var filter_target = options.filter_target || '#filter-list-sales-order-lines'; - setupFilterList('salesorderlineitems', $(table), filter_target); + setupFilterList('salesorderlineitem', $(table), filter_target); // Is the order pending? var pending = options.status == {{ SalesOrderStatus.PENDING }}; @@ -2228,7 +2295,21 @@ function loadSalesOrderLineItemTable(table, options={}) { } html += makeIconButton('fa-edit icon-blue', 'button-edit', pk, '{% trans "Edit line item" %}'); - html += makeIconButton('fa-trash-alt icon-red', 'button-delete', pk, '{% trans "Delete line item " %}'); + + var delete_disabled = false; + + var title = '{% trans "Delete line item" %}'; + + if (!!row.shipped) { + delete_disabled = true; + title = '{% trans "Cannot be deleted as items have been shipped" %}'; + } else if (!!row.allocated) { + delete_disabled = true; + title = '{% trans "Cannot be deleted as items have been allocated" %}'; + } + + // Prevent deletion of the line item if items have been allocated or shipped! + html += makeIconButton('fa-trash-alt icon-red', 'button-delete', pk, title, {disabled: delete_disabled}); html += `
    `; diff --git a/InvenTree/templates/js/translated/table_filters.js b/InvenTree/templates/js/translated/table_filters.js index e7f5dc2a4d..6920626284 100644 --- a/InvenTree/templates/js/translated/table_filters.js +++ b/InvenTree/templates/js/translated/table_filters.js @@ -310,6 +310,7 @@ function getAvailableTableFilters(tableKey) { }, }; } + // Filters for the PurchaseOrder table if (tableKey == 'purchaseorder') { @@ -346,6 +347,15 @@ function getAvailableTableFilters(tableKey) { }; } + if (tableKey == 'salesorderlineitem') { + return { + completed: { + type: 'bool', + title: '{% trans "Completed" %}', + }, + }; + } + if (tableKey == 'supplier-part') { return { active: { From ecf70b6d4dd142795b7effbbb03ab5244de3a44b Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 2 Dec 2021 23:58:02 +1100 Subject: [PATCH 39/54] Some PEP fixes --- InvenTree/order/models.py | 7 +++---- InvenTree/order/serializers.py | 8 +++++--- InvenTree/order/test_migrations.py | 2 +- InvenTree/order/test_sales_order.py | 2 +- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 8c21c311f1..b2cca685d9 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -812,7 +812,7 @@ class PurchaseOrderLineItem(OrderLineItem): def get_destination(self): """ Show where the line item is or should be placed - + NOTE: If a line item gets split when recieved, only an arbitrary stock items location will be reported as the location for the entire line. @@ -993,7 +993,7 @@ class SalesOrderShipment(models.Model): if self.shipment_date: # Shipment has already been sent! raise ValidationError(_("Shipment has already been sent")) - + if self.allocations.count() == 0: raise ValidationError(_("Shipment has no allocated stock items")) @@ -1014,11 +1014,10 @@ class SalesOrderShipment(models.Model): # Iterate through each stock item assigned to this shipment for allocation in allocations: - # Mark the allocation as "complete" allocation.complete_allocation(user) - # Update the "shipment" date + # Update the "shipment" date self.shipment_date = datetime.now() self.shipped_by = user diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 3b33d4a16f..b336042684 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -11,6 +11,7 @@ from django.core.exceptions import ValidationError as DjangoValidationError from django.db import models, transaction from django.db.models import Case, When, Value from django.db.models import BooleanField, ExpressionWrapper, F +from InvenTree.InvenTree.status_codes import SalesOrderStatus from rest_framework import serializers from rest_framework.serializers import ValidationError @@ -653,7 +654,7 @@ class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer): return data = self.validated_data - + request = self.context['request'] user = request.user @@ -748,7 +749,9 @@ class SalesOrderCompleteSerializer(serializers.Serializer): order = self.context['order'] data = self.validated_data - + # Mark this order as complete! + order.status = SalesOrderStatus.SHIPPED + order.save() class SOShipmentAllocationSerializer(serializers.Serializer): @@ -816,7 +819,6 @@ class SOShipmentAllocationSerializer(serializers.Serializer): with transaction.atomic(): for entry in items: - # Create a new SalesOrderAllocation order.models.SalesOrderAllocation.objects.create( line=entry.get('line_item'), diff --git a/InvenTree/order/test_migrations.py b/InvenTree/order/test_migrations.py index d62449613e..d8f3a655ca 100644 --- a/InvenTree/order/test_migrations.py +++ b/InvenTree/order/test_migrations.py @@ -111,7 +111,7 @@ class TestShipmentMigration(MigratorTestCase): # The "shipment" model does not exist yet with self.assertRaises(LookupError): self.old_state.apps.get_model('order', 'salesordershipment') - + def test_shipment_creation(self): """ Check that a SalesOrderShipment has been created diff --git a/InvenTree/order/test_sales_order.py b/InvenTree/order/test_sales_order.py index 76f9275dac..40fab98349 100644 --- a/InvenTree/order/test_sales_order.py +++ b/InvenTree/order/test_sales_order.py @@ -10,7 +10,7 @@ from company.models import Company from InvenTree import status_codes as status -from order.models import SalesOrder, SalesOrderLineItem, SalesOrderShipment, SalesOrderAllocation +from order.models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation from part.models import Part From 80b615bfb73d899af20d218d0ec715318fe8d1a9 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 3 Dec 2021 00:08:05 +1100 Subject: [PATCH 40/54] Import fix --- InvenTree/order/serializers.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 11508070d5..b0af634a00 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -11,7 +11,6 @@ from django.core.exceptions import ValidationError as DjangoValidationError from django.db import models, transaction from django.db.models import Case, When, Value from django.db.models import BooleanField, ExpressionWrapper, F -from InvenTree.InvenTree.status_codes import SalesOrderStatus from rest_framework import serializers from rest_framework.serializers import ValidationError @@ -27,7 +26,7 @@ from InvenTree.serializers import InvenTreeModelSerializer from InvenTree.serializers import InvenTreeDecimalField from InvenTree.serializers import InvenTreeMoneySerializer from InvenTree.serializers import ReferenceIndexingSerializerMixin -from InvenTree.status_codes import StockStatus +from InvenTree.status_codes import StockStatus, SalesOrderStatus import order.models From 732034d9e540afbc19976ea39281c64bcc03a8f5 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 3 Dec 2021 00:43:10 +1100 Subject: [PATCH 41/54] Merge conflicting migrations --- ...4_auto_20211201_2139_0060_auto_20211129_1339.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 InvenTree/order/migrations/0061_merge_0054_auto_20211201_2139_0060_auto_20211129_1339.py diff --git a/InvenTree/order/migrations/0061_merge_0054_auto_20211201_2139_0060_auto_20211129_1339.py b/InvenTree/order/migrations/0061_merge_0054_auto_20211201_2139_0060_auto_20211129_1339.py new file mode 100644 index 0000000000..e844b0fc23 --- /dev/null +++ b/InvenTree/order/migrations/0061_merge_0054_auto_20211201_2139_0060_auto_20211129_1339.py @@ -0,0 +1,14 @@ +# Generated by Django 3.2.5 on 2021-12-02 13:31 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0054_auto_20211201_2139'), + ('order', '0060_auto_20211129_1339'), + ] + + operations = [ + ] From 3c3dd9368db3c7938373be941ee7b107d2c081a5 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 3 Dec 2021 00:45:44 +1100 Subject: [PATCH 42/54] Do not auto-complete salesorder when shipment is done - User might want to add more line items? --- InvenTree/order/models.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index b2cca685d9..0c5fe5e3d4 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -1029,11 +1029,6 @@ class SalesOrderShipment(models.Model): self.save() - # Finally, check if the order is fully shipped - if self.order.is_completed(): - self.order.status = SalesOrderStatus.SHIPPED - self.order.save() - class SalesOrderAllocation(models.Model): """ From 6b29e60494e01a80a41a23ec62dc0118bbe31c74 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 3 Dec 2021 11:12:49 +1100 Subject: [PATCH 43/54] Fixes for migration tests --- InvenTree/order/test_migrations.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/InvenTree/order/test_migrations.py b/InvenTree/order/test_migrations.py index d8f3a655ca..97ced6dbbf 100644 --- a/InvenTree/order/test_migrations.py +++ b/InvenTree/order/test_migrations.py @@ -8,13 +8,13 @@ from InvenTree import helpers from InvenTree.status_codes import SalesOrderStatus -class TestForwardMigrations(MigratorTestCase): +class TestRefIntMigrations(MigratorTestCase): """ Test entire schema migration """ - migrate_from = ('order', helpers.getOldestMigrationFile('order')) - migrate_to = ('order', helpers.getNewestMigrationFile('order')) + migrate_from = ('order', '0040_salesorder_target_date') + migrate_to = ('order', '0061_merge_0054_auto_20211201_2139_0060_auto_20211129_1339') def prepare(self): """ From 88fce1e813efdfb54d74a8209d1c1d1d2c470026 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 3 Dec 2021 18:42:36 +1100 Subject: [PATCH 44/54] Unit test fixes --- InvenTree/order/models.py | 28 +++++++++++++++++++++- InvenTree/order/serializers.py | 10 ++++---- InvenTree/order/test_migrations.py | 1 - InvenTree/order/test_sales_order.py | 37 +++++++++++++++++++++++------ InvenTree/order/views.py | 4 +--- 5 files changed, 63 insertions(+), 17 deletions(-) diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 0c5fe5e3d4..e88572ae55 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -617,11 +617,34 @@ class SalesOrder(Order): def is_completed(self): """ Check if this order is "shipped" (all line items delivered), - and mark it as "shipped" if so. """ return self.lines.count() > 0 and all([line.is_completed() for line in self.lines.all()]) + def complete_order(self, user): + """ + Mark this order as "complete" + """ + + if self.lines.count() == 0: + # Order without line items cannot be completed + raise ValidationError(_('Order cannot be completed as no parts have been assigned')) + + if self.status != SalesOrderStatus.PENDING: + # Only a PENDING order can be marked as SHIPPED + raise ValidationError(_('Only a pending order can be marked as complete')) + + # Check if there are any incomplete shipments + for shipment in self.shipments.all(): + if not shipment.shipment_date: + raise ValidationError(_('Order cannot be completed as there are pending shipments')) + + self.status = SalesOrderStatus.SHIPPED + self.shipped_by = user + self.shipment_date = datetime.now() + + self.save() + def can_cancel(self): """ Return True if this order can be cancelled @@ -988,6 +1011,9 @@ class SalesOrderShipment(models.Model): help_text=_('Shipment tracking information'), ) + def is_complete(self): + return self.shipment_date is not None + def check_can_complete(self): if self.shipment_date: diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index b0af634a00..cb082dc1f7 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -26,7 +26,7 @@ from InvenTree.serializers import InvenTreeModelSerializer from InvenTree.serializers import InvenTreeDecimalField from InvenTree.serializers import InvenTreeMoneySerializer from InvenTree.serializers import ReferenceIndexingSerializerMixin -from InvenTree.status_codes import StockStatus, SalesOrderStatus +from InvenTree.status_codes import StockStatus import order.models @@ -745,11 +745,11 @@ class SalesOrderCompleteSerializer(serializers.Serializer): request = self.context['request'] order = self.context['order'] - data = self.validated_data + # data = self.validated_data - # Mark this order as complete! - order.status = SalesOrderStatus.SHIPPED - order.save() + user = getattr(request, 'user', None) + + order.complete_order(user) class SOShipmentAllocationSerializer(serializers.Serializer): diff --git a/InvenTree/order/test_migrations.py b/InvenTree/order/test_migrations.py index 97ced6dbbf..3afba65223 100644 --- a/InvenTree/order/test_migrations.py +++ b/InvenTree/order/test_migrations.py @@ -4,7 +4,6 @@ Unit tests for the 'order' model data migrations from django_test_migrations.contrib.unittest_case import MigratorTestCase -from InvenTree import helpers from InvenTree.status_codes import SalesOrderStatus diff --git a/InvenTree/order/test_sales_order.py b/InvenTree/order/test_sales_order.py index 40fab98349..a4fda63a46 100644 --- a/InvenTree/order/test_sales_order.py +++ b/InvenTree/order/test_sales_order.py @@ -10,7 +10,7 @@ from company.models import Company from InvenTree import status_codes as status -from order.models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation +from order.models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation, SalesOrderShipment from part.models import Part @@ -42,6 +42,12 @@ class SalesOrderTest(TestCase): customer_reference='ABC 55555' ) + # Create a Shipment against this SalesOrder + self.shipment = SalesOrderShipment.objects.create( + order=self.order, + reference='001', + ) + # Create a line item self.line = SalesOrderLineItem.objects.create(quantity=50, order=self.order, part=self.part) @@ -86,11 +92,13 @@ class SalesOrderTest(TestCase): # Allocate stock to the order SalesOrderAllocation.objects.create( line=self.line, + shipment=self.shipment, item=StockItem.objects.get(pk=self.Sa.pk), quantity=25) SalesOrderAllocation.objects.create( line=self.line, + shipment=self.shipment, item=StockItem.objects.get(pk=self.Sb.pk), quantity=25 if full else 20 ) @@ -126,9 +134,9 @@ class SalesOrderTest(TestCase): # Now try to ship it - should fail with self.assertRaises(ValidationError): - self.order.ship_order(None) + self.order.complete_order(None) - def test_ship_order(self): + def test_complete_order(self): # Allocate line items, then ship the order # Assert some stuff before we run the test @@ -140,7 +148,22 @@ class SalesOrderTest(TestCase): self.assertEqual(SalesOrderAllocation.objects.count(), 2) - self.order.ship_order(None) + # Attempt to complete the order (but shipments are not completed!) + with self.assertRaises(ValidationError): + self.order.complete_order(None) + + self.assertIsNone(self.shipment.shipment_date) + self.assertFalse(self.shipment.is_complete()) + + # Mark the shipments as complete + self.shipment.complete_shipment(None) + self.assertTrue(self.shipment.is_complete()) + + # Now, should be OK to ship + self.order.complete_order(None) + + self.assertEqual(self.order.status, status.SalesOrderStatus.SHIPPED) + self.assertIsNotNone(self.order.shipment_date) # There should now be 4 stock items self.assertEqual(StockItem.objects.count(), 4) @@ -162,12 +185,12 @@ class SalesOrderTest(TestCase): self.assertEqual(sa.sales_order, None) self.assertEqual(sb.sales_order, None) - # And no allocations - self.assertEqual(SalesOrderAllocation.objects.count(), 0) + # And the allocations still exist + self.assertEqual(SalesOrderAllocation.objects.count(), 2) 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) + self.assertEqual(self.line.allocated_quantity(), 50) diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index 2c3e125dc0..6eec7145ee 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -792,14 +792,12 @@ class OrderParts(AjaxView): order.add_line_item(supplier_part, quantity, purchase_price=purchase_price) - -#### TODO: This class MUST be converted to the API forms! -#### TODO: We MUST select the shipment class SalesOrderAssignSerials(AjaxView, FormMixin): """ View for assigning stock items to a sales order, by serial number lookup. """ + # TODO: Remove this class and replace with an API endpoint model = SalesOrderAllocation role_required = 'sales_order.change' From c93009876dc59c8e6a2a1f00c6a52a884a46523b Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 3 Dec 2021 20:14:09 +1100 Subject: [PATCH 45/54] UI changes --- InvenTree/order/templates/order/sales_order_detail.html | 4 +++- InvenTree/templates/js/translated/order.js | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/InvenTree/order/templates/order/sales_order_detail.html b/InvenTree/order/templates/order/sales_order_detail.html index 7977274155..48b2542752 100644 --- a/InvenTree/order/templates/order/sales_order_detail.html +++ b/InvenTree/order/templates/order/sales_order_detail.html @@ -18,7 +18,7 @@

    {% trans "Sales Order Items" %}

    {% include "spacer.html" %}
    - {% if roles.sales_order.change %} + {% if roles.sales_order.change and order.is_pending %} @@ -52,9 +52,11 @@ {% endif %} + {% if roles.sales_order.change %} + {% endif %}
    diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 64c4f97645..23467bfc88 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -1400,7 +1400,7 @@ function loadSalesOrderShipmentTable(table, options={}) { }, { field: 'reference', - title: '{% trans "Shipment" %}', + title: '{% trans "Shipment Reference" %}', switchable: false, }, { From 3abad2f73d1b5af857ae19ac4d8a492caeb30468 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 4 Dec 2021 09:33:42 +1100 Subject: [PATCH 46/54] js linting --- InvenTree/templates/js/translated/order.js | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 23467bfc88..78065b2998 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -22,6 +22,7 @@ allocateStockToSalesOrder, completeShipment, createSalesOrder, + createSalesOrderShipment, editPurchaseOrderLineItem, exportOrder, loadPurchaseOrderLineItemTable, @@ -1529,13 +1530,9 @@ function allocateStockToSalesOrder(order_id, line_items, options={}) { var table_entries = ''; - for (var idx = 0; idx < line_items.length; idx++ ){ + for (var idx = 0; idx < line_items.length; idx++ ) { var line_item = line_items[idx]; - var todo = "auto-calculate remaining quantity"; - - var todo = "see how it is done for the build order allocation system!"; - var remaining = 0; table_entries += renderLineItemRow(line_item, remaining); @@ -1879,8 +1876,6 @@ function showAllocationSubTable(index, row, element, options) { var table = $(`#allocation-table-${row.pk}`); - var shipped = options.shipped; - function setupCallbacks() { // Add callbacks for 'edit' buttons table.find('.button-allocation-edit').click(function() { @@ -2211,7 +2206,7 @@ function loadSalesOrderLineItemTable(table, options={}) { switchable: false, sortable: true, formatter: function(value, row, index, field) { - return makeProgressBar(row.allocated, row.quantity, { + return makeProgressBar(row.allocated, row.quantity, { id: `order-line-progress-${row.pk}`, }); }, From 1215ef520d4410420c3ba9e04b3a5b7a6a822a95 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 4 Dec 2021 09:44:23 +1100 Subject: [PATCH 47/54] Further JS linting --- InvenTree/templates/js/translated/order.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 7298432a66..2c441d086f 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -1650,7 +1650,6 @@ function allocateStockToSalesOrder(order_id, line_items, options={}) { var available = Math.max((data.quantity || 0) - (data.allocated || 0), 0); // Remaining quantity to be allocated? - var todo = "fix this calculation!"; var remaining = opts.quantity || available; // Maximum amount that we need @@ -2252,8 +2251,7 @@ function loadSalesOrderLineItemTable(table, options={}) { } }); - columns.push( - { + columns.push({ field: 'notes', title: '{% trans "Notes" %}', }); From 921ef5ae9ed86d194b662aafece10da176436bc2 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 4 Dec 2021 10:11:18 +1100 Subject: [PATCH 48/54] Tweaks to PO and SO tables --- InvenTree/templates/js/translated/order.js | 24 ++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 2c441d086f..58c86d1894 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -761,6 +761,9 @@ function removePurchaseOrderLineItem(e) { } +/* + * Load a table displaying list of purchase orders + */ function loadPurchaseOrderTable(table, options) { /* Create a purchase-order table */ @@ -808,13 +811,6 @@ function loadPurchaseOrderTable(table, options) { var html = renderLink(value, `/order/purchase-order/${row.pk}/`); - html += purchaseOrderStatusDisplay( - row.status, - { - classes: 'float-right', - } - ); - if (row.overdue) { html += makeIconBadge('fa-calendar-times icon-red', '{% trans "Order is overdue" %}'); } @@ -839,6 +835,15 @@ function loadPurchaseOrderTable(table, options) { field: 'description', title: '{% trans "Description" %}', }, + { + field: 'status', + title: '{% trans "Status" %}', + switchable: true, + sortable: true, + formatter: function(value, row) { + return purchaseOrderStatusDisplay(row.status); + } + }, { field: 'creation_date', title: '{% trans "Date" %}', @@ -1172,6 +1177,9 @@ function loadPurchaseOrderLineItemTable(table, options={}) { } +/* + * Load table displaying list of sales orders + */ function loadSalesOrderTable(table, options) { options.params = options.params || {}; @@ -1254,7 +1262,7 @@ function loadSalesOrderTable(table, options) { field: 'status', title: '{% trans "Status" %}', formatter: function(value, row) { - return salesOrderStatusDisplay(row.status, row.status_text); + return salesOrderStatusDisplay(row.status); } }, { From 9ba6ac423ded4bbb36b3ed45bd73dcb5b977e330 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 4 Dec 2021 10:16:51 +1100 Subject: [PATCH 49/54] Add shipment status to sales order page --- InvenTree/order/models.py | 24 +++++++++++++++++++ .../templates/order/sales_order_base.html | 10 ++++++++ 2 files changed, 34 insertions(+) diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index e88572ae55..4ab0d6621c 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -700,6 +700,30 @@ class SalesOrder(Order): def pending_line_count(self): return self.pending_line_items().count() + def completed_shipments(self): + """ + Return a queryset of the completed shipments for this order + """ + return self.shipments.exclude(shipment_date=None) + + def pending_shipments(self): + """ + Return a queryset of the pending shipments for this order + """ + + return self.shipments.filter(shipment_date=None) + + @property + def shipment_count(self): + return self.shipments.count() + + @property + def completed_shipment_count(self): + return self.completed_shipments().count() + + @property + def pending_shipment_count(self): + return self.pending_shipments().count() class PurchaseOrderAttachment(InvenTreeAttachment): """ diff --git a/InvenTree/order/templates/order/sales_order_base.html b/InvenTree/order/templates/order/sales_order_base.html index 80ff5bbd97..4a8457d074 100644 --- a/InvenTree/order/templates/order/sales_order_base.html +++ b/InvenTree/order/templates/order/sales_order_base.html @@ -135,6 +135,16 @@ src="{% static 'img/blank_image.png' %}" {% endif %} + + + {% trans "Completed Shipments" %} + + {{ order.completed_shipment_count }} / {{ order.shipment_count }} + {% if order.pending_shipment_count > 0 %} + {% trans "Incomplete" %} + {% endif %} + + {% if order.link %} From e9796676c0e9e199dd7ffb0be317a5ab18e1e5f4 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 4 Dec 2021 10:44:48 +1100 Subject: [PATCH 50/54] Add a progress spinner to modal forms --- InvenTree/order/templates/order/sales_order_base.html | 9 +++++++-- InvenTree/templates/js/translated/forms.js | 6 ++++++ InvenTree/templates/js/translated/modals.js | 1 + 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/InvenTree/order/templates/order/sales_order_base.html b/InvenTree/order/templates/order/sales_order_base.html index 4a8457d074..2781b26a30 100644 --- a/InvenTree/order/templates/order/sales_order_base.html +++ b/InvenTree/order/templates/order/sales_order_base.html @@ -167,8 +167,13 @@ src="{% static 'img/blank_image.png' %}" {% if order.shipment_date %} - {% trans "Shipped" %} - {{ order.shipment_date }}{{ order.shipped_by }} + {% trans "Completed" %} + + {{ order.shipment_date }} + {% if order.shipped_by %} + {{ order.shipped_by }} + {% endif %} + {% endif %} {% if order.responsible %} diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index fdd8b60d28..def7e41358 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -730,6 +730,9 @@ function submitFormData(fields, options) { data = options.processBeforeUpload(data); } + // Show the progress spinner + $(options.modal).find('#modal-progress-spinner').show(); + // Submit data upload_func( options.url, @@ -737,10 +740,13 @@ function submitFormData(fields, options) { { method: options.method, success: function(response) { + $(options.modal).find('#modal-progress-spinner').hide(); handleFormSuccess(response, options); }, error: function(xhr) { + $(options.modal).find('#modal-progress-spinner').hide(); + switch (xhr.status) { case 400: handleFormErrors(xhr.responseJSON, fields, options); diff --git a/InvenTree/templates/js/translated/modals.js b/InvenTree/templates/js/translated/modals.js index 4cd0be8cec..c01bb6c34a 100644 --- a/InvenTree/templates/js/translated/modals.js +++ b/InvenTree/templates/js/translated/modals.js @@ -72,6 +72,7 @@ function createNewModal(options={}) { +

    From 008c52ef39bf71738fa8eefd74464aaff638445f Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 4 Dec 2021 13:08:00 +1100 Subject: [PATCH 51/54] Allocation by serial number now moved to the API --- InvenTree/order/api.py | 25 +++ InvenTree/order/forms.py | 44 +---- InvenTree/order/models.py | 1 + InvenTree/order/serializers.py | 172 ++++++++++++++++- .../order/so_allocate_by_serial.html | 12 -- InvenTree/order/urls.py | 5 - InvenTree/order/views.py | 173 ------------------ InvenTree/templates/js/translated/order.js | 27 ++- 8 files changed, 213 insertions(+), 246 deletions(-) delete mode 100644 InvenTree/order/templates/order/so_allocate_by_serial.html diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 4a06bd76d5..a46215fb0d 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -699,6 +699,30 @@ class SalesOrderComplete(generics.CreateAPIView): return ctx +class SalesOrderAllocateSerials(generics.CreateAPIView): + """ + API endpoint to allocation stock items against a SalesOrder, + by specifying serial numbers. + """ + + queryset = models.SalesOrder.objects.none() + serializer_class = serializers.SOSerialAllocationSerializer + + def get_serializer_context(self): + + ctx = super().get_serializer_context() + + # Pass through the SalesOrder object to the serializer + try: + ctx['order'] = models.SalesOrder.objects.get(pk=self.kwargs.get('pk', None)) + except: + pass + + ctx['request'] = self.request + + return ctx + + class SalesOrderAllocate(generics.CreateAPIView): """ API endpoint to allocate stock items against a SalesOrder @@ -944,6 +968,7 @@ order_api_urls = [ url(r'^(?P\d+)/', include([ url(r'^complete/', SalesOrderComplete.as_view(), name='api-so-complete'), url(r'^allocate/', SalesOrderAllocate.as_view(), name='api-so-allocate'), + url(r'^allocate-serials/', SalesOrderAllocateSerials.as_view(), name='api-so-allocate-serials'), url(r'^.*$', SODetail.as_view(), name='api-so-detail'), ])), diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index a5a3ddc0f1..3eb5566a1e 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -15,10 +15,8 @@ from InvenTree.helpers import clean_decimal from common.forms import MatchItemForm -import part.models - from .models import PurchaseOrder -from .models import SalesOrder, SalesOrderLineItem +from .models import SalesOrder class IssuePurchaseOrderForm(HelperForm): @@ -65,46 +63,6 @@ class CancelSalesOrderForm(HelperForm): ] -class AllocateSerialsToSalesOrderForm(forms.Form): - """ - Form for assigning stock to a sales order, - by serial number lookup - - TODO: Refactor this form / view to use the new API forms interface - """ - - line = forms.ModelChoiceField( - queryset=SalesOrderLineItem.objects.all(), - ) - - part = forms.ModelChoiceField( - queryset=part.models.Part.objects.all(), - ) - - serials = forms.CharField( - label=_("Serial Numbers"), - required=True, - help_text=_('Enter stock item serial numbers'), - ) - - quantity = forms.IntegerField( - label=_('Quantity'), - required=True, - help_text=_('Enter quantity of stock items'), - initial=1, - min_value=1 - ) - - class Meta: - - fields = [ - 'line', - 'part', - 'serials', - 'quantity', - ] - - class OrderMatchItemForm(MatchItemForm): """ Override MatchItemForm fields """ diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 4ab0d6621c..c036e15190 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -725,6 +725,7 @@ class SalesOrder(Order): def pending_shipment_count(self): return self.pending_shipments().count() + class PurchaseOrderAttachment(InvenTreeAttachment): """ Model for storing file attachments against a PurchaseOrder object diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 1c2e9c76bd..fd43164833 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -21,7 +21,7 @@ from common.settings import currency_code_mappings from company.serializers import CompanyBriefSerializer, SupplierPartSerializer from InvenTree.serializers import InvenTreeAttachmentSerializer -from InvenTree.helpers import normalize +from InvenTree.helpers import normalize, extract_serial_numbers from InvenTree.serializers import InvenTreeModelSerializer from InvenTree.serializers import InvenTreeDecimalField from InvenTree.serializers import InvenTreeMoneySerializer @@ -724,7 +724,7 @@ class SOShipmentAllocationItemSerializer(serializers.Serializer): def validate(self, data): - super().validate(data) + data = super().validate(data) stock_item = data['stock_item'] quantity = data['quantity'] @@ -760,6 +760,169 @@ class SalesOrderCompleteSerializer(serializers.Serializer): order.complete_order(user) +class SOSerialAllocationSerializer(serializers.Serializer): + """ + DRF serializer for allocation of serial numbers against a sales order / shipment + """ + + class Meta: + fields = [ + 'line_item', + 'quantity', + 'serial_numbers', + 'shipment', + ] + + line_item = serializers.PrimaryKeyRelatedField( + queryset=order.models.SalesOrderLineItem.objects.all(), + many=False, + required=True, + allow_null=False, + label=_('Line Item'), + ) + + def validate_line_item(self, line_item): + """ + Ensure that the line_item is valid + """ + + order = self.context['order'] + + # Ensure that the line item points to the correct order + if line_item.order != order: + raise ValidationError(_("Line item is not associated with this order")) + + return line_item + + quantity = serializers.IntegerField( + min_value=1, + required=True, + allow_null=False, + label=_('Quantity'), + ) + + serial_numbers = serializers.CharField( + label=_("Serial Numbers"), + help_text=_("Enter serial numbers to allocate"), + required=True, + allow_blank=False, + ) + + shipment = serializers.PrimaryKeyRelatedField( + queryset=order.models.SalesOrderShipment.objects.all(), + many=False, + allow_null=False, + required=True, + label=_('Shipment'), + ) + + def validate_shipment(self, shipment): + """ + Validate the shipment: + + - Must point to the same order + - Must not be shipped + """ + + order = self.context['order'] + + if shipment.shipment_date is not None: + raise ValidationError(_("Shipment has already been shipped")) + + if shipment.order != order: + raise ValidationError(_("Shipment is not associated with this order")) + + return shipment + + def validate(self, data): + """ + Validation for the serializer: + + - Ensure the serial_numbers and quantity fields match + - Check that all serial numbers exist + - Check that the serial numbers are not yet allocated + """ + + data = super().validate(data) + + line_item = data['line_item'] + quantity = data['quantity'] + serial_numbers = data['serial_numbers'] + + part = line_item.part + + try: + data['serials'] = extract_serial_numbers(serial_numbers, quantity) + except DjangoValidationError as e: + raise ValidationError({ + 'serial_numbers': e.messages, + }) + + serials_not_exist = [] + serials_allocated = [] + stock_items_to_allocate = [] + + for serial in data['serials']: + items = stock.models.StockItem.objects.filter( + part=part, + serial=serial, + quantity=1, + ) + + if not items.exists(): + serials_not_exist.append(str(serial)) + continue + + stock_item = items[0] + + if stock_item.unallocated_quantity() == 1: + stock_items_to_allocate.append(stock_item) + else: + serials_allocated.append(str(serial)) + + if len(serials_not_exist) > 0: + + error_msg = _("No match found for the following serial numbers") + error_msg += ": " + error_msg += ",".join(serials_not_exist) + + raise ValidationError({ + 'serial_numbers': error_msg + }) + + if len(serials_allocated) > 0: + + error_msg = _("The following serial numbers are already allocated") + error_msg += ": " + error_msg += ",".join(serials_allocated) + + raise ValidationError({ + 'serial_numbers': error_msg, + }) + + data['stock_items'] = stock_items_to_allocate + + return data + + def save(self): + + data = self.validated_data + + line_item = data['line_item'] + stock_items = data['stock_items'] + shipment = data['shipment'] + + with transaction.atomic(): + for stock_item in stock_items: + # Create a new SalesOrderAllocation + order.models.SalesOrderAllocation.objects.create( + line=line_item, + item=stock_item, + quantity=1, + shipment=shipment + ) + + class SOShipmentAllocationSerializer(serializers.Serializer): """ DRF serializer for allocation of stock items against a sales order / shipment @@ -833,11 +996,6 @@ class SOShipmentAllocationSerializer(serializers.Serializer): shipment=shipment, ) - try: - pass - except (ValidationError, DjangoValidationError) as exc: - raise ValidationError(detail=serializers.as_serializer_error(exc)) - class SOAttachmentSerializer(InvenTreeAttachmentSerializer): """ diff --git a/InvenTree/order/templates/order/so_allocate_by_serial.html b/InvenTree/order/templates/order/so_allocate_by_serial.html deleted file mode 100644 index 3e11d658c7..0000000000 --- a/InvenTree/order/templates/order/so_allocate_by_serial.html +++ /dev/null @@ -1,12 +0,0 @@ -{% extends "modal_form.html" %} -{% load i18n %} - -{% block pre_form_content %} - -
    - {% include "hover_image.html" with image=part.image hover=true %}{{ part }} -
    - {% trans "Allocate stock items by serial number" %} -
    - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/urls.py b/InvenTree/order/urls.py index 8cf472e0ae..504145892a 100644 --- a/InvenTree/order/urls.py +++ b/InvenTree/order/urls.py @@ -41,11 +41,6 @@ sales_order_detail_urls = [ ] sales_order_urls = [ - # URLs for sales order allocations - url(r'^allocation/', include([ - url(r'^assign-serials/', views.SalesOrderAssignSerials.as_view(), name='so-assign-serials'), - ])), - # Display detail view for a single SalesOrder url(r'^(?P\d+)/', include(sales_order_detail_urls)), diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index 6eec7145ee..c89d2a77b1 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -9,12 +9,10 @@ from django.db import transaction from django.db.utils import IntegrityError from django.http.response import JsonResponse from django.shortcuts import get_object_or_404 -from django.core.exceptions import ValidationError from django.urls import reverse from django.http import HttpResponseRedirect from django.utils.translation import ugettext_lazy as _ from django.views.generic import DetailView, ListView -from django.views.generic.edit import FormMixin from django.forms import HiddenInput, IntegerField import logging @@ -22,7 +20,6 @@ from decimal import Decimal, InvalidOperation from .models import PurchaseOrder, PurchaseOrderLineItem from .models import SalesOrder, SalesOrderLineItem -from .models import SalesOrderAllocation from .admin import POLineItemResource, SOLineItemResource from build.models import Build from company.models import Company, SupplierPart # ManufacturerPart @@ -38,7 +35,6 @@ from part.views import PartPricing from InvenTree.views import AjaxView, AjaxUpdateView from InvenTree.helpers import DownloadFile, str2bool -from InvenTree.helpers import extract_serial_numbers from InvenTree.views import InvenTreeRoleMixin from InvenTree.status_codes import PurchaseOrderStatus @@ -792,175 +788,6 @@ class OrderParts(AjaxView): order.add_line_item(supplier_part, quantity, purchase_price=purchase_price) -class SalesOrderAssignSerials(AjaxView, FormMixin): - """ - View for assigning stock items to a sales order, - by serial number lookup. - """ - # TODO: Remove this class and replace with an API endpoint - - model = SalesOrderAllocation - role_required = 'sales_order.change' - ajax_template_name = 'order/so_allocate_by_serial.html' - ajax_form_title = _('Allocate Serial Numbers') - form_class = order_forms.AllocateSerialsToSalesOrderForm - - # Keep track of SalesOrderLineItem and Part references - line = None - part = None - - def get_initial(self): - """ - Initial values are passed as query params - """ - - initials = super().get_initial() - - try: - self.line = SalesOrderLineItem.objects.get(pk=self.request.GET.get('line', None)) - initials['line'] = self.line - except (ValueError, SalesOrderLineItem.DoesNotExist): - pass - - try: - self.part = Part.objects.get(pk=self.request.GET.get('part', None)) - initials['part'] = self.part - except (ValueError, Part.DoesNotExist): - pass - - return initials - - def post(self, request, *args, **kwargs): - - self.form = self.get_form() - - # Validate the form - self.form.is_valid() - self.validate() - - valid = self.form.is_valid() - - if valid: - self.allocate_items() - - data = { - 'form_valid': valid, - 'form_errors': self.form.errors.as_json(), - 'non_field_errors': self.form.non_field_errors().as_json(), - 'success': _("Allocated {n} items").format(n=len(self.stock_items)) - } - - return self.renderJsonResponse(request, self.form, data) - - def validate(self): - - data = self.form.cleaned_data - - # Extract hidden fields from posted data - self.line = data.get('line', None) - self.part = data.get('part', None) - - if self.line: - self.form.fields['line'].widget = HiddenInput() - else: - self.form.add_error('line', _('Select line item')) - - if self.part: - self.form.fields['part'].widget = HiddenInput() - else: - self.form.add_error('part', _('Select part')) - - if not self.form.is_valid(): - return - - # Form is otherwise valid - check serial numbers - serials = data.get('serials', '') - quantity = data.get('quantity', 1) - - # Save a list of serial_numbers - self.serial_numbers = None - self.stock_items = [] - - try: - self.serial_numbers = extract_serial_numbers(serials, quantity) - - for serial in self.serial_numbers: - try: - # Find matching stock item - stock_item = StockItem.objects.get( - part=self.part, - serial=serial - ) - except StockItem.DoesNotExist: - self.form.add_error( - 'serials', - _('No matching item for serial {serial}').format(serial=serial) - ) - continue - - # Now we have a valid stock item - but can it be added to the sales order? - - # If not in stock, cannot be added to the order - if not stock_item.in_stock: - self.form.add_error( - 'serials', - _('{serial} is not in stock').format(serial=serial) - ) - continue - - # Already allocated to an order - if stock_item.is_allocated(): - self.form.add_error( - 'serials', - _('{serial} already allocated to an order').format(serial=serial) - ) - continue - - # Add it to the list! - self.stock_items.append(stock_item) - - except ValidationError as e: - self.form.add_error('serials', e.messages) - - def allocate_items(self): - """ - Create stock item allocations for each selected serial number - """ - - for stock_item in self.stock_items: - SalesOrderAllocation.objects.create( - item=stock_item, - line=self.line, - quantity=1, - ) - - def get_form(self): - - form = super().get_form() - - if self.line: - form.fields['line'].widget = HiddenInput() - - if self.part: - form.fields['part'].widget = HiddenInput() - - return form - - def get_context_data(self): - return { - 'line': self.line, - 'part': self.part, - } - - def get(self, request, *args, **kwargs): - - return self.renderJsonResponse( - request, - self.get_form(), - context=self.get_context_data(), - ) - - class LineItemPricing(PartPricing): """ View for inspecting part pricing information """ diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 58c86d1894..df8821fe07 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -2357,15 +2357,30 @@ function loadSalesOrderLineItemTable(table, options={}) { $(table).find('.button-add-by-sn').click(function() { var pk = $(this).attr('pk'); - // TODO: Migrate this form to the API forms inventreeGet(`/api/order/so-line/${pk}/`, {}, { success: function(response) { - launchModalForm('{% url "so-assign-serials" %}', { - success: reloadTable, - data: { - line: pk, - part: response.part, + + constructForm(`/api/order/so/${options.order}/allocate-serials/`, { + method: 'POST', + title: '{% trans "Allocate Serial Numbers" %}', + fields: { + line_item: { + value: pk, + hidden: true, + }, + quantity: {}, + serial_numbers: {}, + shipment: { + filters: { + order: options.order, + shipped: false, + }, + auto_fill: true, + } + }, + onSuccess: function() { + $(table).bootstrapTable('refresh'); } }); } From 31398b4c10d2732fa132c539c222e9b19d22a2ca Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 4 Dec 2021 13:50:11 +1100 Subject: [PATCH 52/54] Sales order can now be completed via the API --- InvenTree/order/api.py | 2 +- InvenTree/order/models.py | 44 ++++++++++++++----- InvenTree/order/serializers.py | 11 ++++- .../templates/order/sales_order_base.html | 9 +++- 4 files changed, 50 insertions(+), 16 deletions(-) diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index a46215fb0d..1ae6ae0184 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -683,7 +683,7 @@ class SalesOrderComplete(generics.CreateAPIView): """ queryset = models.SalesOrder.objects.all() - serializer_class = serializers.SalesOrderShipmentCompleteSerializer + serializer_class = serializers.SalesOrderCompleteSerializer def get_serializer_context(self): diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index c036e15190..434e6a9d15 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -621,23 +621,43 @@ class SalesOrder(Order): return self.lines.count() > 0 and all([line.is_completed() for line in self.lines.all()]) + def can_complete(self, raise_error=False): + """ + Test if this SalesOrder can be completed. + + Throws a ValidationError if cannot be completed. + """ + + # Order without line items cannot be completed + if self.lines.count() == 0: + if raise_error: + raise ValidationError(_('Order cannot be completed as no parts have been assigned')) + + # Only a PENDING order can be marked as SHIPPED + elif self.status != SalesOrderStatus.PENDING: + if raise_error: + raise ValidationError(_('Only a pending order can be marked as complete')) + + elif self.pending_shipment_count > 0: + if raise_error: + raise ValidationError(_("Order cannot be completed as there are incomplete shipments")) + + elif self.pending_line_count > 0: + if raise_error: + raise ValidationError(_("Order cannot be completed as there are incomplete line items")) + + else: + return True + + return False + def complete_order(self, user): """ Mark this order as "complete" """ - if self.lines.count() == 0: - # Order without line items cannot be completed - raise ValidationError(_('Order cannot be completed as no parts have been assigned')) - - if self.status != SalesOrderStatus.PENDING: - # Only a PENDING order can be marked as SHIPPED - raise ValidationError(_('Only a pending order can be marked as complete')) - - # Check if there are any incomplete shipments - for shipment in self.shipments.all(): - if not shipment.shipment_date: - raise ValidationError(_('Order cannot be completed as there are pending shipments')) + if not self.can_complete(): + return self.status = SalesOrderStatus.SHIPPED self.shipped_by = user diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index fd43164833..32e50943b1 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -749,11 +749,20 @@ class SalesOrderCompleteSerializer(serializers.Serializer): DRF serializer for manually marking a sales order as complete """ + def validate(self, data): + + data = super().validate(data) + + order = self.context['order'] + + order.can_complete(raise_error=True) + + return data + def save(self): request = self.context['request'] order = self.context['order'] - # data = self.validated_data user = getattr(request, 'user', None) diff --git a/InvenTree/order/templates/order/sales_order_base.html b/InvenTree/order/templates/order/sales_order_base.html index 2781b26a30..c8718d54d8 100644 --- a/InvenTree/order/templates/order/sales_order_base.html +++ b/InvenTree/order/templates/order/sales_order_base.html @@ -63,7 +63,7 @@ src="{% static 'img/blank_image.png' %}" {% if order.status == SalesOrderStatus.PENDING %} - {% endif %} @@ -224,7 +224,12 @@ $("#cancel-order").click(function() { }); $("#complete-order").click(function() { - completeSalesOrder({{ order.pk }}); + constructForm('{% url "api-so-complete" order.id %}', { + method: 'POST', + title: '{% trans "Complete Sales Order" %}', + confirm: true, + reload: true, + }); }); {% if report_enabled %} From 9e35c52b1d114ad61e6345bb5e99678eeb441153 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 4 Dec 2021 17:30:13 +1100 Subject: [PATCH 53/54] unit testing fixes --- InvenTree/order/models.py | 4 +++- InvenTree/order/test_sales_order.py | 16 +++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 434e6a9d15..3a7d39d7b5 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -657,7 +657,7 @@ class SalesOrder(Order): """ if not self.can_complete(): - return + return False self.status = SalesOrderStatus.SHIPPED self.shipped_by = user @@ -665,6 +665,8 @@ class SalesOrder(Order): self.save() + return True + def can_cancel(self): """ Return True if this order can be cancelled diff --git a/InvenTree/order/test_sales_order.py b/InvenTree/order/test_sales_order.py index a4fda63a46..d43c94996c 100644 --- a/InvenTree/order/test_sales_order.py +++ b/InvenTree/order/test_sales_order.py @@ -132,9 +132,12 @@ class SalesOrderTest(TestCase): 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.complete_order(None) + self.order.can_complete(raise_error=True) + + # Now try to ship it - should fail + result = self.order.complete_order(None) + self.assertFalse(result) def test_complete_order(self): # Allocate line items, then ship the order @@ -149,8 +152,9 @@ class SalesOrderTest(TestCase): self.assertEqual(SalesOrderAllocation.objects.count(), 2) # Attempt to complete the order (but shipments are not completed!) - with self.assertRaises(ValidationError): - self.order.complete_order(None) + result = self.order.complete_order(None) + + self.assertFalse(result) self.assertIsNone(self.shipment.shipment_date) self.assertFalse(self.shipment.is_complete()) @@ -160,7 +164,9 @@ class SalesOrderTest(TestCase): self.assertTrue(self.shipment.is_complete()) # Now, should be OK to ship - self.order.complete_order(None) + result = self.order.complete_order(None) + + self.assertTrue(result) self.assertEqual(self.order.status, status.SalesOrderStatus.SHIPPED) self.assertIsNotNone(self.order.shipment_date) From 9ec5e39c503cb0817f80a09e0549d98fb858013c Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 4 Dec 2021 17:50:25 +1100 Subject: [PATCH 54/54] js tweaks --- InvenTree/templates/js/translated/order.js | 26 +++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index df8821fe07..fefa82475c 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -2017,8 +2017,9 @@ function showFulfilledSubTable(index, row, element, options) { queryParams: { part: row.part, sales_order: options.order, + location_detail: true, }, - showHeader: false, + showHeader: true, columns: [ { field: 'pk', @@ -2026,6 +2027,7 @@ function showFulfilledSubTable(index, row, element, options) { }, { field: 'stock', + title: '{% trans "Stock Item" %}', formatter: function(value, row) { var text = ''; if (row.serial && row.quantity == 1) { @@ -2037,11 +2039,25 @@ function showFulfilledSubTable(index, row, element, options) { return renderLink(text, `/stock/item/${row.pk}/`); }, }, - /* { - field: 'po' - }, - */ + field: 'location', + title: '{% trans "Location" %}', + formatter: function(value, row) { + if (row.customer) { + return renderLink( + '{% trans "Shipped to customer" %}', + `/company/${row.customer}/` + ); + } else if (row.location && row.location_detail) { + return renderLink( + row.location_detail.pathstring, + `/stock/location/${row.location}`, + ); + } else { + return `{% trans "Stock location not specified" %}`; + } + } + } ], }); }