diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index f72668f9f0..214f1cd605 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -637,7 +637,7 @@ class SupplierPart(models.Model): get_price = common.models.get_price def open_orders(self): - """ Return a database query for PO line items for this SupplierPart, + """ Return a database query for PurchaseOrder line items for this SupplierPart, limited to purchase orders that are open / outstanding. """ diff --git a/InvenTree/order/admin.py b/InvenTree/order/admin.py index e98b31939a..0de28d5668 100644 --- a/InvenTree/order/admin.py +++ b/InvenTree/order/admin.py @@ -8,11 +8,35 @@ from import_export.admin import ImportExportModelAdmin from import_export.resources import ModelResource from import_export.fields import Field -from .models import PurchaseOrder, PurchaseOrderLineItem -from .models import SalesOrder, SalesOrderLineItem +from .models import PurchaseOrder, PurchaseOrderLineItem, PurchaseOrderExtraLine +from .models import SalesOrder, SalesOrderLineItem, SalesOrderExtraLine from .models import SalesOrderShipment, SalesOrderAllocation +# region general classes +class GeneralExtraLineAdmin: + list_display = ( + 'order', + 'quantity', + 'reference' + ) + + search_fields = [ + 'order__reference', + 'order__customer__name', + 'reference', + ] + + autocomplete_fields = ('order', ) + + +class GeneralExtraLineMeta: + skip_unchanged = True + report_skipped = False + clean_model_instances = True +# endregion + + class PurchaseOrderLineItemInlineAdmin(admin.StackedInline): model = PurchaseOrderLineItem extra = 0 @@ -68,8 +92,8 @@ class SalesOrderAdmin(ImportExportModelAdmin): autocomplete_fields = ('customer',) -class POLineItemResource(ModelResource): - """ Class for managing import / export of POLineItem data """ +class PurchaseOrderLineItemResource(ModelResource): + """ Class for managing import / export of PurchaseOrderLineItem data """ part_name = Field(attribute='part__part__name', readonly=True) @@ -86,9 +110,16 @@ class POLineItemResource(ModelResource): clean_model_instances = True -class SOLineItemResource(ModelResource): +class PurchaseOrderExtraLineResource(ModelResource): + """ Class for managing import / export of PurchaseOrderExtraLine data """ + + class Meta(GeneralExtraLineMeta): + model = PurchaseOrderExtraLine + + +class SalesOrderLineItemResource(ModelResource): """ - Class for managing import / export of SOLineItem data + Class for managing import / export of SalesOrderLineItem data """ part_name = Field(attribute='part__name', readonly=True) @@ -117,9 +148,16 @@ class SOLineItemResource(ModelResource): clean_model_instances = True +class SalesOrderExtraLineResource(ModelResource): + """ Class for managing import / export of SalesOrderExtraLine data """ + + class Meta(GeneralExtraLineMeta): + model = SalesOrderExtraLine + + class PurchaseOrderLineItemAdmin(ImportExportModelAdmin): - resource_class = POLineItemResource + resource_class = PurchaseOrderLineItemResource list_display = ( 'order', @@ -133,9 +171,14 @@ class PurchaseOrderLineItemAdmin(ImportExportModelAdmin): autocomplete_fields = ('order', 'part', 'destination',) +class PurchaseOrderExtraLineAdmin(GeneralExtraLineAdmin, ImportExportModelAdmin): + + resource_class = PurchaseOrderExtraLineResource + + class SalesOrderLineItemAdmin(ImportExportModelAdmin): - resource_class = SOLineItemResource + resource_class = SalesOrderLineItemResource list_display = ( 'order', @@ -154,6 +197,11 @@ class SalesOrderLineItemAdmin(ImportExportModelAdmin): autocomplete_fields = ('order', 'part',) +class SalesOrderExtraLineAdmin(GeneralExtraLineAdmin, ImportExportModelAdmin): + + resource_class = SalesOrderExtraLineResource + + class SalesOrderShipmentAdmin(ImportExportModelAdmin): list_display = [ @@ -184,9 +232,11 @@ class SalesOrderAllocationAdmin(ImportExportModelAdmin): admin.site.register(PurchaseOrder, PurchaseOrderAdmin) admin.site.register(PurchaseOrderLineItem, PurchaseOrderLineItemAdmin) +admin.site.register(PurchaseOrderExtraLine, PurchaseOrderExtraLineAdmin) admin.site.register(SalesOrder, SalesOrderAdmin) admin.site.register(SalesOrderLineItem, SalesOrderLineItemAdmin) +admin.site.register(SalesOrderExtraLine, SalesOrderExtraLineAdmin) admin.site.register(SalesOrderShipment, SalesOrderShipmentAdmin) admin.site.register(SalesOrderAllocation, SalesOrderAllocationAdmin) diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 8c333734ed..0b0fe3185a 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -20,16 +20,68 @@ from InvenTree.helpers import str2bool, DownloadFile from InvenTree.api import AttachmentMixin from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus -from order.admin import POLineItemResource +from order.admin import PurchaseOrderLineItemResource import order.models as models import order.serializers as serializers from part.models import Part from users.models import Owner -class POFilter(rest_filters.FilterSet): +class GeneralExtraLineList: """ - Custom API filters for the POList endpoint + General template for ExtraLine API classes + """ + + def get_serializer(self, *args, **kwargs): + try: + params = self.request.query_params + + kwargs['order_detail'] = str2bool(params.get('order_detail', False)) + except AttributeError: + pass + + kwargs['context'] = self.get_serializer_context() + + return self.serializer_class(*args, **kwargs) + + def get_queryset(self, *args, **kwargs): + + queryset = super().get_queryset(*args, **kwargs) + + queryset = queryset.prefetch_related( + 'order', + ) + + return queryset + + filter_backends = [ + rest_filters.DjangoFilterBackend, + filters.SearchFilter, + filters.OrderingFilter + ] + + ordering_fields = [ + 'title', + 'quantity', + 'note', + 'reference', + ] + + search_fields = [ + 'title', + 'quantity', + 'note', + 'reference' + ] + + filter_fields = [ + 'order', + ] + + +class PurchaseOrderFilter(rest_filters.FilterSet): + """ + Custom API filters for the PurchaseOrderList endpoint """ assigned_to_me = rest_filters.BooleanFilter(label='assigned_to_me', method='filter_assigned_to_me') @@ -58,16 +110,16 @@ class POFilter(rest_filters.FilterSet): ] -class POList(generics.ListCreateAPIView): +class PurchaseOrderList(generics.ListCreateAPIView): """ API endpoint for accessing a list of PurchaseOrder objects - - GET: Return list of PO objects (with filters) + - GET: Return list of PurchaseOrder objects (with filters) - POST: Create a new PurchaseOrder object """ queryset = models.PurchaseOrder.objects.all() - serializer_class = serializers.POSerializer - filterset_class = POFilter + serializer_class = serializers.PurchaseOrderSerializer + filterset_class = PurchaseOrderFilter def create(self, request, *args, **kwargs): """ @@ -104,7 +156,7 @@ class POList(generics.ListCreateAPIView): 'lines', ) - queryset = serializers.POSerializer.annotate_queryset(queryset) + queryset = serializers.PurchaseOrderSerializer.annotate_queryset(queryset) return queryset @@ -202,11 +254,11 @@ class POList(generics.ListCreateAPIView): ordering = '-creation_date' -class PODetail(generics.RetrieveUpdateDestroyAPIView): +class PurchaseOrderDetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for detail view of a PurchaseOrder object """ queryset = models.PurchaseOrder.objects.all() - serializer_class = serializers.POSerializer + serializer_class = serializers.PurchaseOrderSerializer def get_serializer(self, *args, **kwargs): @@ -229,12 +281,12 @@ class PODetail(generics.RetrieveUpdateDestroyAPIView): 'lines', ) - queryset = serializers.POSerializer.annotate_queryset(queryset) + queryset = serializers.PurchaseOrderSerializer.annotate_queryset(queryset) return queryset -class POReceive(generics.CreateAPIView): +class PurchaseOrderReceive(generics.CreateAPIView): """ API endpoint to receive stock items against a purchase order. @@ -249,7 +301,7 @@ class POReceive(generics.CreateAPIView): queryset = models.PurchaseOrderLineItem.objects.none() - serializer_class = serializers.POReceiveSerializer + serializer_class = serializers.PurchaseOrderReceiveSerializer def get_serializer_context(self): @@ -266,9 +318,9 @@ class POReceive(generics.CreateAPIView): return context -class POLineItemFilter(rest_filters.FilterSet): +class PurchaseOrderLineItemFilter(rest_filters.FilterSet): """ - Custom filters for the POLineItemList endpoint + Custom filters for the PurchaseOrderLineItemList endpoint """ class Meta: @@ -318,22 +370,22 @@ class POLineItemFilter(rest_filters.FilterSet): return queryset -class POLineItemList(generics.ListCreateAPIView): - """ API endpoint for accessing a list of POLineItem objects +class PurchaseOrderLineItemList(generics.ListCreateAPIView): + """ API endpoint for accessing a list of PurchaseOrderLineItem objects - - GET: Return a list of PO Line Item objects + - GET: Return a list of PurchaseOrder Line Item objects - POST: Create a new PurchaseOrderLineItem object """ queryset = models.PurchaseOrderLineItem.objects.all() - serializer_class = serializers.POLineItemSerializer - filterset_class = POLineItemFilter + serializer_class = serializers.PurchaseOrderLineItemSerializer + filterset_class = PurchaseOrderLineItemFilter def get_queryset(self, *args, **kwargs): queryset = super().get_queryset(*args, **kwargs) - queryset = serializers.POLineItemSerializer.annotate_queryset(queryset) + queryset = serializers.PurchaseOrderLineItemSerializer.annotate_queryset(queryset) return queryset @@ -382,7 +434,7 @@ class POLineItemList(generics.ListCreateAPIView): export_format = str(export_format).strip().lower() if export_format in ['csv', 'tsv', 'xls', 'xlsx']: - dataset = POLineItemResource().export(queryset=queryset) + dataset = PurchaseOrderLineItemResource().export(queryset=queryset) filedata = dataset.export(export_format) @@ -432,30 +484,46 @@ class POLineItemList(generics.ListCreateAPIView): ] -class POLineItemDetail(generics.RetrieveUpdateDestroyAPIView): +class PurchaseOrderLineItemDetail(generics.RetrieveUpdateDestroyAPIView): """ Detail API endpoint for PurchaseOrderLineItem object """ queryset = models.PurchaseOrderLineItem.objects.all() - serializer_class = serializers.POLineItemSerializer + serializer_class = serializers.PurchaseOrderLineItemSerializer def get_queryset(self): queryset = super().get_queryset() - queryset = serializers.POLineItemSerializer.annotate_queryset(queryset) + queryset = serializers.PurchaseOrderLineItemSerializer.annotate_queryset(queryset) return queryset -class SOAttachmentList(generics.ListCreateAPIView, AttachmentMixin): +class PurchaseOrderExtraLineList(GeneralExtraLineList, generics.ListCreateAPIView): + """ + API endpoint for accessing a list of PurchaseOrderExtraLine objects. + """ + + queryset = models.PurchaseOrderExtraLine.objects.all() + serializer_class = serializers.PurchaseOrderExtraLineSerializer + + +class PurchaseOrderExtraLineDetail(generics.RetrieveUpdateDestroyAPIView): + """ API endpoint for detail view of a PurchaseOrderExtraLine object """ + + queryset = models.PurchaseOrderExtraLine.objects.all() + serializer_class = serializers.PurchaseOrderExtraLineSerializer + + +class SalesOrderAttachmentList(generics.ListCreateAPIView, AttachmentMixin): """ API endpoint for listing (and creating) a SalesOrderAttachment (file upload) """ queryset = models.SalesOrderAttachment.objects.all() - serializer_class = serializers.SOAttachmentSerializer + serializer_class = serializers.SalesOrderAttachmentSerializer filter_backends = [ rest_filters.DjangoFilterBackend, @@ -466,20 +534,20 @@ class SOAttachmentList(generics.ListCreateAPIView, AttachmentMixin): ] -class SOAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin): +class SalesOrderAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin): """ Detail endpoint for SalesOrderAttachment """ queryset = models.SalesOrderAttachment.objects.all() - serializer_class = serializers.SOAttachmentSerializer + serializer_class = serializers.SalesOrderAttachmentSerializer -class SOList(generics.ListCreateAPIView): +class SalesOrderList(generics.ListCreateAPIView): """ API endpoint for accessing a list of SalesOrder objects. - - GET: Return list of SO objects (with filters) + - GET: Return list of SalesOrder objects (with filters) - POST: Create a new SalesOrder """ @@ -616,7 +684,7 @@ class SOList(generics.ListCreateAPIView): ordering = '-creation_date' -class SODetail(generics.RetrieveUpdateDestroyAPIView): +class SalesOrderDetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for detail view of a SalesOrder object. """ @@ -646,9 +714,9 @@ class SODetail(generics.RetrieveUpdateDestroyAPIView): return queryset -class SOLineItemFilter(rest_filters.FilterSet): +class SalesOrderLineItemFilter(rest_filters.FilterSet): """ - Custom filters for SOLineItemList endpoint + Custom filters for SalesOrderLineItemList endpoint """ class Meta: @@ -679,14 +747,14 @@ class SOLineItemFilter(rest_filters.FilterSet): return queryset -class SOLineItemList(generics.ListCreateAPIView): +class SalesOrderLineItemList(generics.ListCreateAPIView): """ API endpoint for accessing a list of SalesOrderLineItem objects. """ queryset = models.SalesOrderLineItem.objects.all() - serializer_class = serializers.SOLineItemSerializer - filterset_class = SOLineItemFilter + serializer_class = serializers.SalesOrderLineItemSerializer + filterset_class = SalesOrderLineItemFilter def get_serializer(self, *args, **kwargs): @@ -743,11 +811,27 @@ class SOLineItemList(generics.ListCreateAPIView): ] -class SOLineItemDetail(generics.RetrieveUpdateDestroyAPIView): +class SalesOrderExtraLineList(GeneralExtraLineList, generics.ListCreateAPIView): + """ + API endpoint for accessing a list of SalesOrderExtraLine objects. + """ + + queryset = models.SalesOrderExtraLine.objects.all() + serializer_class = serializers.SalesOrderExtraLineSerializer + + +class SalesOrderExtraLineDetail(generics.RetrieveUpdateDestroyAPIView): + """ API endpoint for detail view of a SalesOrderExtraLine object """ + + queryset = models.SalesOrderExtraLine.objects.all() + serializer_class = serializers.SalesOrderExtraLineSerializer + + +class SalesOrderLineItemDetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for detail view of a SalesOrderLineItem object """ queryset = models.SalesOrderLineItem.objects.all() - serializer_class = serializers.SOLineItemSerializer + serializer_class = serializers.SalesOrderLineItemSerializer class SalesOrderComplete(generics.CreateAPIView): @@ -779,7 +863,7 @@ class SalesOrderAllocateSerials(generics.CreateAPIView): """ queryset = models.SalesOrder.objects.none() - serializer_class = serializers.SOSerialAllocationSerializer + serializer_class = serializers.SalesOrderSerialAllocationSerializer def get_serializer_context(self): @@ -801,11 +885,11 @@ class SalesOrderAllocate(generics.CreateAPIView): API endpoint to allocate stock items against a SalesOrder - The SalesOrder is specified in the URL - - See the SOShipmentAllocationSerializer class + - See the SalesOrderShipmentAllocationSerializer class """ queryset = models.SalesOrder.objects.none() - serializer_class = serializers.SOShipmentAllocationSerializer + serializer_class = serializers.SalesOrderShipmentAllocationSerializer def get_serializer_context(self): @@ -822,7 +906,7 @@ class SalesOrderAllocate(generics.CreateAPIView): return ctx -class SOAllocationDetail(generics.RetrieveUpdateDestroyAPIView): +class SalesOrderAllocationDetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for detali view of a SalesOrderAllocation object """ @@ -831,7 +915,7 @@ class SOAllocationDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = serializers.SalesOrderAllocationSerializer -class SOAllocationList(generics.ListAPIView): +class SalesOrderAllocationList(generics.ListAPIView): """ API endpoint for listing SalesOrderAllocation objects """ @@ -909,9 +993,9 @@ class SOAllocationList(generics.ListAPIView): ] -class SOShipmentFilter(rest_filters.FilterSet): +class SalesOrderShipmentFilter(rest_filters.FilterSet): """ - Custom filterset for the SOShipmentList endpoint + Custom filterset for the SalesOrderShipmentList endpoint """ shipped = rest_filters.BooleanFilter(label='shipped', method='filter_shipped') @@ -934,21 +1018,21 @@ class SOShipmentFilter(rest_filters.FilterSet): ] -class SOShipmentList(generics.ListCreateAPIView): +class SalesOrderShipmentList(generics.ListCreateAPIView): """ API list endpoint for SalesOrderShipment model """ queryset = models.SalesOrderShipment.objects.all() serializer_class = serializers.SalesOrderShipmentSerializer - filterset_class = SOShipmentFilter + filterset_class = SalesOrderShipmentFilter filter_backends = [ rest_filters.DjangoFilterBackend, ] -class SOShipmentDetail(generics.RetrieveUpdateDestroyAPIView): +class SalesOrderShipmentDetail(generics.RetrieveUpdateDestroyAPIView): """ API detail endpooint for SalesOrderShipment model """ @@ -957,7 +1041,7 @@ class SOShipmentDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = serializers.SalesOrderShipmentSerializer -class SOShipmentComplete(generics.CreateAPIView): +class SalesOrderShipmentComplete(generics.CreateAPIView): """ API endpoint for completing (shipping) a SalesOrderShipment """ @@ -983,13 +1067,13 @@ class SOShipmentComplete(generics.CreateAPIView): return ctx -class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin): +class PurchaseOrderAttachmentList(generics.ListCreateAPIView, AttachmentMixin): """ API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload) """ queryset = models.PurchaseOrderAttachment.objects.all() - serializer_class = serializers.POAttachmentSerializer + serializer_class = serializers.PurchaseOrderAttachmentSerializer filter_backends = [ rest_filters.DjangoFilterBackend, @@ -1000,13 +1084,13 @@ class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin): ] -class POAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin): +class PurchaseOrderAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin): """ Detail endpoint for a PurchaseOrderAttachment """ queryset = models.PurchaseOrderAttachment.objects.all() - serializer_class = serializers.POAttachmentSerializer + serializer_class = serializers.PurchaseOrderAttachmentSerializer order_api_urls = [ @@ -1016,39 +1100,45 @@ order_api_urls = [ # Purchase order attachments url(r'attachment/', include([ - url(r'^(?P\d+)/$', POAttachmentDetail.as_view(), name='api-po-attachment-detail'), - url(r'^.*$', POAttachmentList.as_view(), name='api-po-attachment-list'), + url(r'^(?P\d+)/$', PurchaseOrderAttachmentDetail.as_view(), name='api-po-attachment-detail'), + url(r'^.*$', PurchaseOrderAttachmentList.as_view(), name='api-po-attachment-list'), ])), # Individual purchase order detail URLs url(r'^(?P\d+)/', include([ - url(r'^receive/', POReceive.as_view(), name='api-po-receive'), - url(r'.*$', PODetail.as_view(), name='api-po-detail'), + url(r'^receive/', PurchaseOrderReceive.as_view(), name='api-po-receive'), + url(r'.*$', PurchaseOrderDetail.as_view(), name='api-po-detail'), ])), # Purchase order list - url(r'^.*$', POList.as_view(), name='api-po-list'), + url(r'^.*$', PurchaseOrderList.as_view(), name='api-po-list'), ])), # API endpoints for purchase order line items url(r'^po-line/', include([ - url(r'^(?P\d+)/$', POLineItemDetail.as_view(), name='api-po-line-detail'), - url(r'^.*$', POLineItemList.as_view(), name='api-po-line-list'), + url(r'^(?P\d+)/$', PurchaseOrderLineItemDetail.as_view(), name='api-po-line-detail'), + url(r'^.*$', PurchaseOrderLineItemList.as_view(), name='api-po-line-list'), ])), - # API endpoints for sales orders + # API endpoints for purchase order extra line + url(r'^po-extra-line/', include([ + url(r'^(?P\d+)/$', PurchaseOrderExtraLineDetail.as_view(), name='api-po-extra-line-detail'), + url(r'^$', PurchaseOrderExtraLineList.as_view(), name='api-po-extra-line-list'), + ])), + + # API endpoints for sales ordesr url(r'^so/', include([ url(r'attachment/', include([ - url(r'^(?P\d+)/$', SOAttachmentDetail.as_view(), name='api-so-attachment-detail'), - url(r'^.*$', SOAttachmentList.as_view(), name='api-so-attachment-list'), + url(r'^(?P\d+)/$', SalesOrderAttachmentDetail.as_view(), name='api-so-attachment-detail'), + url(r'^.*$', SalesOrderAttachmentList.as_view(), name='api-so-attachment-list'), ])), 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'^ship/$', SalesOrderShipmentComplete.as_view(), name='api-so-shipment-ship'), + url(r'^.*$', SalesOrderShipmentDetail.as_view(), name='api-so-shipment-detail'), ])), - url(r'^.*$', SOShipmentList.as_view(), name='api-so-shipment-list'), + url(r'^.*$', SalesOrderShipmentList.as_view(), name='api-so-shipment-list'), ])), # Sales order detail view @@ -1056,22 +1146,28 @@ order_api_urls = [ 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'), + url(r'^.*$', SalesOrderDetail.as_view(), name='api-so-detail'), ])), # Sales order list view - url(r'^.*$', SOList.as_view(), name='api-so-list'), + url(r'^.*$', SalesOrderList.as_view(), name='api-so-list'), ])), # API endpoints for sales order line items url(r'^so-line/', include([ - url(r'^(?P\d+)/$', SOLineItemDetail.as_view(), name='api-so-line-detail'), - url(r'^$', SOLineItemList.as_view(), name='api-so-line-list'), + url(r'^(?P\d+)/$', SalesOrderLineItemDetail.as_view(), name='api-so-line-detail'), + url(r'^$', SalesOrderLineItemList.as_view(), name='api-so-line-list'), + ])), + + # API endpoints for sales order extra line + url(r'^so-extra-line/', include([ + url(r'^(?P\d+)/$', SalesOrderExtraLineDetail.as_view(), name='api-so-extra-line-detail'), + url(r'^$', SalesOrderExtraLineList.as_view(), name='api-so-extra-line-list'), ])), # API endpoints for sales order allocations url(r'^so-allocation/', include([ - url(r'^(?P\d+)/$', SOAllocationDetail.as_view(), name='api-so-allocation-detail'), - url(r'^.*$', SOAllocationList.as_view(), name='api-so-allocation-list'), + url(r'^(?P\d+)/$', SalesOrderAllocationDetail.as_view(), name='api-so-allocation-detail'), + url(r'^.*$', SalesOrderAllocationList.as_view(), name='api-so-allocation-list'), ])), ] diff --git a/InvenTree/order/migrations/0064_purchaseorderextraline_salesorderextraline.py b/InvenTree/order/migrations/0064_purchaseorderextraline_salesorderextraline.py new file mode 100644 index 0000000000..53bf0621ed --- /dev/null +++ b/InvenTree/order/migrations/0064_purchaseorderextraline_salesorderextraline.py @@ -0,0 +1,109 @@ +# Generated by Django 3.2.12 on 2022-03-27 01:11 + +import InvenTree.fields +import django.core.validators +from django.core import serializers +from django.db import migrations, models +import django.db.models.deletion +import djmoney.models.fields +import djmoney.models.validators + + +def _convert_model(apps, line_item_ref, extra_line_ref, price_ref): + """Convert the OrderLineItem instances if applicable to new ExtraLine instances""" + OrderLineItem = apps.get_model('order', line_item_ref) + OrderExtraLine = apps.get_model('order', extra_line_ref) + + items_to_change = OrderLineItem.objects.filter(part=None) + if items_to_change.count() == 0: + return + + print(f'\nFound {items_to_change.count()} old {line_item_ref} instance(s)') + print(f'Starting to convert - currently at {OrderExtraLine.objects.all().count()} {extra_line_ref} / {OrderLineItem.objects.all().count()} {line_item_ref} instance(s)') + for lineItem in items_to_change: + newitem = OrderExtraLine( + order=lineItem.order, + notes=lineItem.notes, + price=getattr(lineItem, price_ref), + quantity=lineItem.quantity, + reference=lineItem.reference, + ) + newitem.context = {'migration': serializers.serialize('json', [lineItem, ])} + newitem.save() + + lineItem.delete() + print(f'Done converting line items - now at {OrderExtraLine.objects.all().count()} {extra_line_ref} / {OrderLineItem.objects.all().count()} {line_item_ref} instance(s)') + + +def _reconvert_model(apps, line_item_ref, extra_line_ref): + """Convert ExtraLine instances back to OrderLineItem instances""" + OrderLineItem = apps.get_model('order', line_item_ref) + OrderExtraLine = apps.get_model('order', extra_line_ref) + + print(f'\nStarting to convert - currently at {OrderExtraLine.objects.all().count()} {extra_line_ref} / {OrderLineItem.objects.all().count()} {line_item_ref} instance(s)') + for extra_line in OrderExtraLine.objects.all(): + # regenreate item + if extra_line.context: + context_string = getattr(extra_line.context, 'migration') + if not context_string: + continue + [item.save() for item in serializers.deserialize('json', context_string)] + extra_line.delete() + print(f'Done converting line items - now at {OrderExtraLine.objects.all().count()} {extra_line_ref} / {OrderLineItem.objects.all().count()} {line_item_ref} instance(s)') + + +def convert_line_items(apps, schema_editor): + """convert line items""" + _convert_model(apps, 'PurchaseOrderLineItem', 'PurchaseOrderExtraLine', 'purchase_price') + _convert_model(apps, 'SalesOrderLineItem', 'SalesOrderExtraLine', 'sale_price') + + +def nunconvert_line_items(apps, schema_editor): # pragma: no cover + """reconvert line items""" + _reconvert_model(apps, 'PurchaseOrderLineItem', 'PurchaseOrderExtraLine') + _reconvert_model(apps, 'SalesOrderLineItem', 'SalesOrderExtraLine') + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0063_alter_purchaseorderlineitem_unique_together'), + ] + + operations = [ + migrations.CreateModel( + name='SalesOrderExtraLine', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, help_text='Item quantity', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Quantity')), + ('reference', models.CharField(blank=True, help_text='Line item reference', max_length=100, verbose_name='Reference')), + ('notes', models.CharField(blank=True, help_text='Line item notes', max_length=500, verbose_name='Notes')), + ('target_date', models.DateField(blank=True, help_text='Target shipping date for this line item', null=True, verbose_name='Target Date')), + ('context', models.JSONField(blank=True, help_text='Additional context for this line', null=True, verbose_name='Context')), + ('price_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)), + ('price', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=4, default_currency='', help_text='Unit price', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Price')), + ('order', models.ForeignKey(help_text='Sales Order', on_delete=django.db.models.deletion.CASCADE, related_name='extra_lines', to='order.salesorder', verbose_name='Order')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='PurchaseOrderExtraLine', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, help_text='Item quantity', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Quantity')), + ('reference', models.CharField(blank=True, help_text='Line item reference', max_length=100, verbose_name='Reference')), + ('notes', models.CharField(blank=True, help_text='Line item notes', max_length=500, verbose_name='Notes')), + ('target_date', models.DateField(blank=True, help_text='Target shipping date for this line item', null=True, verbose_name='Target Date')), + ('context', models.JSONField(blank=True, help_text='Additional context for this line', null=True, verbose_name='Context')), + ('price_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)), + ('price', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=4, default_currency='', help_text='Unit price', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Price')), + ('order', models.ForeignKey(help_text='Purchase Order', on_delete=django.db.models.deletion.CASCADE, related_name='extra_lines', to='order.purchaseorder', verbose_name='Order')), + ], + options={ + 'abstract': False, + }, + ), + migrations.RunPython(convert_line_items, reverse_code=nunconvert_line_items), + ] diff --git a/InvenTree/order/migrations/0065_alter_purchaseorderlineitem_part.py b/InvenTree/order/migrations/0065_alter_purchaseorderlineitem_part.py new file mode 100644 index 0000000000..033b333f27 --- /dev/null +++ b/InvenTree/order/migrations/0065_alter_purchaseorderlineitem_part.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.12 on 2022-03-28 22:02 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('company', '0042_supplierpricebreak_updated'), + ('order', '0064_purchaseorderextraline_salesorderextraline'), + ] + + operations = [ + migrations.AlterField( + model_name='purchaseorderlineitem', + name='part', + field=models.ForeignKey(help_text='Supplier part', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='purchase_order_line_items', to='company.supplierpart', verbose_name='Part'), + ), + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index f08880a882..c9147bac20 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -5,6 +5,7 @@ Order model definitions # -*- coding: utf-8 -*- import os + from datetime import datetime from decimal import Decimal @@ -21,6 +22,10 @@ from django.utils.translation import ugettext_lazy as _ from markdownx.models import MarkdownxField from mptt.models import TreeForeignKey +from djmoney.contrib.exchange.models import convert_money +from djmoney.money import Money +from common.settings import currency_code_default + from users import models as UserModels from part import models as PartModels from stock import models as stock_models @@ -146,6 +151,25 @@ class Order(ReferenceIndexingMixin): notes = MarkdownxField(blank=True, verbose_name=_('Notes'), help_text=_('Order notes')) + def get_total_price(self): + """ + Calculates the total price of all order lines + """ + target_currency = currency_code_default() + total = Money(0, target_currency) + + # gather name reference + price_ref = 'sale_price' if isinstance(self, SalesOrder) else 'purchase_price' + # order items + total += sum([a.quantity * convert_money(getattr(a, price_ref), target_currency) for a in self.lines.all() if getattr(a, price_ref)]) + + # extra lines + total += sum([a.quantity * convert_money(a.price, target_currency) for a in self.extra_lines.all() if a.price]) + + # set decimal-places + total.decimal_places = 4 + return total + class PurchaseOrder(Order): """ A PurchaseOrder represents goods shipped inwards from an external supplier. @@ -285,7 +309,7 @@ class PurchaseOrder(Order): raise ValidationError({'supplier': _("Part supplier must match PO supplier")}) if group: - # Check if there is already a matching line item (for this PO) + # Check if there is already a matching line item (for this PurchaseOrder) matches = self.lines.filter(part=supplier_part) if matches.count() > 0: @@ -400,7 +424,7 @@ class PurchaseOrder(Order): @transaction.atomic def receive_line_item(self, line, location, quantity, user, status=StockStatus.OK, **kwargs): """ - Receive a line item (or partial line item) against this PO + Receive a line item (or partial line item) against this PurchaseOrder """ # Extract optional batch code for the new stock item @@ -851,12 +875,44 @@ class OrderLineItem(models.Model): ) +class OrderExtraLine(OrderLineItem): + """ + Abstract Model for a single ExtraLine in a Order + Attributes: + price: The unit sale price for this OrderLineItem + """ + + class Meta: + abstract = True + unique_together = [ + ] + + context = models.JSONField( + blank=True, null=True, + verbose_name=_('Context'), + help_text=_('Additional context for this line'), + ) + + price = InvenTreeModelMoneyField( + max_digits=19, + decimal_places=4, + null=True, blank=True, + verbose_name=_('Price'), + help_text=_('Unit price'), + ) + + def price_converted(self): + return convert_money(self.price, currency_code_default()) + + def price_converted_currency(self): + return currency_code_default() + + class PurchaseOrderLineItem(OrderLineItem): """ Model for a purchase order line item. Attributes: order: Reference to a PurchaseOrder object - """ class Meta: @@ -903,11 +959,9 @@ class PurchaseOrderLineItem(OrderLineItem): else: return self.part.part - # TODO - Function callback for when the SupplierPart is deleted? - part = models.ForeignKey( SupplierPart, on_delete=models.SET_NULL, - blank=True, null=True, + blank=False, null=True, related_name='purchase_order_line_items', verbose_name=_('Part'), help_text=_("Supplier part"), @@ -960,6 +1014,21 @@ class PurchaseOrderLineItem(OrderLineItem): return max(r, 0) +class PurchaseOrderExtraLine(OrderExtraLine): + """ + Model for a single ExtraLine in a PurchaseOrder + Attributes: + order: Link to the PurchaseOrder that this line belongs to + title: title of line + price: The unit price for this OrderLine + """ + @staticmethod + def get_api_url(): + return reverse('api-po-extra-line-list') + + order = models.ForeignKey(PurchaseOrder, on_delete=models.CASCADE, related_name='extra_lines', verbose_name=_('Order'), help_text=_('Purchase Order')) + + class SalesOrderLineItem(OrderLineItem): """ Model for a single LineItem in a SalesOrder @@ -1163,6 +1232,21 @@ class SalesOrderShipment(models.Model): trigger_event('salesordershipment.completed', id=self.pk) +class SalesOrderExtraLine(OrderExtraLine): + """ + Model for a single ExtraLine in a SalesOrder + Attributes: + order: Link to the SalesOrder that this line belongs to + title: title of line + price: The unit price for this OrderLine + """ + @staticmethod + def get_api_url(): + return reverse('api-so-extra-line-list') + + order = models.ForeignKey(SalesOrder, on_delete=models.CASCADE, related_name='extra_lines', verbose_name=_('Order'), help_text=_('Sales Order')) + + class SalesOrderAllocation(models.Model): """ This model is used to 'allocate' stock items to a SalesOrder. diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 2f4c1ea5df..98b204612e 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -40,7 +40,64 @@ import stock.serializers from users.serializers import OwnerSerializer -class POSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSerializer): +class AbstractOrderSerializer(serializers.Serializer): + """ + Abstract field definitions for OrderSerializers + """ + total_price = InvenTreeMoneySerializer( + source='get_total_price', + allow_null=True, + read_only=True, + ) + + total_price_string = serializers.CharField(source='get_total_price', read_only=True) + + +class AbstractExtraLineSerializer(serializers.Serializer): + """ Abstract Serializer for a ExtraLine object """ + def __init__(self, *args, **kwargs): + + order_detail = kwargs.pop('order_detail', False) + + super().__init__(*args, **kwargs) + + if order_detail is not True: + self.fields.pop('order_detail') + + quantity = serializers.FloatField() + + price = InvenTreeMoneySerializer( + allow_null=True + ) + + price_string = serializers.CharField(source='price', read_only=True) + + price_currency = serializers.ChoiceField( + choices=currency_code_mappings(), + help_text=_('Price currency'), + ) + + +class AbstractExtraLineMeta: + """ + Abstract Meta for ExtraLine + """ + + fields = [ + 'pk', + 'quantity', + 'reference', + 'notes', + 'context', + 'order', + 'order_detail', + 'price', + 'price_currency', + 'price_string', + ] + + +class PurchaseOrderSerializer(AbstractOrderSerializer, ReferenceIndexingSerializerMixin, InvenTreeModelSerializer): """ Serializer for a PurchaseOrder object """ def __init__(self, *args, **kwargs): @@ -110,6 +167,8 @@ class POSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSerializer): 'status_text', 'target_date', 'notes', + 'total_price', + 'total_price_string', ] read_only_fields = [ @@ -120,7 +179,7 @@ class POSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSerializer): ] -class POLineItemSerializer(InvenTreeModelSerializer): +class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer): @staticmethod def annotate_queryset(queryset): @@ -187,7 +246,7 @@ class POLineItemSerializer(InvenTreeModelSerializer): help_text=_('Purchase price currency'), ) - order_detail = POSerializer(source='order', read_only=True, many=False) + order_detail = PurchaseOrderSerializer(source='order', read_only=True, many=False) class Meta: model = order.models.PurchaseOrderLineItem @@ -214,7 +273,16 @@ class POLineItemSerializer(InvenTreeModelSerializer): ] -class POLineItemReceiveSerializer(serializers.Serializer): +class PurchaseOrderExtraLineSerializer(AbstractExtraLineSerializer, InvenTreeModelSerializer): + """ Serializer for a PurchaseOrderExtraLine object """ + + order_detail = PurchaseOrderSerializer(source='order', many=False, read_only=True) + + class Meta(AbstractExtraLineMeta): + model = order.models.PurchaseOrderExtraLine + + +class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer): """ A serializer for receiving a single purchase order line item against a purchase order """ @@ -344,12 +412,12 @@ class POLineItemReceiveSerializer(serializers.Serializer): return data -class POReceiveSerializer(serializers.Serializer): +class PurchaseOrderReceiveSerializer(serializers.Serializer): """ Serializer for receiving items against a purchase order """ - items = POLineItemReceiveSerializer(many=True) + items = PurchaseOrderLineItemReceiveSerializer(many=True) location = serializers.PrimaryKeyRelatedField( queryset=stock.models.StockLocation.objects.all(), @@ -444,7 +512,7 @@ class POReceiveSerializer(serializers.Serializer): ] -class POAttachmentSerializer(InvenTreeAttachmentSerializer): +class PurchaseOrderAttachmentSerializer(InvenTreeAttachmentSerializer): """ Serializers for the PurchaseOrderAttachment model """ @@ -467,7 +535,7 @@ class POAttachmentSerializer(InvenTreeAttachmentSerializer): ] -class SalesOrderSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSerializer): +class SalesOrderSerializer(AbstractOrderSerializer, ReferenceIndexingSerializerMixin, InvenTreeModelSerializer): """ Serializers for the SalesOrder object """ @@ -535,6 +603,8 @@ class SalesOrderSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSeria 'status_text', 'shipment_date', 'target_date', + 'total_price', + 'total_price_string', ] read_only_fields = [ @@ -612,7 +682,7 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer): ] -class SOLineItemSerializer(InvenTreeModelSerializer): +class SalesOrderLineItemSerializer(InvenTreeModelSerializer): """ Serializer for a SalesOrderLineItem object """ @staticmethod @@ -862,7 +932,7 @@ class SalesOrderCompleteSerializer(serializers.Serializer): order.complete_order(user) -class SOSerialAllocationSerializer(serializers.Serializer): +class SalesOrderSerialAllocationSerializer(serializers.Serializer): """ DRF serializer for allocation of serial numbers against a sales order / shipment """ @@ -1025,7 +1095,7 @@ class SOSerialAllocationSerializer(serializers.Serializer): ) -class SOShipmentAllocationSerializer(serializers.Serializer): +class SalesOrderShipmentAllocationSerializer(serializers.Serializer): """ DRF serializer for allocation of stock items against a sales order / shipment """ @@ -1099,7 +1169,16 @@ class SOShipmentAllocationSerializer(serializers.Serializer): ) -class SOAttachmentSerializer(InvenTreeAttachmentSerializer): +class SalesOrderExtraLineSerializer(AbstractExtraLineSerializer, InvenTreeModelSerializer): + """ Serializer for a SalesOrderExtraLine object """ + + order_detail = SalesOrderSerializer(source='order', many=False, read_only=True) + + class Meta(AbstractExtraLineMeta): + model = order.models.SalesOrderExtraLine + + +class SalesOrderAttachmentSerializer(InvenTreeAttachmentSerializer): """ Serializers for the SalesOrderAttachment model """ diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html index e30fc838e4..c2aa10f722 100644 --- a/InvenTree/order/templates/order/order_base.html +++ b/InvenTree/order/templates/order/order_base.html @@ -171,6 +171,12 @@ src="{% static 'img/blank_image.png' %}" {{ order.responsible }} {% endif %} + + + + {% trans "Total cost" %} + {{ order.get_total_price }} + {% endblock %} diff --git a/InvenTree/order/templates/order/purchase_order_detail.html b/InvenTree/order/templates/order/purchase_order_detail.html index a6393b7b68..23bdd908e1 100644 --- a/InvenTree/order/templates/order/purchase_order_detail.html +++ b/InvenTree/order/templates/order/purchase_order_detail.html @@ -42,6 +42,29 @@
+ +
+
+

{% trans "Extra Lines" %}

+ {% include "spacer.html" %} +
+ {% if roles.purchase_order.change and order.status == PurchaseOrderStatus.PENDING %} + + {% endif %} +
+
+
+
+
+
+ {% include "filter_list.html" with id="purchase-order-extra-lines" %} +
+
+ +
+
@@ -200,6 +223,37 @@ loadPurchaseOrderLineItemTable('#po-line-table', { {% endif %} }); +$("#new-po-extra-line").click(function() { + + var fields = extraLineFields({ + order: {{ order.pk }}, + }); + + constructForm('{% url "api-po-extra-line-list" %}', { + fields: fields, + method: 'POST', + title: '{% trans "Add Order Line" %}', + onSuccess: function() { + $("#po-extra-lines-table").bootstrapTable("refresh"); + }, + }); +}); + +loadPurchaseOrderExtraLineTable( + '#po-extra-lines-table', + { + order: {{ order.pk }}, + status: {{ order.status }}, + } +); + +loadOrderTotal( + '#poTotalPrice', + { + url: '{% url "api-po-detail" order.pk %}', + } +); + enableSidebar('purchaseorder'); {% endblock %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/sales_order_base.html b/InvenTree/order/templates/order/sales_order_base.html index 423090f917..9abd058996 100644 --- a/InvenTree/order/templates/order/sales_order_base.html +++ b/InvenTree/order/templates/order/sales_order_base.html @@ -183,6 +183,12 @@ src="{% static 'img/blank_image.png' %}" {{ order.responsible }} {% endif %} + + + + {% trans "Total cost" %} + {{ order.get_total_price }} + {% endblock %} diff --git a/InvenTree/order/templates/order/sales_order_detail.html b/InvenTree/order/templates/order/sales_order_detail.html index f11d1fa832..628e510d37 100644 --- a/InvenTree/order/templates/order/sales_order_detail.html +++ b/InvenTree/order/templates/order/sales_order_detail.html @@ -34,6 +34,29 @@
+ +
+
+

{% trans "Extra Lines" %}

+ {% include "spacer.html" %} +
+ {% if roles.sales_order.change and order.is_pending %} + + {% endif %} +
+
+
+
+
+
+ {% include "filter_list.html" with id="sales-order-extra-lines" %} +
+
+ +
+
{% if order.is_pending %} @@ -238,6 +261,37 @@ } ); + $("#new-so-extra-line").click(function() { + + var fields = extraLineFields({ + order: {{ order.pk }}, + }); + + constructForm('{% url "api-so-extra-line-list" %}', { + fields: fields, + method: 'POST', + title: '{% trans "Add Extra Line" %}', + onSuccess: function() { + $("#so-extra-lines-table").bootstrapTable("refresh"); + }, + }); + }); + + loadSalesOrderExtraLineTable( + '#so-extra-lines-table', + { + order: {{ order.pk }}, + status: {{ order.status }}, + } + ); + + loadOrderTotal( + '#soTotalPrice', + { + url: '{% url "api-so-detail" order.pk %}', + } + ); + enableSidebar('salesorder'); {% endblock %} \ No newline at end of file diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index 8d5d91f0fb..d3e405e5fa 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -63,7 +63,7 @@ class PurchaseOrderTest(OrderTest): def test_po_list(self): - # List *ALL* PO items + # List *ALL* PurchaseOrder items self.filter({}, 7) # Filter by supplier @@ -175,7 +175,7 @@ class PurchaseOrderTest(OrderTest): pk = response.data['pk'] - # Try to create a PO with identical reference (should fail!) + # Try to create a PurchaseOrder with identical reference (should fail!) response = self.post( url, { @@ -493,7 +493,7 @@ class PurchaseOrderReceiveTest(OrderTest): self.assertIn('can only be received against', str(response.data)) - # Now, set the PO back to "PLACED" so the items can be received + # Now, set the PurchaseOrder back to "PLACED" so the items can be received order.status = PurchaseOrderStatus.PLACED order.save() diff --git a/InvenTree/order/test_migrations.py b/InvenTree/order/test_migrations.py index 3afba65223..61299a8e2f 100644 --- a/InvenTree/order/test_migrations.py +++ b/InvenTree/order/test_migrations.py @@ -122,3 +122,97 @@ class TestShipmentMigration(MigratorTestCase): # Check that the correct number of Shipments have been created self.assertEqual(SalesOrder.objects.count(), 5) self.assertEqual(Shipment.objects.count(), 5) + + +class TestAdditionalLineMigration(MigratorTestCase): + """ + Test entire schema migration + """ + + migrate_from = ('order', '0063_alter_purchaseorderlineitem_unique_together') + migrate_to = ('order', '0064_purchaseorderextraline_salesorderextraline') + + def prepare(self): + """ + Create initial data set + """ + + # Create a purchase order from a supplier + Company = self.old_state.apps.get_model('company', 'company') + PurchaseOrder = self.old_state.apps.get_model('order', 'purchaseorder') + Part = self.old_state.apps.get_model('part', 'part') + Supplierpart = self.old_state.apps.get_model('company', 'supplierpart') + # TODO @matmair fix this test!!! + # SalesOrder = self.old_state.apps.get_model('order', 'salesorder') + + supplier = Company.objects.create( + name='Supplier A', + description='A great supplier!', + is_supplier=True, + is_customer=True, + ) + + part = Part.objects.create( + name='Bob', + description='Can we build it?', + assembly=True, + salable=True, + purchaseable=False, + tree_id=0, + level=0, + lft=0, + rght=0, + ) + supplierpart = Supplierpart.objects.create( + part=part, + supplier=supplier + ) + + # Create some orders + for ii in range(10): + + order = PurchaseOrder.objects.create( + supplier=supplier, + reference=f"{ii}-abcde", + description="Just a test order" + ) + order.lines.create( + part=supplierpart, + quantity=12, + received=1 + ) + order.lines.create( + quantity=12, + received=1 + ) + + # TODO @matmair fix this test!!! + # sales_order = SalesOrder.objects.create( + # customer=supplier, + # reference=f"{ii}-xyz", + # description="A test sales order", + # ) + # sales_order.lines.create( + # part=part, + # quantity=12, + # received=1 + # ) + + def test_po_migration(self): + """ + Test that the the PO lines where converted correctly + """ + + PurchaseOrder = self.new_state.apps.get_model('order', 'purchaseorder') + for ii in range(10): + + po = PurchaseOrder.objects.get(reference=f"{ii}-abcde") + self.assertEqual(po.extra_lines.count(), 1) + self.assertEqual(po.lines.count(), 1) + + # TODO @matmair fix this test!!! + # SalesOrder = self.new_state.apps.get_model('order', 'salesorder') + # for ii in range(10): + # so = SalesOrder.objects.get(reference=f"{ii}-xyz") + # self.assertEqual(so.extra_lines, 1) + # self.assertEqual(so.lines.count(), 1) diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index c89d2a77b1..69d42b9594 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -20,7 +20,7 @@ from decimal import Decimal, InvalidOperation from .models import PurchaseOrder, PurchaseOrderLineItem from .models import SalesOrder, SalesOrderLineItem -from .admin import POLineItemResource, SOLineItemResource +from .admin import PurchaseOrderLineItemResource, SalesOrderLineItemResource from build.models import Build from company.models import Company, SupplierPart # ManufacturerPart from stock.models import StockItem @@ -410,7 +410,7 @@ class SalesOrderExport(AjaxView): filename = f"{str(order)} - {order.customer.name}.{export_format}" - dataset = SOLineItemResource().export(queryset=order.lines.all()) + dataset = SalesOrderLineItemResource().export(queryset=order.lines.all()) filedata = dataset.export(format=export_format) @@ -441,7 +441,7 @@ class PurchaseOrderExport(AjaxView): fmt=export_format ) - dataset = POLineItemResource().export(queryset=order.lines.all()) + dataset = PurchaseOrderLineItemResource().export(queryset=order.lines.all()) filedata = dataset.export(format=export_format) @@ -491,7 +491,7 @@ class OrderParts(AjaxView): return data def get_suppliers(self): - """ Calculates a list of suppliers which the user will need to create POs for. + """ Calculates a list of suppliers which the user will need to create PurchaseOrders for. This is calculated AFTER the user finishes selecting the parts to order. Crucially, get_parts() must be called before get_suppliers() """ diff --git a/InvenTree/report/api.py b/InvenTree/report/api.py index c7d2b15e4d..0484cfbc06 100644 --- a/InvenTree/report/api.py +++ b/InvenTree/report/api.py @@ -31,8 +31,8 @@ from .models import SalesOrderReport from .serializers import TestReportSerializer from .serializers import BuildReportSerializer from .serializers import BOMReportSerializer -from .serializers import POReportSerializer -from .serializers import SOReportSerializer +from .serializers import PurchaseOrderReportSerializer +from .serializers import SalesOrderReportSerializer class ReportListView(generics.ListAPIView): @@ -561,12 +561,12 @@ class BuildReportPrint(generics.RetrieveAPIView, BuildReportMixin, ReportPrintMi return self.print(request, builds) -class POReportList(ReportListView, OrderReportMixin): +class PurchaseOrderReportList(ReportListView, OrderReportMixin): OrderModel = order.models.PurchaseOrder queryset = PurchaseOrderReport.objects.all() - serializer_class = POReportSerializer + serializer_class = PurchaseOrderReportSerializer def filter_queryset(self, queryset): @@ -618,16 +618,16 @@ class POReportList(ReportListView, OrderReportMixin): return queryset -class POReportDetail(generics.RetrieveUpdateDestroyAPIView): +class PurchaseOrderReportDetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for a single PurchaseOrderReport object """ queryset = PurchaseOrderReport.objects.all() - serializer_class = POReportSerializer + serializer_class = PurchaseOrderReportSerializer -class POReportPrint(generics.RetrieveAPIView, OrderReportMixin, ReportPrintMixin): +class PurchaseOrderReportPrint(generics.RetrieveAPIView, OrderReportMixin, ReportPrintMixin): """ API endpoint for printing a PurchaseOrderReport object """ @@ -635,7 +635,7 @@ class POReportPrint(generics.RetrieveAPIView, OrderReportMixin, ReportPrintMixin OrderModel = order.models.PurchaseOrder queryset = PurchaseOrderReport.objects.all() - serializer_class = POReportSerializer + serializer_class = PurchaseOrderReportSerializer def get(self, request, *args, **kwargs): @@ -644,12 +644,12 @@ class POReportPrint(generics.RetrieveAPIView, OrderReportMixin, ReportPrintMixin return self.print(request, orders) -class SOReportList(ReportListView, OrderReportMixin): +class SalesOrderReportList(ReportListView, OrderReportMixin): OrderModel = order.models.SalesOrder queryset = SalesOrderReport.objects.all() - serializer_class = SOReportSerializer + serializer_class = SalesOrderReportSerializer def filter_queryset(self, queryset): @@ -701,16 +701,16 @@ class SOReportList(ReportListView, OrderReportMixin): return queryset -class SOReportDetail(generics.RetrieveUpdateDestroyAPIView): +class SalesOrderReportDetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for a single SalesOrderReport object """ queryset = SalesOrderReport.objects.all() - serializer_class = SOReportSerializer + serializer_class = SalesOrderReportSerializer -class SOReportPrint(generics.RetrieveAPIView, OrderReportMixin, ReportPrintMixin): +class SalesOrderReportPrint(generics.RetrieveAPIView, OrderReportMixin, ReportPrintMixin): """ API endpoint for printing a PurchaseOrderReport object """ @@ -718,7 +718,7 @@ class SOReportPrint(generics.RetrieveAPIView, OrderReportMixin, ReportPrintMixin OrderModel = order.models.SalesOrder queryset = SalesOrderReport.objects.all() - serializer_class = SOReportSerializer + serializer_class = SalesOrderReportSerializer def get(self, request, *args, **kwargs): @@ -733,23 +733,23 @@ report_api_urls = [ url(r'po/', include([ # Detail views url(r'^(?P\d+)/', include([ - url(r'print/', POReportPrint.as_view(), name='api-po-report-print'), - url(r'^$', POReportDetail.as_view(), name='api-po-report-detail'), + url(r'print/', PurchaseOrderReportPrint.as_view(), name='api-po-report-print'), + url(r'^$', PurchaseOrderReportDetail.as_view(), name='api-po-report-detail'), ])), # List view - url(r'^$', POReportList.as_view(), name='api-po-report-list'), + url(r'^$', PurchaseOrderReportList.as_view(), name='api-po-report-list'), ])), # Sales order reports url(r'so/', include([ # Detail views url(r'^(?P\d+)/', include([ - url(r'print/', SOReportPrint.as_view(), name='api-so-report-print'), - url(r'^$', SOReportDetail.as_view(), name='api-so-report-detail'), + url(r'print/', SalesOrderReportPrint.as_view(), name='api-so-report-print'), + url(r'^$', SalesOrderReportDetail.as_view(), name='api-so-report-detail'), ])), - url(r'^$', SOReportList.as_view(), name='api-so-report-list'), + url(r'^$', SalesOrderReportList.as_view(), name='api-so-report-list'), ])), # Build reports diff --git a/InvenTree/report/models.py b/InvenTree/report/models.py index 3ee19bd5e6..32ba9077b1 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -466,6 +466,7 @@ class PurchaseOrderReport(ReportTemplateBase): return { 'description': order.description, 'lines': order.lines, + 'extra_lines': order.extra_lines, 'order': order, 'reference': order.reference, 'supplier': order.supplier, @@ -505,6 +506,7 @@ class SalesOrderReport(ReportTemplateBase): 'customer': order.customer, 'description': order.description, 'lines': order.lines, + 'extra_lines': order.extra_lines, 'order': order, 'prefix': common.models.InvenTreeSetting.get_setting('SALESORDER_REFERENCE_PREFIX'), 'reference': order.reference, diff --git a/InvenTree/report/serializers.py b/InvenTree/report/serializers.py index fa7de1a3ea..6e3e36df18 100644 --- a/InvenTree/report/serializers.py +++ b/InvenTree/report/serializers.py @@ -58,7 +58,7 @@ class BOMReportSerializer(InvenTreeModelSerializer): ] -class POReportSerializer(InvenTreeModelSerializer): +class PurchaseOrderReportSerializer(InvenTreeModelSerializer): template = InvenTreeAttachmentSerializerField(required=True) @@ -74,7 +74,7 @@ class POReportSerializer(InvenTreeModelSerializer): ] -class SOReportSerializer(InvenTreeModelSerializer): +class SalesOrderReportSerializer(InvenTreeModelSerializer): template = InvenTreeAttachmentSerializerField(required=True) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index c88c29e64e..52c159a7ff 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -38,7 +38,7 @@ from InvenTree.filters import InvenTreeOrderingFilter from order.models import PurchaseOrder from order.models import SalesOrder, SalesOrderAllocation -from order.serializers import POSerializer +from order.serializers import PurchaseOrderSerializer from part.models import BomItem, Part, PartCategory from part.serializers import PartBriefSerializer @@ -1315,7 +1315,7 @@ class StockTrackingList(generics.ListAPIView): if 'purchaseorder' in deltas: try: order = PurchaseOrder.objects.get(pk=deltas['purchaseorder']) - serializer = POSerializer(order) + serializer = PurchaseOrderSerializer(order) deltas['purchaseorder_detail'] = serializer.data except: pass diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 9d66fa2ab4..c971a4d694 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -26,15 +26,19 @@ editPurchaseOrderLineItem, exportOrder, loadPurchaseOrderLineItemTable, + loadPurchaseOrderExtraLineTable loadPurchaseOrderTable, loadSalesOrderAllocationTable, loadSalesOrderLineItemTable, + loadSalesOrderExtraLineTable loadSalesOrderShipmentTable, loadSalesOrderTable, newPurchaseOrderFromOrderWizard, newSupplierPartFromOrderWizard, removeOrderRowFromOrderWizard, removePurchaseOrderLineItem, + loadOrderTotal, + extraLineFields, */ @@ -272,7 +276,7 @@ function createPurchaseOrder(options={}) { if (options.onSuccess) { options.onSuccess(data); } else { - // Default action is to redirect browser to the new PO + // Default action is to redirect browser to the new PurchaseOrder location.href = `/order/purchase-order/${data.pk}/`; } }, @@ -305,6 +309,28 @@ function soLineItemFields(options={}) { } +/* Construct a set of fields for a OrderExtraLine form */ +function extraLineFields(options={}) { + + var fields = { + order: { + hidden: true, + }, + quantity: {}, + reference: {}, + price: {}, + price_currency: {}, + notes: {}, + }; + + if (options.order) { + fields.order.value = options.order; + } + + return fields; +} + + /* Construct a set of fields for the PurchaseOrderLineItem form */ function poLineItemFields(options={}) { @@ -502,7 +528,7 @@ function newPurchaseOrderFromOrderWizard(e) { /** * Receive stock items against a PurchaseOrder - * Uses the POReceive API endpoint + * Uses the PurchaseOrderReceive API endpoint * * arguments: * - order_id, ID / PK for the PurchaseOrder instance @@ -1373,6 +1399,226 @@ function loadPurchaseOrderLineItemTable(table, options={}) { } +/** + * Load a table displaying lines for a particular PurchaseOrder + * + * @param {String} table : HTML ID tag e.g. '#table' + * @param {Object} options : object which contains: + * - order {integer} : pk of the PurchaseOrder + * - status: {integer} : status code for the order + */ +function loadPurchaseOrderExtraLineTable(table, options={}) { + + options.table = table; + + options.params = options.params || {}; + + if (!options.order) { + console.log('ERROR: function called without order ID'); + return; + } + + if (!options.status) { + console.log('ERROR: function called without order status'); + return; + } + + options.params.order = options.order; + options.params.part_detail = true; + options.params.allocations = true; + + var filters = loadTableFilters('purchaseorderextraline'); + + for (var key in options.params) { + filters[key] = options.params[key]; + } + + options.url = options.url || '{% url "api-po-extra-line-list" %}'; + + var filter_target = options.filter_target || '#filter-list-purchase-order-extra-lines'; + + setupFilterList('purchaseorderextraline', $(table), filter_target); + + // Is the order pending? + var pending = options.status == {{ SalesOrderStatus.PENDING }}; + + // Table columns to display + var columns = [ + { + sortable: true, + field: 'reference', + title: '{% trans "Reference" %}', + switchable: true, + }, + { + sortable: true, + field: 'quantity', + title: '{% trans "Quantity" %}', + footerFormatter: function(data) { + return data.map(function(row) { + return +row['quantity']; + }).reduce(function(sum, i) { + return sum + i; + }, 0); + }, + switchable: false, + }, + { + sortable: true, + field: 'price', + title: '{% trans "Unit Price" %}', + formatter: function(value, row) { + var formatter = new Intl.NumberFormat( + 'en-US', + { + style: 'currency', + currency: row.price_currency + } + ); + + return formatter.format(row.price); + } + }, + { + field: 'total_price', + sortable: true, + title: '{% trans "Total Price" %}', + formatter: function(value, row) { + var formatter = new Intl.NumberFormat( + 'en-US', + { + style: 'currency', + currency: row.price_currency + } + ); + + return formatter.format(row.price * row.quantity); + }, + footerFormatter: function(data) { + var total = data.map(function(row) { + return +row['price'] * row['quantity']; + }).reduce(function(sum, i) { + return sum + i; + }, 0); + + var currency = (data.slice(-1)[0] && data.slice(-1)[0].price_currency) || 'USD'; + + var formatter = new Intl.NumberFormat( + 'en-US', + { + style: 'currency', + currency: currency + } + ); + + return formatter.format(total); + } + } + ]; + + columns.push({ + field: 'notes', + title: '{% trans "Notes" %}', + }); + + if (pending) { + columns.push({ + field: 'buttons', + switchable: false, + formatter: function(value, row, index, field) { + + var html = `
`; + + var pk = row.pk; + + html += makeIconButton('fa-clone', 'button-duplicate', pk, '{% trans "Duplicate line" %}'); + html += makeIconButton('fa-edit icon-blue', 'button-edit', pk, '{% trans "Edit line" %}'); + html += makeIconButton('fa-trash-alt icon-red', 'button-delete', pk, '{% trans "Delete line" %}', ); + + html += `
`; + + return html; + } + }); + } + + function reloadTable() { + $(table).bootstrapTable('refresh'); + reloadTotal(); + } + + // Configure callback functions once the table is loaded + function setupCallbacks() { + + // Callback for duplicating lines + $(table).find('.button-duplicate').click(function() { + var pk = $(this).attr('pk'); + + inventreeGet(`/api/order/po-extra-line/${pk}/`, {}, { + success: function(data) { + + var fields = extraLineFields(); + + constructForm('{% url "api-po-extra-line-list" %}', { + method: 'POST', + fields: fields, + data: data, + title: '{% trans "Duplicate Line" %}', + onSuccess: function(response) { + $(table).bootstrapTable('refresh'); + } + }); + } + }); + }); + + // Callback for editing lines + $(table).find('.button-edit').click(function() { + var pk = $(this).attr('pk'); + + constructForm(`/api/order/po-extra-line/${pk}/`, { + fields: { + quantity: {}, + reference: {}, + price: {}, + price_currency: {}, + notes: {}, + }, + title: '{% trans "Edit Line" %}', + onSuccess: reloadTable, + }); + }); + + // Callback for deleting lines + $(table).find('.button-delete').click(function() { + var pk = $(this).attr('pk'); + + constructForm(`/api/order/po-extra-line/${pk}/`, { + method: 'DELETE', + title: '{% trans "Delete Line" %}', + onSuccess: reloadTable, + }); + }); + } + + $(table).inventreeTable({ + onPostBody: setupCallbacks, + name: 'purchaseorderextraline', + sidePagination: 'client', + formatNoMatches: function() { + return '{% trans "No matching line" %}'; + }, + queryParams: filters, + original: options.params, + url: options.url, + showFooter: true, + uniqueId: 'pk', + detailViewByClick: false, + columns: columns, + }); +} + + /* * Load table displaying list of sales orders */ @@ -2259,6 +2505,26 @@ function showFulfilledSubTable(index, row, element, options) { }); } +var TotalPriceRef = ''; // reference to total price field +var TotalPriceOptions = {}; // options to reload the price + +function loadOrderTotal(reference, options={}) { + TotalPriceRef = reference; + TotalPriceOptions = options; +} + +function reloadTotal() { + inventreeGet( + TotalPriceOptions.url, + {}, + { + success: function(data) { + $(TotalPriceRef).html(data.total_price_string); + } + } + ); +}; + /** * Load a table displaying line items for a particular SalesOrder @@ -2556,6 +2822,7 @@ function loadSalesOrderLineItemTable(table, options={}) { function reloadTable() { $(table).bootstrapTable('refresh'); + reloadTotal(); } // Configure callback functions once the table is loaded @@ -2765,3 +3032,223 @@ function loadSalesOrderLineItemTable(table, options={}) { columns: columns, }); } + + +/** + * Load a table displaying lines for a particular SalesOrder + * + * @param {String} table : HTML ID tag e.g. '#table' + * @param {Object} options : object which contains: + * - order {integer} : pk of the SalesOrder + * - status: {integer} : status code for the order + */ +function loadSalesOrderExtraLineTable(table, options={}) { + + options.table = table; + + options.params = options.params || {}; + + if (!options.order) { + console.log('ERROR: function called without order ID'); + return; + } + + if (!options.status) { + console.log('ERROR: function called without order status'); + return; + } + + options.params.order = options.order; + options.params.part_detail = true; + options.params.allocations = true; + + var filters = loadTableFilters('salesorderextraline'); + + for (var key in options.params) { + filters[key] = options.params[key]; + } + + options.url = options.url || '{% url "api-so-extra-line-list" %}'; + + var filter_target = options.filter_target || '#filter-list-sales-order-extra-lines'; + + setupFilterList('salesorderextraline', $(table), filter_target); + + // Is the order pending? + var pending = options.status == {{ SalesOrderStatus.PENDING }}; + + // Table columns to display + var columns = [ + { + sortable: true, + field: 'reference', + title: '{% trans "Reference" %}', + switchable: true, + }, + { + sortable: true, + field: 'quantity', + title: '{% trans "Quantity" %}', + footerFormatter: function(data) { + return data.map(function(row) { + return +row['quantity']; + }).reduce(function(sum, i) { + return sum + i; + }, 0); + }, + switchable: false, + }, + { + sortable: true, + field: 'price', + title: '{% trans "Unit Price" %}', + formatter: function(value, row) { + var formatter = new Intl.NumberFormat( + 'en-US', + { + style: 'currency', + currency: row.price_currency + } + ); + + return formatter.format(row.price); + } + }, + { + field: 'total_price', + sortable: true, + title: '{% trans "Total Price" %}', + formatter: function(value, row) { + var formatter = new Intl.NumberFormat( + 'en-US', + { + style: 'currency', + currency: row.price_currency + } + ); + + return formatter.format(row.price * row.quantity); + }, + footerFormatter: function(data) { + var total = data.map(function(row) { + return +row['price'] * row['quantity']; + }).reduce(function(sum, i) { + return sum + i; + }, 0); + + var currency = (data.slice(-1)[0] && data.slice(-1)[0].price_currency) || 'USD'; + + var formatter = new Intl.NumberFormat( + 'en-US', + { + style: 'currency', + currency: currency + } + ); + + return formatter.format(total); + } + } + ]; + + columns.push({ + field: 'notes', + title: '{% trans "Notes" %}', + }); + + if (pending) { + columns.push({ + field: 'buttons', + switchable: false, + formatter: function(value, row, index, field) { + + var html = `
`; + + var pk = row.pk; + + html += makeIconButton('fa-clone', 'button-duplicate', pk, '{% trans "Duplicate line" %}'); + html += makeIconButton('fa-edit icon-blue', 'button-edit', pk, '{% trans "Edit line" %}'); + html += makeIconButton('fa-trash-alt icon-red', 'button-delete', pk, '{% trans "Delete line" %}', ); + + html += `
`; + + return html; + } + }); + } + + function reloadTable() { + $(table).bootstrapTable('refresh'); + reloadTotal(); + } + + // Configure callback functions once the table is loaded + function setupCallbacks() { + + // Callback for duplicating lines + $(table).find('.button-duplicate').click(function() { + var pk = $(this).attr('pk'); + + inventreeGet(`/api/order/so-extra-line/${pk}/`, {}, { + success: function(data) { + + var fields = extraLineFields(); + + constructForm('{% url "api-so-extra-line-list" %}', { + method: 'POST', + fields: fields, + data: data, + title: '{% trans "Duplicate Line" %}', + onSuccess: function(response) { + $(table).bootstrapTable('refresh'); + } + }); + } + }); + }); + + // Callback for editing lines + $(table).find('.button-edit').click(function() { + var pk = $(this).attr('pk'); + + constructForm(`/api/order/so-extra-line/${pk}/`, { + fields: { + quantity: {}, + reference: {}, + price: {}, + price_currency: {}, + notes: {}, + }, + title: '{% trans "Edit Line" %}', + onSuccess: reloadTable, + }); + }); + + // Callback for deleting lines + $(table).find('.button-delete').click(function() { + var pk = $(this).attr('pk'); + + constructForm(`/api/order/so-extra-line/${pk}/`, { + method: 'DELETE', + title: '{% trans "Delete Line" %}', + onSuccess: reloadTable, + }); + }); + } + + $(table).inventreeTable({ + onPostBody: setupCallbacks, + name: 'salesorderextraline', + sidePagination: 'client', + formatNoMatches: function() { + return '{% trans "No matching lines" %}'; + }, + queryParams: filters, + original: options.params, + url: options.url, + showFooter: true, + uniqueId: 'pk', + detailViewByClick: false, + columns: columns, + }); +} diff --git a/InvenTree/templates/js/translated/report.js b/InvenTree/templates/js/translated/report.js index 4f887f2275..49afbcb7ea 100644 --- a/InvenTree/templates/js/translated/report.js +++ b/InvenTree/templates/js/translated/report.js @@ -271,7 +271,7 @@ function printBomReports(parts) { function printPurchaseOrderReports(orders) { /** - * Print PO reports for the provided purchase order(s) + * Print PurchaseOrder reports for the provided purchase order(s) */ if (orders.length == 0) { @@ -325,7 +325,7 @@ function printPurchaseOrderReports(orders) { function printSalesOrderReports(orders) { /** - * Print SO reports for the provided purchase order(s) + * Print SalesOrder reports for the provided purchase order(s) */ if (orders.length == 0) { diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index b29341d1b8..bda1074601 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -132,6 +132,7 @@ class RuleSet(models.Model): 'order_purchaseorder', 'order_purchaseorderattachment', 'order_purchaseorderlineitem', + 'order_purchaseorderextraline', 'company_supplierpart', 'company_manufacturerpart', 'company_manufacturerpartparameter', @@ -142,6 +143,7 @@ class RuleSet(models.Model): 'order_salesorderallocation', 'order_salesorderattachment', 'order_salesorderlineitem', + 'order_salesorderextraline', 'order_salesordershipment', ] }