Merge pull request #2714 from matmair/matmair/issue2694

[FR] Add delivery cost (excluding unit cost that already exists) in PO
This commit is contained in:
Oliver 2022-05-01 10:28:47 +10:00 committed by GitHub
commit 433ecdb2c3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1278 additions and 135 deletions

View File

@ -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.
"""

View File

@ -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)

View File

@ -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<pk>\d+)/$', POAttachmentDetail.as_view(), name='api-po-attachment-detail'),
url(r'^.*$', POAttachmentList.as_view(), name='api-po-attachment-list'),
url(r'^(?P<pk>\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<pk>\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<pk>\d+)/$', POLineItemDetail.as_view(), name='api-po-line-detail'),
url(r'^.*$', POLineItemList.as_view(), name='api-po-line-list'),
url(r'^(?P<pk>\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<pk>\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<pk>\d+)/$', SOAttachmentDetail.as_view(), name='api-so-attachment-detail'),
url(r'^.*$', SOAttachmentList.as_view(), name='api-so-attachment-list'),
url(r'^(?P<pk>\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<pk>\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<pk>\d+)/$', SOLineItemDetail.as_view(), name='api-so-line-detail'),
url(r'^$', SOLineItemList.as_view(), name='api-so-line-list'),
url(r'^(?P<pk>\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<pk>\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<pk>\d+)/$', SOAllocationDetail.as_view(), name='api-so-allocation-detail'),
url(r'^.*$', SOAllocationList.as_view(), name='api-so-allocation-list'),
url(r'^(?P<pk>\d+)/$', SalesOrderAllocationDetail.as_view(), name='api-so-allocation-detail'),
url(r'^.*$', SalesOrderAllocationList.as_view(), name='api-so-allocation-list'),
])),
]

View File

@ -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),
]

View File

@ -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'),
),
]

View File

@ -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.

View File

@ -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
"""

View File

@ -171,6 +171,12 @@ src="{% static 'img/blank_image.png' %}"
<td>{{ order.responsible }}</td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-dollar-sign'></span></td>
<td>{% trans "Total cost" %}</td>
<td id="poTotalPrice">{{ order.get_total_price }}</td>
</tr>
</table>
{% endblock %}

View File

@ -42,6 +42,29 @@
<table class='table table-striped table-condensed' id='po-line-table' data-toolbar='#order-toolbar-buttons'>
</table>
</div>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Extra Lines" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% if roles.purchase_order.change and order.status == PurchaseOrderStatus.PENDING %}
<button type='button' class='btn btn-success' id='new-po-extra-line'>
<span class='fas fa-plus-circle'></span> {% trans "Add Extra Line" %}
</button>
{% endif %}
</div>
</div>
</div>
<div class='panel-content'>
<div id='order-extra-toolbar-buttons' class='btn-group' style='float: right;'>
<div class='btn-group'>
{% include "filter_list.html" with id="purchase-order-extra-lines" %}
</div>
</div>
<table class='table table-striped table-condensed' id='po-extra-lines-table' data-toolbar='#order-extra-toolbar-buttons'>
</table>
</div>
</div>
<div class='panel panel-hidden' id='panel-received-items'>
@ -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 %}

View File

@ -183,6 +183,12 @@ src="{% static 'img/blank_image.png' %}"
<td>{{ order.responsible }}</td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-dollar-sign'></span></td>
<td>{% trans "Total cost" %}</td>
<td id="soTotalPrice">{{ order.get_total_price }}</td>
</tr>
</table>
{% endblock %}

View File

@ -34,6 +34,29 @@
<table class='table table-striped table-condensed' id='so-lines-table' data-toolbar='#order-toolbar-buttons'>
</table>
</div>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Extra Lines" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% if roles.sales_order.change and order.is_pending %}
<button type='button' class='btn btn-success' id='new-so-extra-line'>
<span class='fas fa-plus-circle'></span> {% trans "Add Extra Line" %}
</button>
{% endif %}
</div>
</div>
</div>
<div class='panel-content'>
<div id='order-extra-toolbar-buttons' class='btn-group' style='float: right;'>
<div class='btn-group'>
{% include "filter_list.html" with id="sales-order-extra-lines" %}
</div>
</div>
<table class='table table-striped table-condensed' id='so-extra-lines-table' data-toolbar='#order-extra-toolbar-buttons'>
</table>
</div>
</div>
{% 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 %}

View File

@ -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()

View File

@ -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)

View File

@ -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()
"""

View File

@ -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<pk>\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<pk>\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

View File

@ -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,

View File

@ -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)

View File

@ -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

View File

@ -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 = `<div class='btn-group float-right' role='group'>`;
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 += `</div>`;
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 = `<div class='btn-group float-right' role='group'>`;
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 += `</div>`;
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,
});
}

View File

@ -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) {

View File

@ -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',
]
}