mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge pull request #2199 from SchrodingersGat/partial-shipment
Create SalesOrderShipment model
This commit is contained in:
commit
3f2cdf9dc4
@ -12,10 +12,13 @@ import common.models
|
||||
INVENTREE_SW_VERSION = "0.6.0 dev"
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 20
|
||||
INVENTREE_API_VERSION = 21
|
||||
|
||||
"""
|
||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||
v21 -> 2021-12-04
|
||||
- Adds support for multiple "Shipments" against a SalesOrder
|
||||
- Refactors process for stock allocation against a SalesOrder
|
||||
|
||||
v20 -> 2021-12-03
|
||||
- Adds ability to filter POLineItem endpoint by "base_part"
|
||||
|
@ -423,7 +423,7 @@ class BuildAllocationSerializer(serializers.Serializer):
|
||||
Validation
|
||||
"""
|
||||
|
||||
super().validate(data)
|
||||
data = super().validate(data)
|
||||
|
||||
items = data.get('items', [])
|
||||
|
||||
|
@ -10,7 +10,7 @@ from import_export.fields import Field
|
||||
|
||||
from .models import PurchaseOrder, PurchaseOrderLineItem
|
||||
from .models import SalesOrder, SalesOrderLineItem
|
||||
from .models import SalesOrderAllocation
|
||||
from .models import SalesOrderShipment, SalesOrderAllocation
|
||||
|
||||
|
||||
class PurchaseOrderLineItemInlineAdmin(admin.StackedInline):
|
||||
@ -137,6 +137,15 @@ class SalesOrderLineItemAdmin(ImportExportModelAdmin):
|
||||
)
|
||||
|
||||
|
||||
class SalesOrderShipmentAdmin(ImportExportModelAdmin):
|
||||
|
||||
list_display = [
|
||||
'order',
|
||||
'shipment_date',
|
||||
'reference',
|
||||
]
|
||||
|
||||
|
||||
class SalesOrderAllocationAdmin(ImportExportModelAdmin):
|
||||
|
||||
list_display = (
|
||||
@ -152,4 +161,5 @@ admin.site.register(PurchaseOrderLineItem, PurchaseOrderLineItemAdmin)
|
||||
admin.site.register(SalesOrder, SalesOrderAdmin)
|
||||
admin.site.register(SalesOrderLineItem, SalesOrderLineItemAdmin)
|
||||
|
||||
admin.site.register(SalesOrderShipment, SalesOrderShipmentAdmin)
|
||||
admin.site.register(SalesOrderAllocation, SalesOrderAllocationAdmin)
|
||||
|
@ -13,23 +13,17 @@ from rest_framework import generics
|
||||
from rest_framework import filters, status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from company.models import SupplierPart
|
||||
|
||||
from InvenTree.filters import InvenTreeOrderingFilter
|
||||
from InvenTree.helpers import str2bool
|
||||
from InvenTree.api import AttachmentMixin
|
||||
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus
|
||||
|
||||
import order.models as models
|
||||
import order.serializers as serializers
|
||||
|
||||
from part.models import Part
|
||||
from company.models import SupplierPart
|
||||
|
||||
from .models import PurchaseOrder, PurchaseOrderLineItem
|
||||
from .models import PurchaseOrderAttachment
|
||||
from .serializers import POSerializer, POLineItemSerializer, POAttachmentSerializer
|
||||
|
||||
from .models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation
|
||||
from .models import SalesOrderAttachment
|
||||
from .serializers import SalesOrderSerializer, SOLineItemSerializer, SOAttachmentSerializer
|
||||
from .serializers import SalesOrderAllocationSerializer
|
||||
from .serializers import POReceiveSerializer
|
||||
|
||||
|
||||
class POList(generics.ListCreateAPIView):
|
||||
@ -39,8 +33,8 @@ class POList(generics.ListCreateAPIView):
|
||||
- POST: Create a new PurchaseOrder object
|
||||
"""
|
||||
|
||||
queryset = PurchaseOrder.objects.all()
|
||||
serializer_class = POSerializer
|
||||
queryset = models.PurchaseOrder.objects.all()
|
||||
serializer_class = serializers.POSerializer
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""
|
||||
@ -77,7 +71,7 @@ class POList(generics.ListCreateAPIView):
|
||||
'lines',
|
||||
)
|
||||
|
||||
queryset = POSerializer.annotate_queryset(queryset)
|
||||
queryset = serializers.POSerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
@ -106,9 +100,9 @@ class POList(generics.ListCreateAPIView):
|
||||
overdue = str2bool(overdue)
|
||||
|
||||
if overdue:
|
||||
queryset = queryset.filter(PurchaseOrder.OVERDUE_FILTER)
|
||||
queryset = queryset.filter(models.PurchaseOrder.OVERDUE_FILTER)
|
||||
else:
|
||||
queryset = queryset.exclude(PurchaseOrder.OVERDUE_FILTER)
|
||||
queryset = queryset.exclude(models.PurchaseOrder.OVERDUE_FILTER)
|
||||
|
||||
# Special filtering for 'status' field
|
||||
status = params.get('status', None)
|
||||
@ -142,7 +136,7 @@ class POList(generics.ListCreateAPIView):
|
||||
max_date = params.get('max_date', None)
|
||||
|
||||
if min_date is not None and max_date is not None:
|
||||
queryset = PurchaseOrder.filterByDate(queryset, min_date, max_date)
|
||||
queryset = models.PurchaseOrder.filterByDate(queryset, min_date, max_date)
|
||||
|
||||
return queryset
|
||||
|
||||
@ -182,8 +176,8 @@ class POList(generics.ListCreateAPIView):
|
||||
class PODetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
""" API endpoint for detail view of a PurchaseOrder object """
|
||||
|
||||
queryset = PurchaseOrder.objects.all()
|
||||
serializer_class = POSerializer
|
||||
queryset = models.PurchaseOrder.objects.all()
|
||||
serializer_class = serializers.POSerializer
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
|
||||
@ -206,7 +200,7 @@ class PODetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
'lines',
|
||||
)
|
||||
|
||||
queryset = POSerializer.annotate_queryset(queryset)
|
||||
queryset = serializers.POSerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
@ -224,9 +218,9 @@ class POReceive(generics.CreateAPIView):
|
||||
- A global location can also be specified
|
||||
"""
|
||||
|
||||
queryset = PurchaseOrderLineItem.objects.none()
|
||||
queryset = models.PurchaseOrderLineItem.objects.none()
|
||||
|
||||
serializer_class = POReceiveSerializer
|
||||
serializer_class = serializers.POReceiveSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
|
||||
@ -234,7 +228,7 @@ class POReceive(generics.CreateAPIView):
|
||||
|
||||
# Pass the purchase order through to the serializer for validation
|
||||
try:
|
||||
context['order'] = PurchaseOrder.objects.get(pk=self.kwargs.get('pk', None))
|
||||
context['order'] = models.PurchaseOrder.objects.get(pk=self.kwargs.get('pk', None))
|
||||
except:
|
||||
pass
|
||||
|
||||
@ -249,7 +243,7 @@ class POLineItemFilter(rest_filters.FilterSet):
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrderLineItem
|
||||
model = models.PurchaseOrderLineItem
|
||||
fields = [
|
||||
'order',
|
||||
'part'
|
||||
@ -283,15 +277,15 @@ class POLineItemList(generics.ListCreateAPIView):
|
||||
- POST: Create a new PurchaseOrderLineItem object
|
||||
"""
|
||||
|
||||
queryset = PurchaseOrderLineItem.objects.all()
|
||||
serializer_class = POLineItemSerializer
|
||||
queryset = models.PurchaseOrderLineItem.objects.all()
|
||||
serializer_class = serializers.POLineItemSerializer
|
||||
filterset_class = POLineItemFilter
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
queryset = POLineItemSerializer.annotate_queryset(queryset)
|
||||
queryset = serializers.POLineItemSerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
@ -371,14 +365,14 @@ class POLineItemDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
Detail API endpoint for PurchaseOrderLineItem object
|
||||
"""
|
||||
|
||||
queryset = PurchaseOrderLineItem.objects.all()
|
||||
serializer_class = POLineItemSerializer
|
||||
queryset = models.PurchaseOrderLineItem.objects.all()
|
||||
serializer_class = serializers.POLineItemSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
queryset = super().get_queryset()
|
||||
|
||||
queryset = POLineItemSerializer.annotate_queryset(queryset)
|
||||
queryset = serializers.POLineItemSerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
@ -388,8 +382,8 @@ class SOAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
||||
API endpoint for listing (and creating) a SalesOrderAttachment (file upload)
|
||||
"""
|
||||
|
||||
queryset = SalesOrderAttachment.objects.all()
|
||||
serializer_class = SOAttachmentSerializer
|
||||
queryset = models.SalesOrderAttachment.objects.all()
|
||||
serializer_class = serializers.SOAttachmentSerializer
|
||||
|
||||
filter_backends = [
|
||||
rest_filters.DjangoFilterBackend,
|
||||
@ -405,8 +399,8 @@ class SOAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin)
|
||||
Detail endpoint for SalesOrderAttachment
|
||||
"""
|
||||
|
||||
queryset = SalesOrderAttachment.objects.all()
|
||||
serializer_class = SOAttachmentSerializer
|
||||
queryset = models.SalesOrderAttachment.objects.all()
|
||||
serializer_class = serializers.SOAttachmentSerializer
|
||||
|
||||
|
||||
class SOList(generics.ListCreateAPIView):
|
||||
@ -417,8 +411,8 @@ class SOList(generics.ListCreateAPIView):
|
||||
- POST: Create a new SalesOrder
|
||||
"""
|
||||
|
||||
queryset = SalesOrder.objects.all()
|
||||
serializer_class = SalesOrderSerializer
|
||||
queryset = models.SalesOrder.objects.all()
|
||||
serializer_class = serializers.SalesOrderSerializer
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""
|
||||
@ -455,7 +449,7 @@ class SOList(generics.ListCreateAPIView):
|
||||
'lines'
|
||||
)
|
||||
|
||||
queryset = SalesOrderSerializer.annotate_queryset(queryset)
|
||||
queryset = serializers.SalesOrderSerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
@ -475,9 +469,9 @@ class SOList(generics.ListCreateAPIView):
|
||||
outstanding = str2bool(outstanding)
|
||||
|
||||
if outstanding:
|
||||
queryset = queryset.filter(status__in=SalesOrderStatus.OPEN)
|
||||
queryset = queryset.filter(status__in=models.SalesOrderStatus.OPEN)
|
||||
else:
|
||||
queryset = queryset.exclude(status__in=SalesOrderStatus.OPEN)
|
||||
queryset = queryset.exclude(status__in=models.SalesOrderStatus.OPEN)
|
||||
|
||||
# Filter by 'overdue' status
|
||||
overdue = params.get('overdue', None)
|
||||
@ -486,9 +480,9 @@ class SOList(generics.ListCreateAPIView):
|
||||
overdue = str2bool(overdue)
|
||||
|
||||
if overdue:
|
||||
queryset = queryset.filter(SalesOrder.OVERDUE_FILTER)
|
||||
queryset = queryset.filter(models.SalesOrder.OVERDUE_FILTER)
|
||||
else:
|
||||
queryset = queryset.exclude(SalesOrder.OVERDUE_FILTER)
|
||||
queryset = queryset.exclude(models.SalesOrder.OVERDUE_FILTER)
|
||||
|
||||
status = params.get('status', None)
|
||||
|
||||
@ -511,7 +505,7 @@ class SOList(generics.ListCreateAPIView):
|
||||
max_date = params.get('max_date', None)
|
||||
|
||||
if min_date is not None and max_date is not None:
|
||||
queryset = SalesOrder.filterByDate(queryset, min_date, max_date)
|
||||
queryset = models.SalesOrder.filterByDate(queryset, min_date, max_date)
|
||||
|
||||
return queryset
|
||||
|
||||
@ -555,8 +549,8 @@ class SODetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
API endpoint for detail view of a SalesOrder object.
|
||||
"""
|
||||
|
||||
queryset = SalesOrder.objects.all()
|
||||
serializer_class = SalesOrderSerializer
|
||||
queryset = models.SalesOrder.objects.all()
|
||||
serializer_class = serializers.SalesOrderSerializer
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
|
||||
@ -575,7 +569,40 @@ class SODetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
|
||||
queryset = queryset.prefetch_related('customer', 'lines')
|
||||
|
||||
queryset = SalesOrderSerializer.annotate_queryset(queryset)
|
||||
queryset = serializers.SalesOrderSerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class SOLineItemFilter(rest_filters.FilterSet):
|
||||
"""
|
||||
Custom filters for SOLineItemList endpoint
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = models.SalesOrderLineItem
|
||||
fields = [
|
||||
'order',
|
||||
'part',
|
||||
]
|
||||
|
||||
completed = rest_filters.BooleanFilter(label='completed', method='filter_completed')
|
||||
|
||||
def filter_completed(self, queryset, name, value):
|
||||
"""
|
||||
Filter by lines which are "completed"
|
||||
|
||||
A line is completed when shipped >= quantity
|
||||
"""
|
||||
|
||||
value = str2bool(value)
|
||||
|
||||
q = Q(shipped__gte=F('quantity'))
|
||||
|
||||
if value:
|
||||
queryset = queryset.filter(q)
|
||||
else:
|
||||
queryset = queryset.exclude(q)
|
||||
|
||||
return queryset
|
||||
|
||||
@ -585,8 +612,9 @@ class SOLineItemList(generics.ListCreateAPIView):
|
||||
API endpoint for accessing a list of SalesOrderLineItem objects.
|
||||
"""
|
||||
|
||||
queryset = SalesOrderLineItem.objects.all()
|
||||
serializer_class = SOLineItemSerializer
|
||||
queryset = models.SalesOrderLineItem.objects.all()
|
||||
serializer_class = serializers.SOLineItemSerializer
|
||||
filterset_class = SOLineItemFilter
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
|
||||
@ -645,8 +673,80 @@ class SOLineItemList(generics.ListCreateAPIView):
|
||||
class SOLineItemDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
""" API endpoint for detail view of a SalesOrderLineItem object """
|
||||
|
||||
queryset = SalesOrderLineItem.objects.all()
|
||||
serializer_class = SOLineItemSerializer
|
||||
queryset = models.SalesOrderLineItem.objects.all()
|
||||
serializer_class = serializers.SOLineItemSerializer
|
||||
|
||||
|
||||
class SalesOrderComplete(generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint for manually marking a SalesOrder as "complete".
|
||||
"""
|
||||
|
||||
queryset = models.SalesOrder.objects.all()
|
||||
serializer_class = serializers.SalesOrderCompleteSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
|
||||
ctx = super().get_serializer_context()
|
||||
|
||||
ctx['request'] = self.request
|
||||
|
||||
try:
|
||||
ctx['order'] = models.SalesOrder.objects.get(pk=self.kwargs.get('pk', None))
|
||||
except:
|
||||
pass
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
class SalesOrderAllocateSerials(generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint to allocation stock items against a SalesOrder,
|
||||
by specifying serial numbers.
|
||||
"""
|
||||
|
||||
queryset = models.SalesOrder.objects.none()
|
||||
serializer_class = serializers.SOSerialAllocationSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
|
||||
ctx = super().get_serializer_context()
|
||||
|
||||
# Pass through the SalesOrder object to the serializer
|
||||
try:
|
||||
ctx['order'] = models.SalesOrder.objects.get(pk=self.kwargs.get('pk', None))
|
||||
except:
|
||||
pass
|
||||
|
||||
ctx['request'] = self.request
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
class SalesOrderAllocate(generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint to allocate stock items against a SalesOrder
|
||||
|
||||
- The SalesOrder is specified in the URL
|
||||
- See the SOShipmentAllocationSerializer class
|
||||
"""
|
||||
|
||||
queryset = models.SalesOrder.objects.none()
|
||||
serializer_class = serializers.SOShipmentAllocationSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
|
||||
ctx = super().get_serializer_context()
|
||||
|
||||
# Pass through the SalesOrder object to the serializer
|
||||
try:
|
||||
ctx['order'] = models.SalesOrder.objects.get(pk=self.kwargs.get('pk', None))
|
||||
except:
|
||||
pass
|
||||
|
||||
ctx['request'] = self.request
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
class SOAllocationDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
@ -654,17 +754,17 @@ class SOAllocationDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
API endpoint for detali view of a SalesOrderAllocation object
|
||||
"""
|
||||
|
||||
queryset = SalesOrderAllocation.objects.all()
|
||||
serializer_class = SalesOrderAllocationSerializer
|
||||
queryset = models.SalesOrderAllocation.objects.all()
|
||||
serializer_class = serializers.SalesOrderAllocationSerializer
|
||||
|
||||
|
||||
class SOAllocationList(generics.ListCreateAPIView):
|
||||
class SOAllocationList(generics.ListAPIView):
|
||||
"""
|
||||
API endpoint for listing SalesOrderAllocation objects
|
||||
"""
|
||||
|
||||
queryset = SalesOrderAllocation.objects.all()
|
||||
serializer_class = SalesOrderAllocationSerializer
|
||||
queryset = models.SalesOrderAllocation.objects.all()
|
||||
serializer_class = serializers.SalesOrderAllocationSerializer
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
|
||||
@ -722,13 +822,87 @@ class SOAllocationList(generics.ListCreateAPIView):
|
||||
]
|
||||
|
||||
|
||||
class SOShipmentFilter(rest_filters.FilterSet):
|
||||
"""
|
||||
Custom filterset for the SOShipmentList endpoint
|
||||
"""
|
||||
|
||||
shipped = rest_filters.BooleanFilter(label='shipped', method='filter_shipped')
|
||||
|
||||
def filter_shipped(self, queryset, name, value):
|
||||
|
||||
value = str2bool(value)
|
||||
|
||||
if value:
|
||||
queryset = queryset.exclude(shipment_date=None)
|
||||
else:
|
||||
queryset = queryset.filter(shipment_date=None)
|
||||
|
||||
return queryset
|
||||
|
||||
class Meta:
|
||||
model = models.SalesOrderShipment
|
||||
fields = [
|
||||
'order',
|
||||
]
|
||||
|
||||
|
||||
class SOShipmentList(generics.ListCreateAPIView):
|
||||
"""
|
||||
API list endpoint for SalesOrderShipment model
|
||||
"""
|
||||
|
||||
queryset = models.SalesOrderShipment.objects.all()
|
||||
serializer_class = serializers.SalesOrderShipmentSerializer
|
||||
filterset_class = SOShipmentFilter
|
||||
|
||||
filter_backends = [
|
||||
rest_filters.DjangoFilterBackend,
|
||||
]
|
||||
|
||||
|
||||
class SOShipmentDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""
|
||||
API detail endpooint for SalesOrderShipment model
|
||||
"""
|
||||
|
||||
queryset = models.SalesOrderShipment.objects.all()
|
||||
serializer_class = serializers.SalesOrderShipmentSerializer
|
||||
|
||||
|
||||
class SOShipmentComplete(generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint for completing (shipping) a SalesOrderShipment
|
||||
"""
|
||||
|
||||
queryset = models.SalesOrderShipment.objects.all()
|
||||
serializer_class = serializers.SalesOrderShipmentCompleteSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""
|
||||
Pass the request object to the serializer
|
||||
"""
|
||||
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['request'] = self.request
|
||||
|
||||
try:
|
||||
ctx['shipment'] = models.SalesOrderShipment.objects.get(
|
||||
pk=self.kwargs.get('pk', None)
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
||||
"""
|
||||
API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload)
|
||||
"""
|
||||
|
||||
queryset = PurchaseOrderAttachment.objects.all()
|
||||
serializer_class = POAttachmentSerializer
|
||||
queryset = models.PurchaseOrderAttachment.objects.all()
|
||||
serializer_class = serializers.POAttachmentSerializer
|
||||
|
||||
filter_backends = [
|
||||
rest_filters.DjangoFilterBackend,
|
||||
@ -744,8 +918,8 @@ class POAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin)
|
||||
Detail endpoint for a PurchaseOrderAttachment
|
||||
"""
|
||||
|
||||
queryset = PurchaseOrderAttachment.objects.all()
|
||||
serializer_class = POAttachmentSerializer
|
||||
queryset = models.PurchaseOrderAttachment.objects.all()
|
||||
serializer_class = serializers.POAttachmentSerializer
|
||||
|
||||
|
||||
order_api_urls = [
|
||||
@ -782,7 +956,23 @@ order_api_urls = [
|
||||
url(r'^.*$', SOAttachmentList.as_view(), name='api-so-attachment-list'),
|
||||
])),
|
||||
|
||||
url(r'^(?P<pk>\d+)/$', SODetail.as_view(), name='api-so-detail'),
|
||||
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'^.*$', SOShipmentList.as_view(), name='api-so-shipment-list'),
|
||||
])),
|
||||
|
||||
# Sales order detail view
|
||||
url(r'^(?P<pk>\d+)/', include([
|
||||
url(r'^complete/', SalesOrderComplete.as_view(), name='api-so-complete'),
|
||||
url(r'^allocate/', SalesOrderAllocate.as_view(), name='api-so-allocate'),
|
||||
url(r'^allocate-serials/', SalesOrderAllocateSerials.as_view(), name='api-so-allocate-serials'),
|
||||
url(r'^.*$', SODetail.as_view(), name='api-so-detail'),
|
||||
])),
|
||||
|
||||
# Sales order list view
|
||||
url(r'^.*$', SOList.as_view(), name='api-so-list'),
|
||||
])),
|
||||
|
||||
|
@ -15,10 +15,8 @@ from InvenTree.helpers import clean_decimal
|
||||
|
||||
from common.forms import MatchItemForm
|
||||
|
||||
import part.models
|
||||
|
||||
from .models import PurchaseOrder
|
||||
from .models import SalesOrder, SalesOrderLineItem
|
||||
from .models import SalesOrder
|
||||
|
||||
|
||||
class IssuePurchaseOrderForm(HelperForm):
|
||||
@ -65,57 +63,6 @@ class CancelSalesOrderForm(HelperForm):
|
||||
]
|
||||
|
||||
|
||||
class ShipSalesOrderForm(HelperForm):
|
||||
|
||||
confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Ship order'))
|
||||
|
||||
class Meta:
|
||||
model = SalesOrder
|
||||
fields = [
|
||||
'confirm',
|
||||
]
|
||||
|
||||
|
||||
class AllocateSerialsToSalesOrderForm(forms.Form):
|
||||
"""
|
||||
Form for assigning stock to a sales order,
|
||||
by serial number lookup
|
||||
|
||||
TODO: Refactor this form / view to use the new API forms interface
|
||||
"""
|
||||
|
||||
line = forms.ModelChoiceField(
|
||||
queryset=SalesOrderLineItem.objects.all(),
|
||||
)
|
||||
|
||||
part = forms.ModelChoiceField(
|
||||
queryset=part.models.Part.objects.all(),
|
||||
)
|
||||
|
||||
serials = forms.CharField(
|
||||
label=_("Serial Numbers"),
|
||||
required=True,
|
||||
help_text=_('Enter stock item serial numbers'),
|
||||
)
|
||||
|
||||
quantity = forms.IntegerField(
|
||||
label=_('Quantity'),
|
||||
required=True,
|
||||
help_text=_('Enter quantity of stock items'),
|
||||
initial=1,
|
||||
min_value=1
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
fields = [
|
||||
'line',
|
||||
'part',
|
||||
'serials',
|
||||
'quantity',
|
||||
]
|
||||
|
||||
|
||||
class OrderMatchItemForm(MatchItemForm):
|
||||
""" Override MatchItemForm fields """
|
||||
|
||||
|
31
InvenTree/order/migrations/0053_salesordershipment.py
Normal file
31
InvenTree/order/migrations/0053_salesordershipment.py
Normal file
@ -0,0 +1,31 @@
|
||||
# Generated by Django 3.2.5 on 2021-10-25 02:08
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
import order.models
|
||||
|
||||
import markdownx.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('order', '0053_auto_20211128_0151'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SalesOrderShipment',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('shipment_date', models.DateField(blank=True, help_text='Date of shipment', null=True, verbose_name='Shipment Date')),
|
||||
('reference', models.CharField(default='1', help_text='Shipment reference', max_length=100, verbose_name='Reference')),
|
||||
('notes', markdownx.models.MarkdownxField(blank=True, help_text='Shipment notes', verbose_name='Notes')),
|
||||
('checked_by', models.ForeignKey(blank=True, help_text='User who checked this shipment', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Checked By')),
|
||||
('order', models.ForeignKey(help_text='Sales Order', on_delete=django.db.models.deletion.CASCADE, related_name='shipments', to='order.salesorder', verbose_name='Order')),
|
||||
],
|
||||
),
|
||||
]
|
@ -0,0 +1,19 @@
|
||||
# Generated by Django 3.2.5 on 2021-10-25 06:42
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('order', '0053_salesordershipment'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='salesorderallocation',
|
||||
name='shipment',
|
||||
field=models.ForeignKey(blank=True, help_text='Sales order shipment reference', null=True, on_delete=django.db.models.deletion.CASCADE, to='order.salesordershipment', verbose_name='Shipment'),
|
||||
),
|
||||
]
|
92
InvenTree/order/migrations/0055_auto_20211025_0645.py
Normal file
92
InvenTree/order/migrations/0055_auto_20211025_0645.py
Normal file
@ -0,0 +1,92 @@
|
||||
# Generated by Django 3.2.5 on 2021-10-25 06:45
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
from InvenTree.status_codes import SalesOrderStatus
|
||||
|
||||
|
||||
def add_shipment(apps, schema_editor):
|
||||
"""
|
||||
Create a SalesOrderShipment for each existing SalesOrder instance.
|
||||
|
||||
Any "allocations" are marked against that shipment.
|
||||
|
||||
For each existing SalesOrder instance, we create a default SalesOrderShipment,
|
||||
and associate each SalesOrderAllocation with this shipment
|
||||
"""
|
||||
|
||||
Allocation = apps.get_model('order', 'salesorderallocation')
|
||||
SalesOrder = apps.get_model('order', 'salesorder')
|
||||
Shipment = apps.get_model('order', 'salesordershipment')
|
||||
|
||||
n = 0
|
||||
|
||||
for order in SalesOrder.objects.all():
|
||||
|
||||
"""
|
||||
We only create an automatic shipment for "PENDING" orders,
|
||||
as SalesOrderAllocations were historically deleted for "SHIPPED" or "CANCELLED" orders
|
||||
"""
|
||||
|
||||
allocations = Allocation.objects.filter(
|
||||
line__order=order
|
||||
)
|
||||
|
||||
if allocations.count() == 0 and order.status != SalesOrderStatus.PENDING:
|
||||
continue
|
||||
|
||||
# Create a new Shipment instance against this order
|
||||
shipment = Shipment.objects.create(
|
||||
order=order,
|
||||
)
|
||||
|
||||
if order.status == SalesOrderStatus.SHIPPED:
|
||||
shipment.shipment_date = order.shipment_date
|
||||
|
||||
shipment.save()
|
||||
|
||||
# Iterate through each allocation associated with this order
|
||||
for allocation in allocations:
|
||||
allocation.shipment = shipment
|
||||
allocation.save()
|
||||
|
||||
n += 1
|
||||
|
||||
if n > 0:
|
||||
print(f"\nCreated SalesOrderShipment for {n} SalesOrder instances")
|
||||
|
||||
|
||||
def reverse_add_shipment(apps, schema_editor):
|
||||
"""
|
||||
Reverse the migration, delete and SalesOrderShipment instances
|
||||
"""
|
||||
|
||||
Allocation = apps.get_model('order', 'salesorderallocation')
|
||||
|
||||
# First, ensure that all SalesOrderAllocation objects point to a null shipment
|
||||
for allocation in Allocation.objects.exclude(shipment=None):
|
||||
allocation.shipment = None
|
||||
allocation.save()
|
||||
|
||||
SOS = apps.get_model('order', 'salesordershipment')
|
||||
|
||||
n = SOS.objects.count()
|
||||
|
||||
print(f"Deleting {n} SalesOrderShipment instances")
|
||||
|
||||
SOS.objects.all().delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('order', '0054_salesorderallocation_shipment'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
add_shipment,
|
||||
reverse_code=reverse_add_shipment,
|
||||
)
|
||||
]
|
@ -0,0 +1,19 @@
|
||||
# Generated by Django 3.2.5 on 2021-10-25 11:40
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('order', '0055_auto_20211025_0645'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='salesorderallocation',
|
||||
name='shipment',
|
||||
field=models.ForeignKey(help_text='Sales order shipment reference', on_delete=django.db.models.deletion.CASCADE, related_name='allocations', to='order.salesordershipment', verbose_name='Shipment'),
|
||||
),
|
||||
]
|
@ -0,0 +1,20 @@
|
||||
# Generated by Django 3.2.5 on 2021-11-26 12:06
|
||||
|
||||
import InvenTree.fields
|
||||
import django.core.validators
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('order', '0056_alter_salesorderallocation_shipment'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='salesorderlineitem',
|
||||
name='shipped',
|
||||
field=InvenTree.fields.RoundingDecimalField(decimal_places=5, default=0, help_text='Shipped quantity', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Shipped'),
|
||||
),
|
||||
]
|
62
InvenTree/order/migrations/0058_auto_20211126_1210.py
Normal file
62
InvenTree/order/migrations/0058_auto_20211126_1210.py
Normal file
@ -0,0 +1,62 @@
|
||||
# Generated by Django 3.2.5 on 2021-11-26 12:10
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
from InvenTree.status_codes import SalesOrderStatus
|
||||
|
||||
|
||||
def calculate_shipped_quantity(apps, schema_editor):
|
||||
"""
|
||||
In migration 0057 we added a new field 'shipped' to the SalesOrderLineItem model.
|
||||
|
||||
This field is used to record the number of items shipped,
|
||||
even if the actual stock items get deleted from the database.
|
||||
|
||||
For existing orders in the database, we calculate this as follows:
|
||||
|
||||
- If the order is "shipped" then we use the total quantity
|
||||
- Otherwise, we use the "fulfilled" calculated quantity
|
||||
|
||||
"""
|
||||
|
||||
StockItem = apps.get_model('stock', 'stockitem')
|
||||
SalesOrderLineItem = apps.get_model('order', 'salesorderlineitem')
|
||||
|
||||
for item in SalesOrderLineItem.objects.all():
|
||||
|
||||
if item.order.status == SalesOrderStatus.SHIPPED:
|
||||
item.shipped = item.quantity
|
||||
else:
|
||||
# Calculate total stock quantity of items allocated to this order?
|
||||
items = StockItem.objects.filter(
|
||||
sales_order=item.order,
|
||||
part=item.part
|
||||
)
|
||||
|
||||
q = sum([item.quantity for item in items])
|
||||
|
||||
item.shipped = q
|
||||
|
||||
item.save()
|
||||
|
||||
|
||||
def reverse_calculate_shipped_quantity(apps, schema_editor):
|
||||
"""
|
||||
Provided only for reverse migration compatibility.
|
||||
This function does nothing.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('order', '0057_salesorderlineitem_shipped'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
calculate_shipped_quantity,
|
||||
reverse_code=reverse_calculate_shipped_quantity
|
||||
)
|
||||
]
|
@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.5 on 2021-11-29 11:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('order', '0058_auto_20211126_1210'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='salesordershipment',
|
||||
name='tracking_number',
|
||||
field=models.CharField(blank=True, help_text='Shipment tracking information', max_length=100, verbose_name='Tracking Number'),
|
||||
),
|
||||
]
|
22
InvenTree/order/migrations/0060_auto_20211129_1339.py
Normal file
22
InvenTree/order/migrations/0060_auto_20211129_1339.py
Normal file
@ -0,0 +1,22 @@
|
||||
# Generated by Django 3.2.5 on 2021-11-29 13:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('order', '0059_salesordershipment_tracking_number'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='salesordershipment',
|
||||
name='reference',
|
||||
field=models.CharField(default='1', help_text='Shipment number', max_length=100, verbose_name='Shipment'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='salesordershipment',
|
||||
unique_together={('order', 'reference')},
|
||||
),
|
||||
]
|
@ -0,0 +1,14 @@
|
||||
# Generated by Django 3.2.5 on 2021-12-02 13:31
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('order', '0054_auto_20211201_2139'),
|
||||
('order', '0060_auto_20211129_1339'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
]
|
@ -107,45 +107,6 @@ class Order(ReferenceIndexingMixin):
|
||||
responsible: User (or group) responsible for managing the order
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def getNextOrderNumber(cls):
|
||||
"""
|
||||
Try to predict the next order-number
|
||||
"""
|
||||
|
||||
if cls.objects.count() == 0:
|
||||
return None
|
||||
|
||||
# We will assume that the latest pk has the highest PO number
|
||||
order = cls.objects.last()
|
||||
ref = order.reference
|
||||
|
||||
if not ref:
|
||||
return None
|
||||
|
||||
tries = set()
|
||||
|
||||
tries.add(ref)
|
||||
|
||||
while 1:
|
||||
new_ref = increment(ref)
|
||||
|
||||
print("Reference:", new_ref)
|
||||
|
||||
if new_ref in tries:
|
||||
# We are in a looping situation - simply return the original one
|
||||
return ref
|
||||
|
||||
# Check that the new ref does not exist in the database
|
||||
if cls.objects.filter(reference=new_ref).exists():
|
||||
tries.add(new_ref)
|
||||
new_ref = increment(new_ref)
|
||||
|
||||
else:
|
||||
break
|
||||
|
||||
return new_ref
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
self.rebuild_reference_field()
|
||||
@ -402,11 +363,30 @@ class PurchaseOrder(Order):
|
||||
|
||||
return self.lines.filter(quantity__gt=F('received'))
|
||||
|
||||
def completed_line_items(self):
|
||||
"""
|
||||
Return a list of completed line items against this order
|
||||
"""
|
||||
return self.lines.filter(quantity__lte=F('received'))
|
||||
|
||||
@property
|
||||
def line_count(self):
|
||||
return self.lines.count()
|
||||
|
||||
@property
|
||||
def completed_line_count(self):
|
||||
|
||||
return self.completed_line_items().count()
|
||||
|
||||
@property
|
||||
def pending_line_count(self):
|
||||
return self.pending_line_items().count()
|
||||
|
||||
@property
|
||||
def is_complete(self):
|
||||
""" Return True if all line items have been received """
|
||||
|
||||
return self.pending_line_items().count() == 0
|
||||
return self.lines.count() > 0 and self.pending_line_items().count() == 0
|
||||
|
||||
@transaction.atomic
|
||||
def receive_line_item(self, line, location, quantity, user, status=StockStatus.OK, purchase_price=None, **kwargs):
|
||||
@ -606,6 +586,16 @@ class SalesOrder(Order):
|
||||
def is_pending(self):
|
||||
return self.status == SalesOrderStatus.PENDING
|
||||
|
||||
@property
|
||||
def stock_allocations(self):
|
||||
"""
|
||||
Return a queryset containing all allocations for this order
|
||||
"""
|
||||
|
||||
return SalesOrderAllocation.objects.filter(
|
||||
line__in=[line.pk for line in self.lines.all()]
|
||||
)
|
||||
|
||||
def is_fully_allocated(self):
|
||||
""" Return True if all line items are fully allocated """
|
||||
|
||||
@ -624,29 +614,55 @@ class SalesOrder(Order):
|
||||
|
||||
return False
|
||||
|
||||
@transaction.atomic
|
||||
def ship_order(self, user):
|
||||
""" Mark this order as 'shipped' """
|
||||
def is_completed(self):
|
||||
"""
|
||||
Check if this order is "shipped" (all line items delivered),
|
||||
"""
|
||||
|
||||
# The order can only be 'shipped' if the current status is PENDING
|
||||
if not self.status == SalesOrderStatus.PENDING:
|
||||
raise ValidationError({'status': _("SalesOrder cannot be shipped as it is not currently pending")})
|
||||
return self.lines.count() > 0 and all([line.is_completed() for line in self.lines.all()])
|
||||
|
||||
# Complete the allocation for each allocated StockItem
|
||||
for line in self.lines.all():
|
||||
for allocation in line.allocations.all():
|
||||
allocation.complete_allocation(user)
|
||||
def can_complete(self, raise_error=False):
|
||||
"""
|
||||
Test if this SalesOrder can be completed.
|
||||
|
||||
Throws a ValidationError if cannot be completed.
|
||||
"""
|
||||
|
||||
# Order without line items cannot be completed
|
||||
if self.lines.count() == 0:
|
||||
if raise_error:
|
||||
raise ValidationError(_('Order cannot be completed as no parts have been assigned'))
|
||||
|
||||
# Only a PENDING order can be marked as SHIPPED
|
||||
elif self.status != SalesOrderStatus.PENDING:
|
||||
if raise_error:
|
||||
raise ValidationError(_('Only a pending order can be marked as complete'))
|
||||
|
||||
elif self.pending_shipment_count > 0:
|
||||
if raise_error:
|
||||
raise ValidationError(_("Order cannot be completed as there are incomplete shipments"))
|
||||
|
||||
elif self.pending_line_count > 0:
|
||||
if raise_error:
|
||||
raise ValidationError(_("Order cannot be completed as there are incomplete line items"))
|
||||
|
||||
# Remove the allocation from the database once it has been 'fulfilled'
|
||||
if allocation.item.sales_order == self:
|
||||
allocation.delete()
|
||||
else:
|
||||
raise ValidationError("Could not complete order - allocation item not fulfilled")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def complete_order(self, user):
|
||||
"""
|
||||
Mark this order as "complete"
|
||||
"""
|
||||
|
||||
if not self.can_complete():
|
||||
return False
|
||||
|
||||
# Ensure the order status is marked as "Shipped"
|
||||
self.status = SalesOrderStatus.SHIPPED
|
||||
self.shipment_date = datetime.now().date()
|
||||
self.shipped_by = user
|
||||
self.shipment_date = datetime.now()
|
||||
|
||||
self.save()
|
||||
|
||||
return True
|
||||
@ -682,6 +698,55 @@ class SalesOrder(Order):
|
||||
|
||||
return True
|
||||
|
||||
@property
|
||||
def line_count(self):
|
||||
return self.lines.count()
|
||||
|
||||
def completed_line_items(self):
|
||||
"""
|
||||
Return a queryset of the completed line items for this order
|
||||
"""
|
||||
return self.lines.filter(shipped__gte=F('quantity'))
|
||||
|
||||
def pending_line_items(self):
|
||||
"""
|
||||
Return a queryset of the pending line items for this order
|
||||
"""
|
||||
return self.lines.filter(shipped__lt=F('quantity'))
|
||||
|
||||
@property
|
||||
def completed_line_count(self):
|
||||
return self.completed_line_items().count()
|
||||
|
||||
@property
|
||||
def pending_line_count(self):
|
||||
return self.pending_line_items().count()
|
||||
|
||||
def completed_shipments(self):
|
||||
"""
|
||||
Return a queryset of the completed shipments for this order
|
||||
"""
|
||||
return self.shipments.exclude(shipment_date=None)
|
||||
|
||||
def pending_shipments(self):
|
||||
"""
|
||||
Return a queryset of the pending shipments for this order
|
||||
"""
|
||||
|
||||
return self.shipments.filter(shipment_date=None)
|
||||
|
||||
@property
|
||||
def shipment_count(self):
|
||||
return self.shipments.count()
|
||||
|
||||
@property
|
||||
def completed_shipment_count(self):
|
||||
return self.completed_shipments().count()
|
||||
|
||||
@property
|
||||
def pending_shipment_count(self):
|
||||
return self.pending_shipments().count()
|
||||
|
||||
|
||||
class PurchaseOrderAttachment(InvenTreeAttachment):
|
||||
"""
|
||||
@ -815,13 +880,15 @@ class PurchaseOrderLineItem(OrderLineItem):
|
||||
)
|
||||
|
||||
def get_destination(self):
|
||||
"""Show where the line item is or should be placed"""
|
||||
# NOTE: If a line item gets split when recieved, only an arbitrary
|
||||
# stock items location will be reported as the location for the
|
||||
# entire line.
|
||||
for stock in stock_models.StockItem.objects.filter(
|
||||
supplier_part=self.part, purchase_order=self.order
|
||||
):
|
||||
"""
|
||||
Show where the line item is or should be placed
|
||||
|
||||
NOTE: If a line item gets split when recieved, only an arbitrary
|
||||
stock items location will be reported as the location for the
|
||||
entire line.
|
||||
"""
|
||||
|
||||
for stock in stock_models.StockItem.objects.filter(supplier_part=self.part, purchase_order=self.order):
|
||||
if stock.location:
|
||||
return stock.location
|
||||
if self.destination:
|
||||
@ -843,6 +910,7 @@ class SalesOrderLineItem(OrderLineItem):
|
||||
order: Link to the SalesOrder that this line item belongs to
|
||||
part: Link to a Part object (may be null)
|
||||
sale_price: The unit sale price for this OrderLineItem
|
||||
shipped: The number of items which have actually shipped against this line item
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@ -867,6 +935,14 @@ class SalesOrderLineItem(OrderLineItem):
|
||||
help_text=_('Unit sale price'),
|
||||
)
|
||||
|
||||
shipped = RoundingDecimalField(
|
||||
verbose_name=_('Shipped'),
|
||||
help_text=_('Shipped quantity'),
|
||||
default=0,
|
||||
max_digits=15, decimal_places=5,
|
||||
validators=[MinValueValidator(0)]
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = [
|
||||
]
|
||||
@ -902,6 +978,130 @@ class SalesOrderLineItem(OrderLineItem):
|
||||
""" Return True if this line item is over allocated """
|
||||
return self.allocated_quantity() > self.quantity
|
||||
|
||||
def is_completed(self):
|
||||
"""
|
||||
Return True if this line item is completed (has been fully shipped)
|
||||
"""
|
||||
|
||||
return self.shipped >= self.quantity
|
||||
|
||||
|
||||
class SalesOrderShipment(models.Model):
|
||||
"""
|
||||
The SalesOrderShipment model represents a physical shipment made against a SalesOrder.
|
||||
|
||||
- Points to a single SalesOrder object
|
||||
- Multiple SalesOrderAllocation objects point to a particular SalesOrderShipment
|
||||
- When a given SalesOrderShipment is "shipped", stock items are removed from stock
|
||||
|
||||
Attributes:
|
||||
order: SalesOrder reference
|
||||
shipment_date: Date this shipment was "shipped" (or null)
|
||||
checked_by: User reference field indicating who checked this order
|
||||
reference: Custom reference text for this shipment (e.g. consignment number?)
|
||||
notes: Custom notes field for this shipment
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
# Shipment reference must be unique for a given sales order
|
||||
unique_together = [
|
||||
'order', 'reference',
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
return reverse('api-so-shipment-list')
|
||||
|
||||
order = models.ForeignKey(
|
||||
SalesOrder,
|
||||
on_delete=models.CASCADE,
|
||||
blank=False, null=False,
|
||||
related_name='shipments',
|
||||
verbose_name=_('Order'),
|
||||
help_text=_('Sales Order'),
|
||||
)
|
||||
|
||||
shipment_date = models.DateField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Shipment Date'),
|
||||
help_text=_('Date of shipment'),
|
||||
)
|
||||
|
||||
checked_by = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True, null=True,
|
||||
verbose_name=_('Checked By'),
|
||||
help_text=_('User who checked this shipment'),
|
||||
related_name='+',
|
||||
)
|
||||
|
||||
reference = models.CharField(
|
||||
max_length=100,
|
||||
blank=False,
|
||||
verbose_name=('Shipment'),
|
||||
help_text=_('Shipment number'),
|
||||
default='1',
|
||||
)
|
||||
|
||||
notes = MarkdownxField(
|
||||
blank=True,
|
||||
verbose_name=_('Notes'),
|
||||
help_text=_('Shipment notes'),
|
||||
)
|
||||
|
||||
tracking_number = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
unique=False,
|
||||
verbose_name=_('Tracking Number'),
|
||||
help_text=_('Shipment tracking information'),
|
||||
)
|
||||
|
||||
def is_complete(self):
|
||||
return self.shipment_date is not None
|
||||
|
||||
def check_can_complete(self):
|
||||
|
||||
if self.shipment_date:
|
||||
# Shipment has already been sent!
|
||||
raise ValidationError(_("Shipment has already been sent"))
|
||||
|
||||
if self.allocations.count() == 0:
|
||||
raise ValidationError(_("Shipment has no allocated stock items"))
|
||||
|
||||
@transaction.atomic
|
||||
def complete_shipment(self, user, **kwargs):
|
||||
"""
|
||||
Complete this particular shipment:
|
||||
|
||||
1. Update any stock items associated with this shipment
|
||||
2. Update the "shipped" quantity of all associated line items
|
||||
3. Set the "shipment_date" to now
|
||||
"""
|
||||
|
||||
# Check if the shipment can be completed (throw error if not)
|
||||
self.check_can_complete()
|
||||
|
||||
allocations = self.allocations.all()
|
||||
|
||||
# Iterate through each stock item assigned to this shipment
|
||||
for allocation in allocations:
|
||||
# Mark the allocation as "complete"
|
||||
allocation.complete_allocation(user)
|
||||
|
||||
# Update the "shipment" date
|
||||
self.shipment_date = datetime.now()
|
||||
self.shipped_by = user
|
||||
|
||||
# Was a tracking number provided?
|
||||
tracking_number = kwargs.get('tracking_number', None)
|
||||
|
||||
if tracking_number is not None:
|
||||
self.tracking_number = tracking_number
|
||||
|
||||
self.save()
|
||||
|
||||
|
||||
class SalesOrderAllocation(models.Model):
|
||||
"""
|
||||
@ -911,6 +1111,7 @@ class SalesOrderAllocation(models.Model):
|
||||
|
||||
Attributes:
|
||||
line: SalesOrderLineItem reference
|
||||
shipment: SalesOrderShipment reference
|
||||
item: StockItem reference
|
||||
quantity: Quantity to take from the StockItem
|
||||
|
||||
@ -966,6 +1167,10 @@ class SalesOrderAllocation(models.Model):
|
||||
if self.item.serial and not self.quantity == 1:
|
||||
errors['quantity'] = _('Quantity must be 1 for serialized stock item')
|
||||
|
||||
if self.line.order != self.shipment.order:
|
||||
errors['line'] = _('Sales order does not match shipment')
|
||||
errors['shipment'] = _('Shipment does not match sales order')
|
||||
|
||||
if len(errors) > 0:
|
||||
raise ValidationError(errors)
|
||||
|
||||
@ -973,7 +1178,16 @@ class SalesOrderAllocation(models.Model):
|
||||
SalesOrderLineItem,
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name=_('Line'),
|
||||
related_name='allocations')
|
||||
related_name='allocations'
|
||||
)
|
||||
|
||||
shipment = models.ForeignKey(
|
||||
SalesOrderShipment,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='allocations',
|
||||
verbose_name=_('Shipment'),
|
||||
help_text=_('Sales order shipment reference'),
|
||||
)
|
||||
|
||||
item = models.ForeignKey(
|
||||
'stock.StockItem',
|
||||
@ -1022,6 +1236,10 @@ class SalesOrderAllocation(models.Model):
|
||||
user=user
|
||||
)
|
||||
|
||||
# Update the 'shipped' quantity
|
||||
self.line.shipped += self.quantity
|
||||
self.line.save()
|
||||
|
||||
# Update our own reference to the StockItem
|
||||
# (It may have changed if the stock was split)
|
||||
self.item = item
|
||||
|
@ -21,21 +21,19 @@ from common.settings import currency_code_mappings
|
||||
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
|
||||
|
||||
from InvenTree.serializers import InvenTreeAttachmentSerializer
|
||||
from InvenTree.helpers import normalize, extract_serial_numbers
|
||||
from InvenTree.serializers import InvenTreeModelSerializer
|
||||
from InvenTree.serializers import InvenTreeDecimalField
|
||||
from InvenTree.serializers import InvenTreeMoneySerializer
|
||||
from InvenTree.serializers import ReferenceIndexingSerializerMixin
|
||||
from InvenTree.status_codes import StockStatus
|
||||
|
||||
import order.models
|
||||
|
||||
from part.serializers import PartBriefSerializer
|
||||
|
||||
import stock.models
|
||||
from stock.serializers import LocationBriefSerializer, StockItemSerializer, LocationSerializer
|
||||
|
||||
from .models import PurchaseOrder, PurchaseOrderLineItem
|
||||
from .models import PurchaseOrderAttachment, SalesOrderAttachment
|
||||
from .models import SalesOrder, SalesOrderLineItem
|
||||
from .models import SalesOrderAllocation
|
||||
import stock.serializers
|
||||
|
||||
from users.serializers import OwnerSerializer
|
||||
|
||||
@ -68,7 +66,7 @@ class POSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSerializer):
|
||||
queryset = queryset.annotate(
|
||||
overdue=Case(
|
||||
When(
|
||||
PurchaseOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()),
|
||||
order.models.PurchaseOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()),
|
||||
),
|
||||
default=Value(False, output_field=BooleanField())
|
||||
)
|
||||
@ -89,7 +87,7 @@ class POSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSerializer):
|
||||
responsible_detail = OwnerSerializer(source='responsible', read_only=True, many=False)
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrder
|
||||
model = order.models.PurchaseOrder
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
@ -168,7 +166,7 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
||||
|
||||
purchase_price_string = serializers.CharField(source='purchase_price', read_only=True)
|
||||
|
||||
destination_detail = LocationBriefSerializer(source='get_destination', read_only=True)
|
||||
destination_detail = stock.serializers.LocationBriefSerializer(source='get_destination', read_only=True)
|
||||
|
||||
purchase_price_currency = serializers.ChoiceField(
|
||||
choices=currency_code_mappings(),
|
||||
@ -178,7 +176,7 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
||||
order_detail = POSerializer(source='order', read_only=True, many=False)
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrderLineItem
|
||||
model = order.models.PurchaseOrderLineItem
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
@ -206,7 +204,7 @@ class POLineItemReceiveSerializer(serializers.Serializer):
|
||||
"""
|
||||
|
||||
line_item = serializers.PrimaryKeyRelatedField(
|
||||
queryset=PurchaseOrderLineItem.objects.all(),
|
||||
queryset=order.models.PurchaseOrderLineItem.objects.all(),
|
||||
many=False,
|
||||
allow_null=False,
|
||||
required=True,
|
||||
@ -386,7 +384,7 @@ class POAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrderAttachment
|
||||
model = order.models.PurchaseOrderAttachment
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
@ -433,7 +431,7 @@ class SalesOrderSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSeria
|
||||
queryset = queryset.annotate(
|
||||
overdue=Case(
|
||||
When(
|
||||
SalesOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()),
|
||||
order.models.SalesOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()),
|
||||
),
|
||||
default=Value(False, output_field=BooleanField())
|
||||
)
|
||||
@ -452,7 +450,7 @@ class SalesOrderSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSeria
|
||||
reference = serializers.CharField(required=True)
|
||||
|
||||
class Meta:
|
||||
model = SalesOrder
|
||||
model = order.models.SalesOrder
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
@ -495,13 +493,15 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
|
||||
# Extra detail fields
|
||||
order_detail = SalesOrderSerializer(source='line.order', many=False, read_only=True)
|
||||
part_detail = PartBriefSerializer(source='item.part', many=False, read_only=True)
|
||||
item_detail = StockItemSerializer(source='item', many=False, read_only=True)
|
||||
location_detail = LocationSerializer(source='item.location', many=False, read_only=True)
|
||||
item_detail = stock.serializers.StockItemSerializer(source='item', many=False, read_only=True)
|
||||
location_detail = stock.serializers.LocationSerializer(source='item.location', many=False, read_only=True)
|
||||
|
||||
shipment_date = serializers.DateField(source='shipment.shipment_date', read_only=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
order_detail = kwargs.pop('order_detail', False)
|
||||
part_detail = kwargs.pop('part_detail', False)
|
||||
part_detail = kwargs.pop('part_detail', True)
|
||||
item_detail = kwargs.pop('item_detail', False)
|
||||
location_detail = kwargs.pop('location_detail', False)
|
||||
|
||||
@ -520,7 +520,7 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
|
||||
self.fields.pop('location_detail')
|
||||
|
||||
class Meta:
|
||||
model = SalesOrderAllocation
|
||||
model = order.models.SalesOrderAllocation
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
@ -535,6 +535,8 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
|
||||
'order_detail',
|
||||
'part',
|
||||
'part_detail',
|
||||
'shipment',
|
||||
'shipment_date',
|
||||
]
|
||||
|
||||
|
||||
@ -565,7 +567,8 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
|
||||
quantity = InvenTreeDecimalField()
|
||||
|
||||
allocated = serializers.FloatField(source='allocated_quantity', read_only=True)
|
||||
fulfilled = serializers.FloatField(source='fulfilled_quantity', read_only=True)
|
||||
|
||||
shipped = InvenTreeDecimalField(read_only=True)
|
||||
|
||||
sale_price = InvenTreeMoneySerializer(
|
||||
allow_null=True
|
||||
@ -579,14 +582,13 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = SalesOrderLineItem
|
||||
model = order.models.SalesOrderLineItem
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
'allocated',
|
||||
'allocations',
|
||||
'quantity',
|
||||
'fulfilled',
|
||||
'reference',
|
||||
'notes',
|
||||
'order',
|
||||
@ -596,16 +598,421 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
|
||||
'sale_price',
|
||||
'sale_price_currency',
|
||||
'sale_price_string',
|
||||
'shipped',
|
||||
]
|
||||
|
||||
|
||||
class SalesOrderShipmentSerializer(InvenTreeModelSerializer):
|
||||
"""
|
||||
Serializer for the SalesOrderShipment class
|
||||
"""
|
||||
|
||||
allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True)
|
||||
|
||||
order_detail = SalesOrderSerializer(source='order', read_only=True, many=False)
|
||||
|
||||
class Meta:
|
||||
model = order.models.SalesOrderShipment
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
'order',
|
||||
'order_detail',
|
||||
'allocations',
|
||||
'shipment_date',
|
||||
'checked_by',
|
||||
'reference',
|
||||
'tracking_number',
|
||||
'notes',
|
||||
]
|
||||
|
||||
|
||||
class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serializer for completing (shipping) a SalesOrderShipment
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = order.models.SalesOrderShipment
|
||||
|
||||
fields = [
|
||||
'tracking_number',
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
data = super().validate(data)
|
||||
|
||||
shipment = self.context.get('shipment', None)
|
||||
|
||||
if not shipment:
|
||||
raise ValidationError(_("No shipment details provided"))
|
||||
|
||||
shipment.check_can_complete()
|
||||
|
||||
return data
|
||||
|
||||
def save(self):
|
||||
|
||||
shipment = self.context.get('shipment', None)
|
||||
|
||||
if not shipment:
|
||||
return
|
||||
|
||||
data = self.validated_data
|
||||
|
||||
request = self.context['request']
|
||||
user = request.user
|
||||
|
||||
# Extract provided tracking number (optional)
|
||||
tracking_number = data.get('tracking_number', None)
|
||||
|
||||
shipment.complete_shipment(user, tracking_number=tracking_number)
|
||||
|
||||
|
||||
class SOShipmentAllocationItemSerializer(serializers.Serializer):
|
||||
"""
|
||||
A serializer for allocating a single stock-item against a SalesOrder shipment
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
'line_item',
|
||||
'stock_item',
|
||||
'quantity',
|
||||
]
|
||||
|
||||
line_item = serializers.PrimaryKeyRelatedField(
|
||||
queryset=order.models.SalesOrderLineItem.objects.all(),
|
||||
many=False,
|
||||
allow_null=False,
|
||||
required=True,
|
||||
label=_('Stock Item'),
|
||||
)
|
||||
|
||||
def validate_line_item(self, line_item):
|
||||
|
||||
order = self.context['order']
|
||||
|
||||
# Ensure that the line item points to the correct order
|
||||
if line_item.order != order:
|
||||
raise ValidationError(_("Line item is not associated with this order"))
|
||||
|
||||
return line_item
|
||||
|
||||
stock_item = serializers.PrimaryKeyRelatedField(
|
||||
queryset=stock.models.StockItem.objects.all(),
|
||||
many=False,
|
||||
allow_null=False,
|
||||
required=True,
|
||||
label=_('Stock Item'),
|
||||
)
|
||||
|
||||
quantity = serializers.DecimalField(
|
||||
max_digits=15,
|
||||
decimal_places=5,
|
||||
min_value=0,
|
||||
required=True
|
||||
)
|
||||
|
||||
def validate_quantity(self, quantity):
|
||||
|
||||
if quantity <= 0:
|
||||
raise ValidationError(_("Quantity must be positive"))
|
||||
|
||||
return quantity
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
data = super().validate(data)
|
||||
|
||||
stock_item = data['stock_item']
|
||||
quantity = data['quantity']
|
||||
|
||||
if stock_item.serialized and quantity != 1:
|
||||
raise ValidationError({
|
||||
'quantity': _("Quantity must be 1 for serialized stock item"),
|
||||
})
|
||||
|
||||
q = normalize(stock_item.unallocated_quantity())
|
||||
|
||||
if quantity > q:
|
||||
raise ValidationError({
|
||||
'quantity': _(f"Available quantity ({q}) exceeded")
|
||||
})
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class SalesOrderCompleteSerializer(serializers.Serializer):
|
||||
"""
|
||||
DRF serializer for manually marking a sales order as complete
|
||||
"""
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
data = super().validate(data)
|
||||
|
||||
order = self.context['order']
|
||||
|
||||
order.can_complete(raise_error=True)
|
||||
|
||||
return data
|
||||
|
||||
def save(self):
|
||||
|
||||
request = self.context['request']
|
||||
order = self.context['order']
|
||||
|
||||
user = getattr(request, 'user', None)
|
||||
|
||||
order.complete_order(user)
|
||||
|
||||
|
||||
class SOSerialAllocationSerializer(serializers.Serializer):
|
||||
"""
|
||||
DRF serializer for allocation of serial numbers against a sales order / shipment
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
'line_item',
|
||||
'quantity',
|
||||
'serial_numbers',
|
||||
'shipment',
|
||||
]
|
||||
|
||||
line_item = serializers.PrimaryKeyRelatedField(
|
||||
queryset=order.models.SalesOrderLineItem.objects.all(),
|
||||
many=False,
|
||||
required=True,
|
||||
allow_null=False,
|
||||
label=_('Line Item'),
|
||||
)
|
||||
|
||||
def validate_line_item(self, line_item):
|
||||
"""
|
||||
Ensure that the line_item is valid
|
||||
"""
|
||||
|
||||
order = self.context['order']
|
||||
|
||||
# Ensure that the line item points to the correct order
|
||||
if line_item.order != order:
|
||||
raise ValidationError(_("Line item is not associated with this order"))
|
||||
|
||||
return line_item
|
||||
|
||||
quantity = serializers.IntegerField(
|
||||
min_value=1,
|
||||
required=True,
|
||||
allow_null=False,
|
||||
label=_('Quantity'),
|
||||
)
|
||||
|
||||
serial_numbers = serializers.CharField(
|
||||
label=_("Serial Numbers"),
|
||||
help_text=_("Enter serial numbers to allocate"),
|
||||
required=True,
|
||||
allow_blank=False,
|
||||
)
|
||||
|
||||
shipment = serializers.PrimaryKeyRelatedField(
|
||||
queryset=order.models.SalesOrderShipment.objects.all(),
|
||||
many=False,
|
||||
allow_null=False,
|
||||
required=True,
|
||||
label=_('Shipment'),
|
||||
)
|
||||
|
||||
def validate_shipment(self, shipment):
|
||||
"""
|
||||
Validate the shipment:
|
||||
|
||||
- Must point to the same order
|
||||
- Must not be shipped
|
||||
"""
|
||||
|
||||
order = self.context['order']
|
||||
|
||||
if shipment.shipment_date is not None:
|
||||
raise ValidationError(_("Shipment has already been shipped"))
|
||||
|
||||
if shipment.order != order:
|
||||
raise ValidationError(_("Shipment is not associated with this order"))
|
||||
|
||||
return shipment
|
||||
|
||||
def validate(self, data):
|
||||
"""
|
||||
Validation for the serializer:
|
||||
|
||||
- Ensure the serial_numbers and quantity fields match
|
||||
- Check that all serial numbers exist
|
||||
- Check that the serial numbers are not yet allocated
|
||||
"""
|
||||
|
||||
data = super().validate(data)
|
||||
|
||||
line_item = data['line_item']
|
||||
quantity = data['quantity']
|
||||
serial_numbers = data['serial_numbers']
|
||||
|
||||
part = line_item.part
|
||||
|
||||
try:
|
||||
data['serials'] = extract_serial_numbers(serial_numbers, quantity)
|
||||
except DjangoValidationError as e:
|
||||
raise ValidationError({
|
||||
'serial_numbers': e.messages,
|
||||
})
|
||||
|
||||
serials_not_exist = []
|
||||
serials_allocated = []
|
||||
stock_items_to_allocate = []
|
||||
|
||||
for serial in data['serials']:
|
||||
items = stock.models.StockItem.objects.filter(
|
||||
part=part,
|
||||
serial=serial,
|
||||
quantity=1,
|
||||
)
|
||||
|
||||
if not items.exists():
|
||||
serials_not_exist.append(str(serial))
|
||||
continue
|
||||
|
||||
stock_item = items[0]
|
||||
|
||||
if stock_item.unallocated_quantity() == 1:
|
||||
stock_items_to_allocate.append(stock_item)
|
||||
else:
|
||||
serials_allocated.append(str(serial))
|
||||
|
||||
if len(serials_not_exist) > 0:
|
||||
|
||||
error_msg = _("No match found for the following serial numbers")
|
||||
error_msg += ": "
|
||||
error_msg += ",".join(serials_not_exist)
|
||||
|
||||
raise ValidationError({
|
||||
'serial_numbers': error_msg
|
||||
})
|
||||
|
||||
if len(serials_allocated) > 0:
|
||||
|
||||
error_msg = _("The following serial numbers are already allocated")
|
||||
error_msg += ": "
|
||||
error_msg += ",".join(serials_allocated)
|
||||
|
||||
raise ValidationError({
|
||||
'serial_numbers': error_msg,
|
||||
})
|
||||
|
||||
data['stock_items'] = stock_items_to_allocate
|
||||
|
||||
return data
|
||||
|
||||
def save(self):
|
||||
|
||||
data = self.validated_data
|
||||
|
||||
line_item = data['line_item']
|
||||
stock_items = data['stock_items']
|
||||
shipment = data['shipment']
|
||||
|
||||
with transaction.atomic():
|
||||
for stock_item in stock_items:
|
||||
# Create a new SalesOrderAllocation
|
||||
order.models.SalesOrderAllocation.objects.create(
|
||||
line=line_item,
|
||||
item=stock_item,
|
||||
quantity=1,
|
||||
shipment=shipment
|
||||
)
|
||||
|
||||
|
||||
class SOShipmentAllocationSerializer(serializers.Serializer):
|
||||
"""
|
||||
DRF serializer for allocation of stock items against a sales order / shipment
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
'items',
|
||||
'shipment',
|
||||
]
|
||||
|
||||
items = SOShipmentAllocationItemSerializer(many=True)
|
||||
|
||||
shipment = serializers.PrimaryKeyRelatedField(
|
||||
queryset=order.models.SalesOrderShipment.objects.all(),
|
||||
many=False,
|
||||
allow_null=False,
|
||||
required=True,
|
||||
label=_('Shipment'),
|
||||
)
|
||||
|
||||
def validate_shipment(self, shipment):
|
||||
"""
|
||||
Run validation against the provided shipment instance
|
||||
"""
|
||||
|
||||
order = self.context['order']
|
||||
|
||||
if shipment.shipment_date is not None:
|
||||
raise ValidationError(_("Shipment has already been shipped"))
|
||||
|
||||
if shipment.order != order:
|
||||
raise ValidationError(_("Shipment is not associated with this order"))
|
||||
|
||||
return shipment
|
||||
|
||||
def validate(self, data):
|
||||
"""
|
||||
Serializer validation
|
||||
"""
|
||||
|
||||
data = super().validate(data)
|
||||
|
||||
# Extract SalesOrder from serializer context
|
||||
# order = self.context['order']
|
||||
|
||||
items = data.get('items', [])
|
||||
|
||||
if len(items) == 0:
|
||||
raise ValidationError(_('Allocation items must be provided'))
|
||||
|
||||
return data
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
Perform the allocation of items against this order
|
||||
"""
|
||||
|
||||
data = self.validated_data
|
||||
|
||||
items = data['items']
|
||||
shipment = data['shipment']
|
||||
|
||||
with transaction.atomic():
|
||||
for entry in items:
|
||||
# Create a new SalesOrderAllocation
|
||||
order.models.SalesOrderAllocation.objects.create(
|
||||
line=entry.get('line_item'),
|
||||
item=entry.get('stock_item'),
|
||||
quantity=entry.get('quantity'),
|
||||
shipment=shipment,
|
||||
)
|
||||
|
||||
|
||||
class SOAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
"""
|
||||
Serializers for the SalesOrderAttachment model
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = SalesOrderAttachment
|
||||
model = order.models.SalesOrderAttachment
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
|
@ -119,6 +119,18 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<td>{{ order.supplier_reference }}{% include "clip.html"%}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td><span class='fas fa-tasks'></span></td>
|
||||
<td>{% trans "Completed Line Items" %}</td>
|
||||
<td>
|
||||
{{ order.completed_line_count }} / {{ order.line_count }}
|
||||
{% if order.is_complete %}
|
||||
<span class='badge bg-success badge-right rounded-pill'>{% trans "Complete" %}</span>
|
||||
{% else %}
|
||||
<span class='badge bg-danger badge-right rounded-pill'>{% trans "Incomplete" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if order.link %}
|
||||
<tr>
|
||||
<td><span class='fas fa-link'></span></td>
|
||||
|
@ -37,7 +37,7 @@
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='order-toolbar-buttons' class='btn-group' style='float: right;'>
|
||||
{% include "filter_list.html" with id="order-lines" %}
|
||||
{% include "filter_list.html" with id="purchase-order-lines" %}
|
||||
</div>
|
||||
|
||||
<table class='table table-striped table-condensed' id='po-line-table' data-toolbar='#order-toolbar-buttons'>
|
||||
@ -190,6 +190,10 @@ $('#new-po-line').click(function() {
|
||||
$('#receive-selected-items').click(function() {
|
||||
var items = $("#po-line-table").bootstrapTable('getSelections');
|
||||
|
||||
if (items.length == 0) {
|
||||
items = $("#po-line-table").bootstrapTable('getData');
|
||||
}
|
||||
|
||||
receivePurchaseOrderItems(
|
||||
{{ order.id }},
|
||||
items,
|
||||
|
@ -63,8 +63,8 @@ src="{% static 'img/blank_image.png' %}"
|
||||
|
||||
</div>
|
||||
{% if order.status == SalesOrderStatus.PENDING %}
|
||||
<button type='button' class='btn btn-success' id='ship-order' title='{% trans "Ship Order" %}'>
|
||||
<span class='fas fa-truck'></span> {% trans "Ship Order" %}
|
||||
<button type='button' class='btn btn-success' id='complete-order' title='{% trans "Complete Sales Order" %}'{% if not order.is_completed %} disabled{% endif %}>
|
||||
<span class='fas fa-check-circle'></span> {% trans "Complete Order" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@ -123,6 +123,28 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<td>{{ order.customer_reference }}{% include "clip.html"%}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td><span class='fas fa-tasks'></span></td>
|
||||
<td>{% trans "Completed Line Items" %}</td>
|
||||
<td>
|
||||
{{ order.completed_line_count }} / {{ order.line_count }}
|
||||
{% if order.is_completed %}
|
||||
<span class='badge bg-success badge-right rounded-pill'>{% trans "Complete" %}</span>
|
||||
{% else %}
|
||||
<span class='badge bg-danger badge-right rounded-pill'>{% trans "Incomplete" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class='fas fa-truck'></span></td>
|
||||
<td>{% trans "Completed Shipments" %}</td>
|
||||
<td>
|
||||
{{ order.completed_shipment_count }} / {{ order.shipment_count }}
|
||||
{% if order.pending_shipment_count > 0 %}
|
||||
<span class='badge bg-danger badge-right rounded-pill'>{% trans "Incomplete" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if order.link %}
|
||||
<tr>
|
||||
<td><span class='fas fa-link'></span></td>
|
||||
@ -145,15 +167,13 @@ src="{% static 'img/blank_image.png' %}"
|
||||
{% if order.shipment_date %}
|
||||
<tr>
|
||||
<td><span class='fas fa-truck'></span></td>
|
||||
<td>{% trans "Shipped" %}</td>
|
||||
<td>{{ order.shipment_date }}<span class='badge badge-right rounded-pill bg-dark'>{{ order.shipped_by }}</span></td>
|
||||
</tr>
|
||||
<td>{% trans "Completed" %}</td>
|
||||
<td>
|
||||
{{ order.shipment_date }}
|
||||
{% if order.shipped_by %}
|
||||
<span class='badge badge-right rounded-pill bg-dark'>{{ order.shipped_by }}</span>
|
||||
{% endif %}
|
||||
{% if order.status == PurchaseOrderStatus.COMPLETE %}
|
||||
<tr>
|
||||
<td><span class='fas fa-calendar-alt'></span></td>
|
||||
<td>{% trans "Received" %}</td>
|
||||
<td>{{ order.complete_date }}<span class='badge badge-right rounded-pill bg-dark'>{{ order.received_by }}</span></td>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if order.responsible %}
|
||||
@ -203,8 +223,11 @@ $("#cancel-order").click(function() {
|
||||
});
|
||||
});
|
||||
|
||||
$("#ship-order").click(function() {
|
||||
launchModalForm("{% url 'so-ship' order.id %}", {
|
||||
$("#complete-order").click(function() {
|
||||
constructForm('{% url "api-so-complete" order.id %}', {
|
||||
method: 'POST',
|
||||
title: '{% trans "Complete Sales Order" %}',
|
||||
confirm: true,
|
||||
reload: true,
|
||||
});
|
||||
});
|
||||
|
@ -18,7 +18,7 @@
|
||||
<h4>{% trans "Sales Order Items" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% if roles.sales_order.change %}
|
||||
{% if roles.sales_order.change and order.is_pending %}
|
||||
<button type='button' class='btn btn-success' id='new-so-line'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %}
|
||||
</button>
|
||||
@ -37,12 +37,67 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if order.is_pending %}
|
||||
<div class='panel panel-hidden' id='panel-order-shipments'>
|
||||
<div class='panel-heading'>
|
||||
<div class='d-flex flex-row'>
|
||||
<h4>{% trans "Pending Shipments" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% if 0 %}
|
||||
<button id='pending-shipment-options' title='{% trans "Actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'>
|
||||
<span class='fas fa-tools'></span> <span class='caret'></span>
|
||||
</button>
|
||||
<ul class='dropdown-menu' role='menu'>
|
||||
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% if roles.sales_order.change %}
|
||||
<button type='button' class='btn btn-success' id='new-shipment'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "New Shipment" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
{% if roles.sales_order.change %}
|
||||
<div id='pending-shipment-toolbar' class='btn-group' style='float: right;'>
|
||||
<div class='btn-group' role='group'>
|
||||
{% include "filter_list.html" with id="pending-shipments" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<table class='table table-striped table-condensed' id='pending-shipments-table' data-toolbar='#pending-shipment-toolbar'></table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class='panel panel-hidden' id='panel-order-shipments-complete'>
|
||||
<div class='panel-heading'>
|
||||
<h4>{% trans "Completed Shipments" %}</h4>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='completed-shipment-toolbar' class='btn-group' style='float: right;'>
|
||||
<div class='btn-group' role='group'>
|
||||
{% include "filter_list.html" with id="completed-shipments" %}
|
||||
</div>
|
||||
</div>
|
||||
<table class='table table-striped table-condensed' id='completed-shipments-table' data-toolbar='#completed-shipment-toolbar'></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel panel-hidden' id='panel-order-builds'>
|
||||
<div class='panel-heading'>
|
||||
<h4>{% trans "Build Orders" %}</h4>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<table class='table table-striped table-condensed' id='builds-table'></table>
|
||||
<div id='builds-toolbar' class='btn-group' style='float: right;'>
|
||||
<div class='btn-group' role='group'>
|
||||
{% include "filter_list.html" with id='build' %}
|
||||
</div>
|
||||
</div>
|
||||
<table class='table table-striped table-condensed' id='builds-table' data-toolbar='#builds-toolbar'></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -89,6 +144,38 @@
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
// Callback when the "shipments" panel is first loaded
|
||||
onPanelLoad('order-shipments', function() {
|
||||
|
||||
{% if order.is_pending %}
|
||||
loadSalesOrderShipmentTable('#pending-shipments-table', {
|
||||
order: {{ order.pk }},
|
||||
shipped: false,
|
||||
filter_target: '#filter-list-pending-shipments',
|
||||
});
|
||||
|
||||
$('#new-shipment').click(function() {
|
||||
createSalesOrderShipment({
|
||||
order: {{ order.pk }},
|
||||
reference: '{{ order.reference }}',
|
||||
onSuccess: function(data) {
|
||||
$('#pending-shipments-table').bootstrapTable('refresh');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
{% endif %}
|
||||
|
||||
});
|
||||
|
||||
onPanelLoad('order-shipments-complete', function() {
|
||||
loadSalesOrderShipmentTable('#completed-shipments-table', {
|
||||
order: {{ order.pk }},
|
||||
shipped: true,
|
||||
filter_target: '#filter-list-completed-shipments',
|
||||
});
|
||||
});
|
||||
|
||||
$('#edit-notes').click(function() {
|
||||
constructForm('{% url "api-so-detail" order.pk %}', {
|
||||
fields: {
|
||||
|
@ -1,30 +0,0 @@
|
||||
{% extends "modal_form.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
|
||||
{% if not order.is_fully_allocated %}
|
||||
<div class='alert alert-block alert-danger'>
|
||||
<h4>{% trans "Warning" %}</h4>
|
||||
{% trans "This order has not been fully allocated. If the order is marked as shipped, it can no longer be adjusted." %}
|
||||
<br>
|
||||
{% trans "Ensure that the order allocation is correct before shipping the order." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if order.is_over_allocated %}
|
||||
<div class='alert alert-block alert-warning'>
|
||||
{% trans "Some line items in this order have been over-allocated" %}
|
||||
<br>
|
||||
{% trans "Ensure that this is correct before shipping the order." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class='alert alert-block alert-info'>
|
||||
<strong>{% trans "Sales Order" %} {{ order.reference }} - {{ order.customer.name }}</strong>
|
||||
<br>
|
||||
{% trans "Shipping this order means that the order will no longer be editable." %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
@ -1,12 +0,0 @@
|
||||
{% extends "modal_form.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
|
||||
<div class='alert alert-block alert-info'>
|
||||
{% include "hover_image.html" with image=part.image hover=true %}{{ part }}
|
||||
<hr>
|
||||
{% trans "Allocate stock items by serial number" %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
@ -4,6 +4,12 @@
|
||||
|
||||
{% trans "Line Items" as text %}
|
||||
{% include "sidebar_item.html" with label='order-items' text=text icon="fa-list-ol" %}
|
||||
{% if order.is_pending %}
|
||||
{% trans "Pending Shipments" as text %}
|
||||
{% include "sidebar_item.html" with label='order-shipments' text=text icon="fa-truck-loading" %}
|
||||
{% endif %}
|
||||
{% trans "Completed Shipments" as text %}
|
||||
{% include "sidebar_item.html" with label='order-shipments-complete' text=text icon="fa-truck" %}
|
||||
{% trans "Build Orders" as text %}
|
||||
{% include "sidebar_item.html" with label='order-builds' text=text icon="fa-tools" %}
|
||||
{% trans "Attachments" as text %}
|
||||
|
@ -11,9 +11,10 @@ from django.urls import reverse
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
from InvenTree.status_codes import PurchaseOrderStatus
|
||||
|
||||
from part.models import Part
|
||||
from stock.models import StockItem
|
||||
|
||||
from .models import PurchaseOrder, PurchaseOrderLineItem, SalesOrder
|
||||
import order.models as models
|
||||
|
||||
|
||||
class OrderTest(InvenTreeAPITestCase):
|
||||
@ -85,7 +86,7 @@ class PurchaseOrderTest(OrderTest):
|
||||
self.filter({'overdue': True}, 0)
|
||||
self.filter({'overdue': False}, 7)
|
||||
|
||||
order = PurchaseOrder.objects.get(pk=1)
|
||||
order = models.PurchaseOrder.objects.get(pk=1)
|
||||
order.target_date = datetime.now().date() - timedelta(days=10)
|
||||
order.save()
|
||||
|
||||
@ -137,7 +138,7 @@ class PurchaseOrderTest(OrderTest):
|
||||
Test that we can create / edit and delete a PurchaseOrder via the API
|
||||
"""
|
||||
|
||||
n = PurchaseOrder.objects.count()
|
||||
n = models.PurchaseOrder.objects.count()
|
||||
|
||||
url = reverse('api-po-list')
|
||||
|
||||
@ -154,7 +155,7 @@ class PurchaseOrderTest(OrderTest):
|
||||
)
|
||||
|
||||
# And no new PurchaseOrder objects should have been created
|
||||
self.assertEqual(PurchaseOrder.objects.count(), n)
|
||||
self.assertEqual(models.PurchaseOrder.objects.count(), n)
|
||||
|
||||
# Ok, now let's give this user the correct permission
|
||||
self.assignRole('purchase_order.add')
|
||||
@ -171,7 +172,7 @@ class PurchaseOrderTest(OrderTest):
|
||||
expected_code=201
|
||||
)
|
||||
|
||||
self.assertEqual(PurchaseOrder.objects.count(), n + 1)
|
||||
self.assertEqual(models.PurchaseOrder.objects.count(), n + 1)
|
||||
|
||||
pk = response.data['pk']
|
||||
|
||||
@ -186,7 +187,7 @@ class PurchaseOrderTest(OrderTest):
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
self.assertEqual(PurchaseOrder.objects.count(), n + 1)
|
||||
self.assertEqual(models.PurchaseOrder.objects.count(), n + 1)
|
||||
|
||||
url = reverse('api-po-detail', kwargs={'pk': pk})
|
||||
|
||||
@ -217,7 +218,7 @@ class PurchaseOrderTest(OrderTest):
|
||||
response = self.delete(url, expected_code=204)
|
||||
|
||||
# Number of PurchaseOrder objects should have decreased
|
||||
self.assertEqual(PurchaseOrder.objects.count(), n)
|
||||
self.assertEqual(models.PurchaseOrder.objects.count(), n)
|
||||
|
||||
# And if we try to access the detail view again, it has gone
|
||||
response = self.get(url, expected_code=404)
|
||||
@ -256,7 +257,7 @@ class PurchaseOrderReceiveTest(OrderTest):
|
||||
self.n = StockItem.objects.count()
|
||||
|
||||
# Mark the order as "placed" so we can receive line items
|
||||
order = PurchaseOrder.objects.get(pk=1)
|
||||
order = models.PurchaseOrder.objects.get(pk=1)
|
||||
order.status = PurchaseOrderStatus.PLACED
|
||||
order.save()
|
||||
|
||||
@ -453,8 +454,8 @@ class PurchaseOrderReceiveTest(OrderTest):
|
||||
Test receipt of valid data
|
||||
"""
|
||||
|
||||
line_1 = PurchaseOrderLineItem.objects.get(pk=1)
|
||||
line_2 = PurchaseOrderLineItem.objects.get(pk=2)
|
||||
line_1 = models.PurchaseOrderLineItem.objects.get(pk=1)
|
||||
line_2 = models.PurchaseOrderLineItem.objects.get(pk=2)
|
||||
|
||||
self.assertEqual(StockItem.objects.filter(supplier_part=line_1.part).count(), 0)
|
||||
self.assertEqual(StockItem.objects.filter(supplier_part=line_2.part).count(), 0)
|
||||
@ -481,7 +482,7 @@ class PurchaseOrderReceiveTest(OrderTest):
|
||||
|
||||
# Before posting "valid" data, we will mark the purchase order as "pending"
|
||||
# In this case we do expect an error!
|
||||
order = PurchaseOrder.objects.get(pk=1)
|
||||
order = models.PurchaseOrder.objects.get(pk=1)
|
||||
order.status = PurchaseOrderStatus.PENDING
|
||||
order.save()
|
||||
|
||||
@ -507,8 +508,8 @@ class PurchaseOrderReceiveTest(OrderTest):
|
||||
# There should be two newly created stock items
|
||||
self.assertEqual(self.n + 2, StockItem.objects.count())
|
||||
|
||||
line_1 = PurchaseOrderLineItem.objects.get(pk=1)
|
||||
line_2 = PurchaseOrderLineItem.objects.get(pk=2)
|
||||
line_1 = models.PurchaseOrderLineItem.objects.get(pk=1)
|
||||
line_2 = models.PurchaseOrderLineItem.objects.get(pk=2)
|
||||
|
||||
self.assertEqual(line_1.received, 50)
|
||||
self.assertEqual(line_2.received, 250)
|
||||
@ -563,7 +564,7 @@ class SalesOrderTest(OrderTest):
|
||||
self.filter({'overdue': False}, 5)
|
||||
|
||||
for pk in [1, 2]:
|
||||
order = SalesOrder.objects.get(pk=pk)
|
||||
order = models.SalesOrder.objects.get(pk=pk)
|
||||
order.target_date = datetime.now().date() - timedelta(days=10)
|
||||
order.save()
|
||||
|
||||
@ -591,7 +592,7 @@ class SalesOrderTest(OrderTest):
|
||||
Test that we can create / edit and delete a SalesOrder via the API
|
||||
"""
|
||||
|
||||
n = SalesOrder.objects.count()
|
||||
n = models.SalesOrder.objects.count()
|
||||
|
||||
url = reverse('api-so-list')
|
||||
|
||||
@ -621,7 +622,7 @@ class SalesOrderTest(OrderTest):
|
||||
)
|
||||
|
||||
# Check that the new order has been created
|
||||
self.assertEqual(SalesOrder.objects.count(), n + 1)
|
||||
self.assertEqual(models.SalesOrder.objects.count(), n + 1)
|
||||
|
||||
# Grab the PK for the newly created SalesOrder
|
||||
pk = response.data['pk']
|
||||
@ -664,7 +665,7 @@ class SalesOrderTest(OrderTest):
|
||||
response = self.delete(url, expected_code=204)
|
||||
|
||||
# Check that the number of sales orders has decreased
|
||||
self.assertEqual(SalesOrder.objects.count(), n)
|
||||
self.assertEqual(models.SalesOrder.objects.count(), n)
|
||||
|
||||
# And the resource should no longer be available
|
||||
response = self.get(url, expected_code=404)
|
||||
@ -685,3 +686,131 @@ class SalesOrderTest(OrderTest):
|
||||
},
|
||||
expected_code=201
|
||||
)
|
||||
|
||||
|
||||
class SalesOrderAllocateTest(OrderTest):
|
||||
"""
|
||||
Unit tests for allocating stock items against a SalesOrder
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.assignRole('sales_order.add')
|
||||
|
||||
self.url = reverse('api-so-allocate', kwargs={'pk': 1})
|
||||
|
||||
self.order = models.SalesOrder.objects.get(pk=1)
|
||||
|
||||
# Create some line items for this purchase order
|
||||
parts = Part.objects.filter(salable=True)
|
||||
|
||||
for part in parts:
|
||||
|
||||
# Create a new line item
|
||||
models.SalesOrderLineItem.objects.create(
|
||||
order=self.order,
|
||||
part=part,
|
||||
quantity=5,
|
||||
)
|
||||
|
||||
# Ensure we have stock!
|
||||
StockItem.objects.create(
|
||||
part=part,
|
||||
quantity=100,
|
||||
)
|
||||
|
||||
# Create a new shipment against this SalesOrder
|
||||
self.shipment = models.SalesOrderShipment.objects.create(
|
||||
order=self.order,
|
||||
)
|
||||
|
||||
def test_invalid(self):
|
||||
"""
|
||||
Test POST with invalid data
|
||||
"""
|
||||
|
||||
# No data
|
||||
response = self.post(self.url, {}, expected_code=400)
|
||||
|
||||
self.assertIn('This field is required', str(response.data['items']))
|
||||
self.assertIn('This field is required', str(response.data['shipment']))
|
||||
|
||||
# Test with a single line items
|
||||
line = self.order.lines.first()
|
||||
part = line.part
|
||||
|
||||
# Valid stock_item, but quantity is invalid
|
||||
data = {
|
||||
'items': [{
|
||||
"line_item": line.pk,
|
||||
"stock_item": part.stock_items.last().pk,
|
||||
"quantity": 0,
|
||||
}],
|
||||
}
|
||||
|
||||
response = self.post(self.url, data, expected_code=400)
|
||||
|
||||
self.assertIn('Quantity must be positive', str(response.data['items']))
|
||||
|
||||
# Valid stock item, too much quantity
|
||||
data['items'][0]['quantity'] = 250
|
||||
|
||||
response = self.post(self.url, data, expected_code=400)
|
||||
|
||||
self.assertIn('Available quantity (100) exceeded', str(response.data['items']))
|
||||
|
||||
# Valid stock item, valid quantity
|
||||
data['items'][0]['quantity'] = 50
|
||||
|
||||
# Invalid shipment!
|
||||
data['shipment'] = 9999
|
||||
|
||||
response = self.post(self.url, data, expected_code=400)
|
||||
|
||||
self.assertIn('does not exist', str(response.data['shipment']))
|
||||
|
||||
# Valid shipment, but points to the wrong order
|
||||
shipment = models.SalesOrderShipment.objects.create(
|
||||
order=models.SalesOrder.objects.get(pk=2),
|
||||
)
|
||||
|
||||
data['shipment'] = shipment.pk
|
||||
|
||||
response = self.post(self.url, data, expected_code=400)
|
||||
|
||||
self.assertIn('Shipment is not associated with this order', str(response.data['shipment']))
|
||||
|
||||
def test_allocate(self):
|
||||
"""
|
||||
Test the the allocation endpoint acts as expected,
|
||||
when provided with valid data!
|
||||
"""
|
||||
|
||||
# First, check that there are no line items allocated against this SalesOrder
|
||||
self.assertEqual(self.order.stock_allocations.count(), 0)
|
||||
|
||||
data = {
|
||||
"items": [],
|
||||
"shipment": self.shipment.pk,
|
||||
}
|
||||
|
||||
for line in self.order.lines.all():
|
||||
stock_item = line.part.stock_items.last()
|
||||
|
||||
# Fully-allocate each line
|
||||
data['items'].append({
|
||||
"line_item": line.pk,
|
||||
"stock_item": stock_item.pk,
|
||||
"quantity": 5
|
||||
})
|
||||
|
||||
self.post(self.url, data, expected_code=201)
|
||||
|
||||
# There should have been 1 stock item allocated against each line item
|
||||
n_lines = self.order.lines.count()
|
||||
|
||||
self.assertEqual(self.order.stock_allocations.count(), n_lines)
|
||||
|
||||
for line in self.order.lines.all():
|
||||
self.assertEqual(line.allocations.count(), 1)
|
||||
|
@ -4,16 +4,16 @@ Unit tests for the 'order' model data migrations
|
||||
|
||||
from django_test_migrations.contrib.unittest_case import MigratorTestCase
|
||||
|
||||
from InvenTree import helpers
|
||||
from InvenTree.status_codes import SalesOrderStatus
|
||||
|
||||
|
||||
class TestForwardMigrations(MigratorTestCase):
|
||||
class TestRefIntMigrations(MigratorTestCase):
|
||||
"""
|
||||
Test entire schema migration
|
||||
"""
|
||||
|
||||
migrate_from = ('order', helpers.getOldestMigrationFile('order'))
|
||||
migrate_to = ('order', helpers.getNewestMigrationFile('order'))
|
||||
migrate_from = ('order', '0040_salesorder_target_date')
|
||||
migrate_to = ('order', '0061_merge_0054_auto_20211201_2139_0060_auto_20211129_1339')
|
||||
|
||||
def prepare(self):
|
||||
"""
|
||||
@ -26,10 +26,12 @@ class TestForwardMigrations(MigratorTestCase):
|
||||
supplier = Company.objects.create(
|
||||
name='Supplier A',
|
||||
description='A great supplier!',
|
||||
is_supplier=True
|
||||
is_supplier=True,
|
||||
is_customer=True,
|
||||
)
|
||||
|
||||
PurchaseOrder = self.old_state.apps.get_model('order', 'purchaseorder')
|
||||
SalesOrder = self.old_state.apps.get_model('order', 'salesorder')
|
||||
|
||||
# Create some orders
|
||||
for ii in range(10):
|
||||
@ -44,16 +46,79 @@ class TestForwardMigrations(MigratorTestCase):
|
||||
with self.assertRaises(AttributeError):
|
||||
print(order.reference_int)
|
||||
|
||||
sales_order = SalesOrder.objects.create(
|
||||
customer=supplier,
|
||||
reference=f"{ii}-xyz",
|
||||
description="A test sales order",
|
||||
)
|
||||
|
||||
# Initially, the 'reference_int' field is unavailable
|
||||
with self.assertRaises(AttributeError):
|
||||
print(sales_order.reference_int)
|
||||
|
||||
def test_ref_field(self):
|
||||
"""
|
||||
Test that the 'reference_int' field has been created and is filled out correctly
|
||||
"""
|
||||
|
||||
PurchaseOrder = self.new_state.apps.get_model('order', 'purchaseorder')
|
||||
SalesOrder = self.new_state.apps.get_model('order', 'salesorder')
|
||||
|
||||
for ii in range(10):
|
||||
|
||||
order = PurchaseOrder.objects.get(reference=f"{ii}-abcde")
|
||||
po = PurchaseOrder.objects.get(reference=f"{ii}-abcde")
|
||||
so = SalesOrder.objects.get(reference=f"{ii}-xyz")
|
||||
|
||||
# The integer reference field must have been correctly updated
|
||||
self.assertEqual(order.reference_int, ii)
|
||||
self.assertEqual(po.reference_int, ii)
|
||||
self.assertEqual(so.reference_int, ii)
|
||||
|
||||
|
||||
class TestShipmentMigration(MigratorTestCase):
|
||||
"""
|
||||
Test data migration for the "SalesOrderShipment" model
|
||||
"""
|
||||
|
||||
migrate_from = ('order', '0051_auto_20211014_0623')
|
||||
migrate_to = ('order', '0055_auto_20211025_0645')
|
||||
|
||||
def prepare(self):
|
||||
"""
|
||||
Create an initial SalesOrder
|
||||
"""
|
||||
|
||||
Company = self.old_state.apps.get_model('company', 'company')
|
||||
|
||||
customer = Company.objects.create(
|
||||
name='My customer',
|
||||
description='A customer we sell stuff too',
|
||||
is_customer=True
|
||||
)
|
||||
|
||||
SalesOrder = self.old_state.apps.get_model('order', 'salesorder')
|
||||
|
||||
for ii in range(5):
|
||||
order = SalesOrder.objects.create(
|
||||
reference=f'SO{ii}',
|
||||
customer=customer,
|
||||
description='A sales order for stuffs',
|
||||
status=SalesOrderStatus.PENDING,
|
||||
)
|
||||
|
||||
order.save()
|
||||
|
||||
# The "shipment" model does not exist yet
|
||||
with self.assertRaises(LookupError):
|
||||
self.old_state.apps.get_model('order', 'salesordershipment')
|
||||
|
||||
def test_shipment_creation(self):
|
||||
"""
|
||||
Check that a SalesOrderShipment has been created
|
||||
"""
|
||||
|
||||
SalesOrder = self.new_state.apps.get_model('order', 'salesorder')
|
||||
Shipment = self.new_state.apps.get_model('order', 'salesordershipment')
|
||||
|
||||
# Check that the correct number of Shipments have been created
|
||||
self.assertEqual(SalesOrder.objects.count(), 5)
|
||||
self.assertEqual(Shipment.objects.count(), 5)
|
||||
|
@ -7,11 +7,15 @@ from django.core.exceptions import ValidationError
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from company.models import Company
|
||||
from stock.models import StockItem
|
||||
from order.models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation
|
||||
from part.models import Part
|
||||
|
||||
from InvenTree import status_codes as status
|
||||
|
||||
from order.models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation, SalesOrderShipment
|
||||
|
||||
from part.models import Part
|
||||
|
||||
from stock.models import StockItem
|
||||
|
||||
|
||||
class SalesOrderTest(TestCase):
|
||||
"""
|
||||
@ -38,6 +42,12 @@ class SalesOrderTest(TestCase):
|
||||
customer_reference='ABC 55555'
|
||||
)
|
||||
|
||||
# Create a Shipment against this SalesOrder
|
||||
self.shipment = SalesOrderShipment.objects.create(
|
||||
order=self.order,
|
||||
reference='001',
|
||||
)
|
||||
|
||||
# Create a line item
|
||||
self.line = SalesOrderLineItem.objects.create(quantity=50, order=self.order, part=self.part)
|
||||
|
||||
@ -82,11 +92,13 @@ class SalesOrderTest(TestCase):
|
||||
# Allocate stock to the order
|
||||
SalesOrderAllocation.objects.create(
|
||||
line=self.line,
|
||||
shipment=self.shipment,
|
||||
item=StockItem.objects.get(pk=self.Sa.pk),
|
||||
quantity=25)
|
||||
|
||||
SalesOrderAllocation.objects.create(
|
||||
line=self.line,
|
||||
shipment=self.shipment,
|
||||
item=StockItem.objects.get(pk=self.Sb.pk),
|
||||
quantity=25 if full else 20
|
||||
)
|
||||
@ -120,11 +132,14 @@ class SalesOrderTest(TestCase):
|
||||
self.assertEqual(SalesOrderAllocation.objects.count(), 0)
|
||||
self.assertEqual(self.order.status, status.SalesOrderStatus.CANCELLED)
|
||||
|
||||
# Now try to ship it - should fail
|
||||
with self.assertRaises(ValidationError):
|
||||
self.order.ship_order(None)
|
||||
self.order.can_complete(raise_error=True)
|
||||
|
||||
def test_ship_order(self):
|
||||
# Now try to ship it - should fail
|
||||
result = self.order.complete_order(None)
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_complete_order(self):
|
||||
# Allocate line items, then ship the order
|
||||
|
||||
# Assert some stuff before we run the test
|
||||
@ -136,7 +151,25 @@ class SalesOrderTest(TestCase):
|
||||
|
||||
self.assertEqual(SalesOrderAllocation.objects.count(), 2)
|
||||
|
||||
self.order.ship_order(None)
|
||||
# Attempt to complete the order (but shipments are not completed!)
|
||||
result = self.order.complete_order(None)
|
||||
|
||||
self.assertFalse(result)
|
||||
|
||||
self.assertIsNone(self.shipment.shipment_date)
|
||||
self.assertFalse(self.shipment.is_complete())
|
||||
|
||||
# Mark the shipments as complete
|
||||
self.shipment.complete_shipment(None)
|
||||
self.assertTrue(self.shipment.is_complete())
|
||||
|
||||
# Now, should be OK to ship
|
||||
result = self.order.complete_order(None)
|
||||
|
||||
self.assertTrue(result)
|
||||
|
||||
self.assertEqual(self.order.status, status.SalesOrderStatus.SHIPPED)
|
||||
self.assertIsNotNone(self.order.shipment_date)
|
||||
|
||||
# There should now be 4 stock items
|
||||
self.assertEqual(StockItem.objects.count(), 4)
|
||||
@ -158,12 +191,12 @@ class SalesOrderTest(TestCase):
|
||||
self.assertEqual(sa.sales_order, None)
|
||||
self.assertEqual(sb.sales_order, None)
|
||||
|
||||
# And no allocations
|
||||
self.assertEqual(SalesOrderAllocation.objects.count(), 0)
|
||||
# And the allocations still exist
|
||||
self.assertEqual(SalesOrderAllocation.objects.count(), 2)
|
||||
|
||||
self.assertEqual(self.order.status, status.SalesOrderStatus.SHIPPED)
|
||||
|
||||
self.assertTrue(self.order.is_fully_allocated())
|
||||
self.assertTrue(self.line.is_fully_allocated())
|
||||
self.assertEqual(self.line.fulfilled_quantity(), 50)
|
||||
self.assertEqual(self.line.allocated_quantity(), 0)
|
||||
self.assertEqual(self.line.allocated_quantity(), 50)
|
||||
|
@ -35,18 +35,12 @@ purchase_order_urls = [
|
||||
|
||||
sales_order_detail_urls = [
|
||||
url(r'^cancel/', views.SalesOrderCancel.as_view(), name='so-cancel'),
|
||||
url(r'^ship/', views.SalesOrderShip.as_view(), name='so-ship'),
|
||||
url(r'^export/', views.SalesOrderExport.as_view(), name='so-export'),
|
||||
|
||||
url(r'^.*$', views.SalesOrderDetail.as_view(), name='so-detail'),
|
||||
]
|
||||
|
||||
sales_order_urls = [
|
||||
# URLs for sales order allocations
|
||||
url(r'^allocation/', include([
|
||||
url(r'^assign-serials/', views.SalesOrderAssignSerials.as_view(), name='so-assign-serials'),
|
||||
])),
|
||||
|
||||
# Display detail view for a single SalesOrder
|
||||
url(r'^(?P<pk>\d+)/', include(sales_order_detail_urls)),
|
||||
|
||||
|
@ -9,12 +9,10 @@ from django.db import transaction
|
||||
from django.db.utils import IntegrityError
|
||||
from django.http.response import JsonResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.urls import reverse
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.views.generic import DetailView, ListView
|
||||
from django.views.generic.edit import FormMixin
|
||||
from django.forms import HiddenInput, IntegerField
|
||||
|
||||
import logging
|
||||
@ -22,7 +20,6 @@ from decimal import Decimal, InvalidOperation
|
||||
|
||||
from .models import PurchaseOrder, PurchaseOrderLineItem
|
||||
from .models import SalesOrder, SalesOrderLineItem
|
||||
from .models import SalesOrderAllocation
|
||||
from .admin import POLineItemResource, SOLineItemResource
|
||||
from build.models import Build
|
||||
from company.models import Company, SupplierPart # ManufacturerPart
|
||||
@ -38,7 +35,6 @@ from part.views import PartPricing
|
||||
|
||||
from InvenTree.views import AjaxView, AjaxUpdateView
|
||||
from InvenTree.helpers import DownloadFile, str2bool
|
||||
from InvenTree.helpers import extract_serial_numbers
|
||||
from InvenTree.views import InvenTreeRoleMixin
|
||||
|
||||
from InvenTree.status_codes import PurchaseOrderStatus
|
||||
@ -213,48 +209,6 @@ class PurchaseOrderComplete(AjaxUpdateView):
|
||||
}
|
||||
|
||||
|
||||
class SalesOrderShip(AjaxUpdateView):
|
||||
""" View for 'shipping' a SalesOrder """
|
||||
form_class = order_forms.ShipSalesOrderForm
|
||||
model = SalesOrder
|
||||
context_object_name = 'order'
|
||||
ajax_template_name = 'order/sales_order_ship.html'
|
||||
ajax_form_title = _('Ship Order')
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
self.request = request
|
||||
|
||||
order = self.get_object()
|
||||
self.object = order
|
||||
|
||||
form = self.get_form()
|
||||
|
||||
confirm = str2bool(request.POST.get('confirm', False))
|
||||
|
||||
valid = False
|
||||
|
||||
if not confirm:
|
||||
form.add_error('confirm', _('Confirm order shipment'))
|
||||
else:
|
||||
valid = True
|
||||
|
||||
if valid:
|
||||
if not order.ship_order(request.user):
|
||||
form.add_error(None, _('Could not ship order'))
|
||||
valid = False
|
||||
|
||||
data = {
|
||||
'form_valid': valid,
|
||||
}
|
||||
|
||||
context = self.get_context_data()
|
||||
|
||||
context['order'] = order
|
||||
|
||||
return self.renderJsonResponse(request, form, data, context)
|
||||
|
||||
|
||||
class PurchaseOrderUpload(FileManagementFormView):
|
||||
''' PurchaseOrder: Upload file, match to fields and parts (using multi-Step form) '''
|
||||
|
||||
@ -834,174 +788,6 @@ class OrderParts(AjaxView):
|
||||
order.add_line_item(supplier_part, quantity, purchase_price=purchase_price)
|
||||
|
||||
|
||||
class SalesOrderAssignSerials(AjaxView, FormMixin):
|
||||
"""
|
||||
View for assigning stock items to a sales order,
|
||||
by serial number lookup.
|
||||
"""
|
||||
|
||||
model = SalesOrderAllocation
|
||||
role_required = 'sales_order.change'
|
||||
ajax_template_name = 'order/so_allocate_by_serial.html'
|
||||
ajax_form_title = _('Allocate Serial Numbers')
|
||||
form_class = order_forms.AllocateSerialsToSalesOrderForm
|
||||
|
||||
# Keep track of SalesOrderLineItem and Part references
|
||||
line = None
|
||||
part = None
|
||||
|
||||
def get_initial(self):
|
||||
"""
|
||||
Initial values are passed as query params
|
||||
"""
|
||||
|
||||
initials = super().get_initial()
|
||||
|
||||
try:
|
||||
self.line = SalesOrderLineItem.objects.get(pk=self.request.GET.get('line', None))
|
||||
initials['line'] = self.line
|
||||
except (ValueError, SalesOrderLineItem.DoesNotExist):
|
||||
pass
|
||||
|
||||
try:
|
||||
self.part = Part.objects.get(pk=self.request.GET.get('part', None))
|
||||
initials['part'] = self.part
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
pass
|
||||
|
||||
return initials
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
self.form = self.get_form()
|
||||
|
||||
# Validate the form
|
||||
self.form.is_valid()
|
||||
self.validate()
|
||||
|
||||
valid = self.form.is_valid()
|
||||
|
||||
if valid:
|
||||
self.allocate_items()
|
||||
|
||||
data = {
|
||||
'form_valid': valid,
|
||||
'form_errors': self.form.errors.as_json(),
|
||||
'non_field_errors': self.form.non_field_errors().as_json(),
|
||||
'success': _("Allocated {n} items").format(n=len(self.stock_items))
|
||||
}
|
||||
|
||||
return self.renderJsonResponse(request, self.form, data)
|
||||
|
||||
def validate(self):
|
||||
|
||||
data = self.form.cleaned_data
|
||||
|
||||
# Extract hidden fields from posted data
|
||||
self.line = data.get('line', None)
|
||||
self.part = data.get('part', None)
|
||||
|
||||
if self.line:
|
||||
self.form.fields['line'].widget = HiddenInput()
|
||||
else:
|
||||
self.form.add_error('line', _('Select line item'))
|
||||
|
||||
if self.part:
|
||||
self.form.fields['part'].widget = HiddenInput()
|
||||
else:
|
||||
self.form.add_error('part', _('Select part'))
|
||||
|
||||
if not self.form.is_valid():
|
||||
return
|
||||
|
||||
# Form is otherwise valid - check serial numbers
|
||||
serials = data.get('serials', '')
|
||||
quantity = data.get('quantity', 1)
|
||||
|
||||
# Save a list of serial_numbers
|
||||
self.serial_numbers = None
|
||||
self.stock_items = []
|
||||
|
||||
try:
|
||||
self.serial_numbers = extract_serial_numbers(serials, quantity)
|
||||
|
||||
for serial in self.serial_numbers:
|
||||
try:
|
||||
# Find matching stock item
|
||||
stock_item = StockItem.objects.get(
|
||||
part=self.part,
|
||||
serial=serial
|
||||
)
|
||||
except StockItem.DoesNotExist:
|
||||
self.form.add_error(
|
||||
'serials',
|
||||
_('No matching item for serial {serial}').format(serial=serial)
|
||||
)
|
||||
continue
|
||||
|
||||
# Now we have a valid stock item - but can it be added to the sales order?
|
||||
|
||||
# If not in stock, cannot be added to the order
|
||||
if not stock_item.in_stock:
|
||||
self.form.add_error(
|
||||
'serials',
|
||||
_('{serial} is not in stock').format(serial=serial)
|
||||
)
|
||||
continue
|
||||
|
||||
# Already allocated to an order
|
||||
if stock_item.is_allocated():
|
||||
self.form.add_error(
|
||||
'serials',
|
||||
_('{serial} already allocated to an order').format(serial=serial)
|
||||
)
|
||||
continue
|
||||
|
||||
# Add it to the list!
|
||||
self.stock_items.append(stock_item)
|
||||
|
||||
except ValidationError as e:
|
||||
self.form.add_error('serials', e.messages)
|
||||
|
||||
def allocate_items(self):
|
||||
"""
|
||||
Create stock item allocations for each selected serial number
|
||||
"""
|
||||
|
||||
for stock_item in self.stock_items:
|
||||
SalesOrderAllocation.objects.create(
|
||||
item=stock_item,
|
||||
line=self.line,
|
||||
quantity=1,
|
||||
)
|
||||
|
||||
def get_form(self):
|
||||
|
||||
form = super().get_form()
|
||||
|
||||
if self.line:
|
||||
form.fields['line'].widget = HiddenInput()
|
||||
|
||||
if self.part:
|
||||
form.fields['part'].widget = HiddenInput()
|
||||
|
||||
return form
|
||||
|
||||
def get_context_data(self):
|
||||
return {
|
||||
'line': self.line,
|
||||
'part': self.part,
|
||||
}
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
|
||||
return self.renderJsonResponse(
|
||||
request,
|
||||
self.get_form(),
|
||||
context=self.get_context_data(),
|
||||
)
|
||||
|
||||
|
||||
class LineItemPricing(PartPricing):
|
||||
""" View for inspecting part pricing information """
|
||||
|
||||
|
@ -69,6 +69,7 @@
|
||||
name: 'Widget'
|
||||
description: 'A watchamacallit'
|
||||
category: 7
|
||||
salable: true
|
||||
assembly: true
|
||||
trackable: true
|
||||
tree_id: 0
|
||||
@ -83,6 +84,7 @@
|
||||
name: 'Orphan'
|
||||
description: 'A part without a category'
|
||||
category: null
|
||||
salable: true
|
||||
tree_id: 0
|
||||
level: 0
|
||||
lft: 0
|
||||
@ -95,6 +97,7 @@
|
||||
name: 'Bob'
|
||||
description: 'Can we build it?'
|
||||
assembly: true
|
||||
salable: true
|
||||
purchaseable: false
|
||||
category: 7
|
||||
active: False
|
||||
@ -113,6 +116,7 @@
|
||||
description: 'A chair'
|
||||
is_template: True
|
||||
trackable: true
|
||||
salable: true
|
||||
category: 7
|
||||
tree_id: 1
|
||||
level: 0
|
||||
|
@ -10,7 +10,7 @@ from datetime import datetime, timedelta
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.conf.urls import url, include
|
||||
from django.http import JsonResponse
|
||||
from django.db.models import Q
|
||||
from django.db.models import Q, F
|
||||
from django.db import transaction
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
@ -304,6 +304,24 @@ class StockFilter(rest_filters.FilterSet):
|
||||
|
||||
return queryset
|
||||
|
||||
available = rest_filters.BooleanFilter(label='Available', method='filter_available')
|
||||
|
||||
def filter_available(self, queryset, name, value):
|
||||
"""
|
||||
Filter by whether the StockItem is "available" or not.
|
||||
|
||||
Here, "available" means that the allocated quantity is less than the total quantity
|
||||
"""
|
||||
|
||||
if str2bool(value):
|
||||
# The 'quantity' field is greater than the calculated 'allocated' field
|
||||
queryset = queryset.filter(Q(quantity__gt=F('allocated')))
|
||||
else:
|
||||
# The 'quantity' field is less than (or equal to) the calculated 'allocated' field
|
||||
queryset = queryset.filter(Q(quantity__lte=F('allocated')))
|
||||
|
||||
return queryset
|
||||
|
||||
batch = rest_filters.CharFilter(label="Batch code filter (case insensitive)", lookup_expr='iexact')
|
||||
|
||||
batch_regex = rest_filters.CharFilter(label="Batch code filter (regex)", field_name='batch', lookup_expr='iregex')
|
||||
|
@ -252,7 +252,7 @@
|
||||
<div class='alert alert-block alert-info'>
|
||||
{% object_link 'so-detail' allocation.line.order.id allocation.line.order as link %}
|
||||
{% decimal allocation.quantity as qty %}
|
||||
{% blocktrans %}This stock item is allocated to Sales Order {{ link }} (Quantity: {{ qty }}){% endblocktrans %}
|
||||
{% trans "This stock item is allocated to Sales Order" %} {{ link }} {% if qty < item.quantity %}({% trans "Quantity" %}: {{ qty }}){% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
@ -260,7 +260,7 @@
|
||||
<div class='alert alert-block alert-info'>
|
||||
{% object_link 'build-detail' allocation.build.id allocation.build %}
|
||||
{% decimal allocation.quantity as qty %}
|
||||
{% blocktrans %}This stock item is allocated to Build {{ link }} (Quantity: {{ qty }}){% endblocktrans %}
|
||||
{% trans "This stock item is allocated to Build Order" %} {{ link }} {% if qty < item.quantity %}({% trans "Quantity" %}: {{ qty }}){% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
|
@ -1226,7 +1226,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
*
|
||||
* options:
|
||||
* - output: ID / PK of the associated build output (or null for untracked items)
|
||||
* - source_location: ID / PK of the top-level StockLocation to take parts from (or null)
|
||||
* - source_location: ID / PK of the top-level StockLocation to source stock from (or null)
|
||||
*/
|
||||
function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
|
||||
|
||||
@ -1339,7 +1339,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
|
||||
|
||||
var html = ``;
|
||||
|
||||
// Render a "take from" input
|
||||
// Render a "source location" input
|
||||
html += constructField(
|
||||
'take_from',
|
||||
{
|
||||
@ -1397,6 +1397,13 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
|
||||
options,
|
||||
);
|
||||
|
||||
// Add callback to "clear" button for take_from field
|
||||
addClearCallback(
|
||||
'take_from',
|
||||
take_from_field,
|
||||
options,
|
||||
);
|
||||
|
||||
// Initialize stock item fields
|
||||
bom_items.forEach(function(bom_item) {
|
||||
initializeRelatedField(
|
||||
@ -1406,6 +1413,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
|
||||
filters: {
|
||||
bom_item: bom_item.pk,
|
||||
in_stock: true,
|
||||
available: true,
|
||||
part_detail: true,
|
||||
location_detail: true,
|
||||
},
|
||||
@ -1456,14 +1464,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
|
||||
);
|
||||
});
|
||||
|
||||
// Add callback to "clear" button for take_from field
|
||||
addClearCallback(
|
||||
'take_from',
|
||||
take_from_field,
|
||||
options,
|
||||
);
|
||||
|
||||
// Add button callbacks
|
||||
// Add remove-row button callbacks
|
||||
$(options.modal).find('.button-row-remove').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
|
@ -730,6 +730,9 @@ function submitFormData(fields, options) {
|
||||
data = options.processBeforeUpload(data);
|
||||
}
|
||||
|
||||
// Show the progress spinner
|
||||
$(options.modal).find('#modal-progress-spinner').show();
|
||||
|
||||
// Submit data
|
||||
upload_func(
|
||||
options.url,
|
||||
@ -737,10 +740,13 @@ function submitFormData(fields, options) {
|
||||
{
|
||||
method: options.method,
|
||||
success: function(response) {
|
||||
$(options.modal).find('#modal-progress-spinner').hide();
|
||||
handleFormSuccess(response, options);
|
||||
},
|
||||
error: function(xhr) {
|
||||
|
||||
$(options.modal).find('#modal-progress-spinner').hide();
|
||||
|
||||
switch (xhr.status) {
|
||||
case 400:
|
||||
handleFormErrors(xhr.responseJSON, fields, options);
|
||||
@ -1713,6 +1719,9 @@ function renderModelData(name, model, data, parameters, options) {
|
||||
case 'salesorder':
|
||||
renderer = renderSalesOrder;
|
||||
break;
|
||||
case 'salesordershipment':
|
||||
renderer = renderSalesOrderShipment;
|
||||
break;
|
||||
case 'manufacturerpart':
|
||||
renderer = renderManufacturerPart;
|
||||
break;
|
||||
|
@ -72,6 +72,7 @@ function createNewModal(options={}) {
|
||||
<!-- Extra buttons can be inserted here -->
|
||||
</div>
|
||||
<span class='flex-item' style='flex-grow: 1;'></span>
|
||||
<h4><span id='modal-progress-spinner' class='fas fa-circle-notch fa-spin' style='display: none;'></span></h4>
|
||||
<button type='button' class='btn btn-secondary' id='modal-form-close' data-bs-dismiss='modal'>{% trans "Cancel" %}</button>
|
||||
<button type='button' class='btn btn-primary' id='modal-form-submit'>{% trans "Submit" %}</button>
|
||||
</div>
|
||||
|
@ -241,6 +241,23 @@ function renderSalesOrder(name, data, parameters, options) {
|
||||
}
|
||||
|
||||
|
||||
// Renderer for "SalesOrderShipment" model
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
function renderSalesOrderShipment(name, data, parameters, options) {
|
||||
|
||||
var so_prefix = global_settings.SALESORDER_REFERENCE_PREFIX;
|
||||
|
||||
var html = `
|
||||
<span>${so_prefix}${data.order_detail.reference} - {% trans "Shipment" %} ${data.reference}</span>
|
||||
<span class='float-right'>
|
||||
<small>{% trans "Shipment ID" %}: ${data.pk}</small>
|
||||
</span>
|
||||
`;
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
|
||||
// Renderer for "PartCategory" model
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
function renderPartCategory(name, data, parameters, options) {
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -95,6 +95,9 @@ function serializeStockItem(pk, options={}) {
|
||||
});
|
||||
}
|
||||
|
||||
options.confirm = true;
|
||||
options.confirmMessage = '{% trans "Confirm Stock Serialization" %}';
|
||||
|
||||
constructForm(url, options);
|
||||
}
|
||||
|
||||
@ -1275,7 +1278,14 @@ function loadStockTable(table, options) {
|
||||
}
|
||||
|
||||
if (row.allocated) {
|
||||
html += makeIconBadge('fa-bookmark', '{% trans "Stock item has been allocated" %}');
|
||||
|
||||
if (row.serial != null && row.quantity == 1) {
|
||||
html += makeIconBadge('fa-bookmark icon-yellow', '{% trans "Serialized stock item has been allocated" %}');
|
||||
} else if (row.allocated >= row.quantity) {
|
||||
html += makeIconBadge('fa-bookmark icon-yellow', '{% trans "Stock item has been fully allocated" %}');
|
||||
} else {
|
||||
html += makeIconBadge('fa-bookmark', '{% trans "Stock item has been partially allocated" %}');
|
||||
}
|
||||
}
|
||||
|
||||
if (row.belongs_to) {
|
||||
|
@ -173,6 +173,11 @@ function getAvailableTableFilters(tableKey) {
|
||||
title: '{% trans "Is allocated" %}',
|
||||
description: '{% trans "Item has been allocated" %}',
|
||||
},
|
||||
available: {
|
||||
type: 'bool',
|
||||
title: '{% trans "Available" %}',
|
||||
description: '{% trans "Stock is available for use" %}',
|
||||
},
|
||||
cascade: {
|
||||
type: 'bool',
|
||||
title: '{% trans "Include sublocations" %}',
|
||||
@ -305,6 +310,7 @@ function getAvailableTableFilters(tableKey) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Filters for the PurchaseOrder table
|
||||
if (tableKey == 'purchaseorder') {
|
||||
|
||||
@ -341,6 +347,15 @@ function getAvailableTableFilters(tableKey) {
|
||||
};
|
||||
}
|
||||
|
||||
if (tableKey == 'salesorderlineitem') {
|
||||
return {
|
||||
completed: {
|
||||
type: 'bool',
|
||||
title: '{% trans "Completed" %}',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (tableKey == 'supplier-part') {
|
||||
return {
|
||||
active: {
|
||||
|
@ -134,9 +134,10 @@ class RuleSet(models.Model):
|
||||
'sales_order': [
|
||||
'company_company',
|
||||
'order_salesorder',
|
||||
'order_salesorderallocation',
|
||||
'order_salesorderattachment',
|
||||
'order_salesorderlineitem',
|
||||
'order_salesorderallocation',
|
||||
'order_salesordershipment',
|
||||
]
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user