mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Adds API endpoint to allocate stock items against a SalesOrder
- SalesOrderAllocations are no longer created manually - API endpoint performs data validation - Multiple line items can be allocated at once - Adds unit testing for new API endpoint
This commit is contained in:
parent
dd5eeb7c61
commit
bff9f0828a
@ -422,7 +422,7 @@ class BuildAllocationSerializer(serializers.Serializer):
|
||||
Validation
|
||||
"""
|
||||
|
||||
super().validate(data)
|
||||
data = super().validate(data)
|
||||
|
||||
items = data.get('items', [])
|
||||
|
||||
|
@ -13,25 +13,17 @@ from rest_framework import generics
|
||||
from rest_framework import filters, status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from company.models import SupplierPart
|
||||
|
||||
from InvenTree.filters import InvenTreeOrderingFilter
|
||||
from InvenTree.helpers import str2bool
|
||||
from InvenTree.api import AttachmentMixin
|
||||
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus
|
||||
|
||||
import order.models as models
|
||||
import order.serializers as serializers
|
||||
|
||||
from part.models import Part
|
||||
from company.models import SupplierPart
|
||||
|
||||
from .models import PurchaseOrder, PurchaseOrderLineItem
|
||||
from .models import PurchaseOrderAttachment
|
||||
from .serializers import POSerializer, POLineItemSerializer, POAttachmentSerializer
|
||||
|
||||
from .models import SalesOrder, SalesOrderLineItem, SalesOrderShipment, SalesOrderAllocation
|
||||
from .models import SalesOrderAttachment
|
||||
|
||||
from .serializers import SalesOrderSerializer, SOLineItemSerializer, SOAttachmentSerializer
|
||||
from .serializers import SalesOrderShipmentSerializer, SalesOrderAllocationSerializer
|
||||
from .serializers import POReceiveSerializer
|
||||
|
||||
|
||||
class POList(generics.ListCreateAPIView):
|
||||
@ -41,8 +33,8 @@ class POList(generics.ListCreateAPIView):
|
||||
- POST: Create a new PurchaseOrder object
|
||||
"""
|
||||
|
||||
queryset = PurchaseOrder.objects.all()
|
||||
serializer_class = POSerializer
|
||||
queryset = models.PurchaseOrder.objects.all()
|
||||
serializer_class = serializers.POSerializer
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""
|
||||
@ -79,7 +71,7 @@ class POList(generics.ListCreateAPIView):
|
||||
'lines',
|
||||
)
|
||||
|
||||
queryset = POSerializer.annotate_queryset(queryset)
|
||||
queryset = serializers.POSerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
@ -108,9 +100,9 @@ class POList(generics.ListCreateAPIView):
|
||||
overdue = str2bool(overdue)
|
||||
|
||||
if overdue:
|
||||
queryset = queryset.filter(PurchaseOrder.OVERDUE_FILTER)
|
||||
queryset = queryset.filter(models.PurchaseOrder.OVERDUE_FILTER)
|
||||
else:
|
||||
queryset = queryset.exclude(PurchaseOrder.OVERDUE_FILTER)
|
||||
queryset = queryset.exclude(models.PurchaseOrder.OVERDUE_FILTER)
|
||||
|
||||
# Special filtering for 'status' field
|
||||
status = params.get('status', None)
|
||||
@ -144,7 +136,7 @@ class POList(generics.ListCreateAPIView):
|
||||
max_date = params.get('max_date', None)
|
||||
|
||||
if min_date is not None and max_date is not None:
|
||||
queryset = PurchaseOrder.filterByDate(queryset, min_date, max_date)
|
||||
queryset = models.PurchaseOrder.filterByDate(queryset, min_date, max_date)
|
||||
|
||||
return queryset
|
||||
|
||||
@ -184,8 +176,8 @@ class POList(generics.ListCreateAPIView):
|
||||
class PODetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
""" API endpoint for detail view of a PurchaseOrder object """
|
||||
|
||||
queryset = PurchaseOrder.objects.all()
|
||||
serializer_class = POSerializer
|
||||
queryset = models.PurchaseOrder.objects.all()
|
||||
serializer_class = serializers.POSerializer
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
|
||||
@ -208,7 +200,7 @@ class PODetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
'lines',
|
||||
)
|
||||
|
||||
queryset = POSerializer.annotate_queryset(queryset)
|
||||
queryset = serializers.POSerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
@ -226,9 +218,9 @@ class POReceive(generics.CreateAPIView):
|
||||
- A global location can also be specified
|
||||
"""
|
||||
|
||||
queryset = PurchaseOrderLineItem.objects.none()
|
||||
queryset = models.PurchaseOrderLineItem.objects.none()
|
||||
|
||||
serializer_class = POReceiveSerializer
|
||||
serializer_class = serializers.POReceiveSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
|
||||
@ -236,7 +228,7 @@ class POReceive(generics.CreateAPIView):
|
||||
|
||||
# Pass the purchase order through to the serializer for validation
|
||||
try:
|
||||
context['order'] = PurchaseOrder.objects.get(pk=self.kwargs.get('pk', None))
|
||||
context['order'] = models.PurchaseOrder.objects.get(pk=self.kwargs.get('pk', None))
|
||||
except:
|
||||
pass
|
||||
|
||||
@ -251,7 +243,7 @@ class POLineItemFilter(rest_filters.FilterSet):
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrderLineItem
|
||||
model = models.PurchaseOrderLineItem
|
||||
fields = [
|
||||
'order',
|
||||
'part'
|
||||
@ -285,15 +277,15 @@ class POLineItemList(generics.ListCreateAPIView):
|
||||
- POST: Create a new PurchaseOrderLineItem object
|
||||
"""
|
||||
|
||||
queryset = PurchaseOrderLineItem.objects.all()
|
||||
serializer_class = POLineItemSerializer
|
||||
queryset = models.PurchaseOrderLineItem.objects.all()
|
||||
serializer_class = serializers.POLineItemSerializer
|
||||
filterset_class = POLineItemFilter
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
queryset = POLineItemSerializer.annotate_queryset(queryset)
|
||||
queryset = serializers.POLineItemSerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
@ -350,14 +342,14 @@ class POLineItemDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
Detail API endpoint for PurchaseOrderLineItem object
|
||||
"""
|
||||
|
||||
queryset = PurchaseOrderLineItem.objects.all()
|
||||
serializer_class = POLineItemSerializer
|
||||
queryset = models.PurchaseOrderLineItem.objects.all()
|
||||
serializer_class = serializers.POLineItemSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
queryset = super().get_queryset()
|
||||
|
||||
queryset = POLineItemSerializer.annotate_queryset(queryset)
|
||||
queryset = serializers.POLineItemSerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
@ -367,8 +359,8 @@ class SOAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
||||
API endpoint for listing (and creating) a SalesOrderAttachment (file upload)
|
||||
"""
|
||||
|
||||
queryset = SalesOrderAttachment.objects.all()
|
||||
serializer_class = SOAttachmentSerializer
|
||||
queryset = models.SalesOrderAttachment.objects.all()
|
||||
serializer_class = serializers.SOAttachmentSerializer
|
||||
|
||||
filter_backends = [
|
||||
rest_filters.DjangoFilterBackend,
|
||||
@ -384,8 +376,8 @@ class SOAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin)
|
||||
Detail endpoint for SalesOrderAttachment
|
||||
"""
|
||||
|
||||
queryset = SalesOrderAttachment.objects.all()
|
||||
serializer_class = SOAttachmentSerializer
|
||||
queryset = models.SalesOrderAttachment.objects.all()
|
||||
serializer_class = serializers.SOAttachmentSerializer
|
||||
|
||||
|
||||
class SOList(generics.ListCreateAPIView):
|
||||
@ -396,8 +388,8 @@ class SOList(generics.ListCreateAPIView):
|
||||
- POST: Create a new SalesOrder
|
||||
"""
|
||||
|
||||
queryset = SalesOrder.objects.all()
|
||||
serializer_class = SalesOrderSerializer
|
||||
queryset = models.SalesOrder.objects.all()
|
||||
serializer_class = serializers.SalesOrderSerializer
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""
|
||||
@ -434,7 +426,7 @@ class SOList(generics.ListCreateAPIView):
|
||||
'lines'
|
||||
)
|
||||
|
||||
queryset = SalesOrderSerializer.annotate_queryset(queryset)
|
||||
queryset = serializers.SalesOrderSerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
@ -454,9 +446,9 @@ class SOList(generics.ListCreateAPIView):
|
||||
outstanding = str2bool(outstanding)
|
||||
|
||||
if outstanding:
|
||||
queryset = queryset.filter(status__in=SalesOrderStatus.OPEN)
|
||||
queryset = queryset.filter(status__in=models.SalesOrderStatus.OPEN)
|
||||
else:
|
||||
queryset = queryset.exclude(status__in=SalesOrderStatus.OPEN)
|
||||
queryset = queryset.exclude(status__in=models.SalesOrderStatus.OPEN)
|
||||
|
||||
# Filter by 'overdue' status
|
||||
overdue = params.get('overdue', None)
|
||||
@ -465,9 +457,9 @@ class SOList(generics.ListCreateAPIView):
|
||||
overdue = str2bool(overdue)
|
||||
|
||||
if overdue:
|
||||
queryset = queryset.filter(SalesOrder.OVERDUE_FILTER)
|
||||
queryset = queryset.filter(models.SalesOrder.OVERDUE_FILTER)
|
||||
else:
|
||||
queryset = queryset.exclude(SalesOrder.OVERDUE_FILTER)
|
||||
queryset = queryset.exclude(models.SalesOrder.OVERDUE_FILTER)
|
||||
|
||||
status = params.get('status', None)
|
||||
|
||||
@ -490,7 +482,7 @@ class SOList(generics.ListCreateAPIView):
|
||||
max_date = params.get('max_date', None)
|
||||
|
||||
if min_date is not None and max_date is not None:
|
||||
queryset = SalesOrder.filterByDate(queryset, min_date, max_date)
|
||||
queryset = models.SalesOrder.filterByDate(queryset, min_date, max_date)
|
||||
|
||||
return queryset
|
||||
|
||||
@ -534,8 +526,8 @@ class SODetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
API endpoint for detail view of a SalesOrder object.
|
||||
"""
|
||||
|
||||
queryset = SalesOrder.objects.all()
|
||||
serializer_class = SalesOrderSerializer
|
||||
queryset = models.SalesOrder.objects.all()
|
||||
serializer_class = serializers.SalesOrderSerializer
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
|
||||
@ -554,7 +546,7 @@ class SODetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
|
||||
queryset = queryset.prefetch_related('customer', 'lines')
|
||||
|
||||
queryset = SalesOrderSerializer.annotate_queryset(queryset)
|
||||
queryset = serializers.SalesOrderSerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
@ -564,8 +556,8 @@ class SOLineItemList(generics.ListCreateAPIView):
|
||||
API endpoint for accessing a list of SalesOrderLineItem objects.
|
||||
"""
|
||||
|
||||
queryset = SalesOrderLineItem.objects.all()
|
||||
serializer_class = SOLineItemSerializer
|
||||
queryset = models.SalesOrderLineItem.objects.all()
|
||||
serializer_class = serializers.SOLineItemSerializer
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
|
||||
@ -624,8 +616,34 @@ class SOLineItemList(generics.ListCreateAPIView):
|
||||
class SOLineItemDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
""" API endpoint for detail view of a SalesOrderLineItem object """
|
||||
|
||||
queryset = SalesOrderLineItem.objects.all()
|
||||
serializer_class = SOLineItemSerializer
|
||||
queryset = models.SalesOrderLineItem.objects.all()
|
||||
serializer_class = serializers.SOLineItemSerializer
|
||||
|
||||
|
||||
class SalesOrderAllocate(generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint to allocate stock items against a SalesOrder
|
||||
|
||||
- The SalesOrder is specified in the URL
|
||||
- See the SOShipmentAllocationSerializer class
|
||||
"""
|
||||
|
||||
queryset = models.SalesOrder.objects.none()
|
||||
serializer_class = serializers.SOShipmentAllocationSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
|
||||
ctx = super().get_serializer_context()
|
||||
|
||||
# Pass through the SalesOrder object to the serializer
|
||||
try:
|
||||
ctx['order'] = models.SalesOrder.objects.get(pk=self.kwargs.get('pk', None))
|
||||
except:
|
||||
pass
|
||||
|
||||
ctx['request'] = self.request
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
class SOAllocationDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
@ -633,17 +651,17 @@ class SOAllocationDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
API endpoint for detali view of a SalesOrderAllocation object
|
||||
"""
|
||||
|
||||
queryset = SalesOrderAllocation.objects.all()
|
||||
serializer_class = SalesOrderAllocationSerializer
|
||||
queryset = models.SalesOrderAllocation.objects.all()
|
||||
serializer_class = serializers.SalesOrderAllocationSerializer
|
||||
|
||||
|
||||
class SOAllocationList(generics.ListCreateAPIView):
|
||||
class SOAllocationList(generics.ListAPIView):
|
||||
"""
|
||||
API endpoint for listing SalesOrderAllocation objects
|
||||
"""
|
||||
|
||||
queryset = SalesOrderAllocation.objects.all()
|
||||
serializer_class = SalesOrderAllocationSerializer
|
||||
queryset = models.SalesOrderAllocation.objects.all()
|
||||
serializer_class = serializers.SalesOrderAllocationSerializer
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
|
||||
@ -720,7 +738,7 @@ class SOShipmentFilter(rest_filters.FilterSet):
|
||||
return queryset
|
||||
|
||||
class Meta:
|
||||
model = SalesOrderShipment
|
||||
model = models.SalesOrderShipment
|
||||
fields = [
|
||||
'order',
|
||||
]
|
||||
@ -731,8 +749,8 @@ class SOShipmentList(generics.ListCreateAPIView):
|
||||
API list endpoint for SalesOrderShipment model
|
||||
"""
|
||||
|
||||
queryset = SalesOrderShipment.objects.all()
|
||||
serializer_class = SalesOrderShipmentSerializer
|
||||
queryset = models.SalesOrderShipment.objects.all()
|
||||
serializer_class = serializers.SalesOrderShipmentSerializer
|
||||
filterset_class = SOShipmentFilter
|
||||
|
||||
filter_backends = [
|
||||
@ -745,8 +763,8 @@ class SOShipmentDetail(generics.RetrieveUpdateAPIView):
|
||||
API detail endpooint for SalesOrderShipment model
|
||||
"""
|
||||
|
||||
queryset = SalesOrderShipment.objects.all()
|
||||
serializer_class = SalesOrderShipmentSerializer
|
||||
queryset = models.SalesOrderShipment.objects.all()
|
||||
serializer_class = serializers.SalesOrderShipmentSerializer
|
||||
|
||||
|
||||
class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
||||
@ -754,8 +772,8 @@ class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
||||
API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload)
|
||||
"""
|
||||
|
||||
queryset = PurchaseOrderAttachment.objects.all()
|
||||
serializer_class = POAttachmentSerializer
|
||||
queryset = models.PurchaseOrderAttachment.objects.all()
|
||||
serializer_class = serializers.POAttachmentSerializer
|
||||
|
||||
filter_backends = [
|
||||
rest_filters.DjangoFilterBackend,
|
||||
@ -771,8 +789,8 @@ class POAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin)
|
||||
Detail endpoint for a PurchaseOrderAttachment
|
||||
"""
|
||||
|
||||
queryset = PurchaseOrderAttachment.objects.all()
|
||||
serializer_class = POAttachmentSerializer
|
||||
queryset = models.PurchaseOrderAttachment.objects.all()
|
||||
serializer_class = serializers.POAttachmentSerializer
|
||||
|
||||
|
||||
order_api_urls = [
|
||||
@ -816,7 +834,13 @@ order_api_urls = [
|
||||
url(r'^.*$', SOShipmentList.as_view(), name='api-so-shipment-list'),
|
||||
])),
|
||||
|
||||
url(r'^(?P<pk>\d+)/$', SODetail.as_view(), name='api-so-detail'),
|
||||
# Sales order detail view
|
||||
url(r'^(?P<pk>\d+)/', include([
|
||||
url(r'^allocate/', SalesOrderAllocate.as_view(), name='api-so-allocate'),
|
||||
url(r'^.*$', SODetail.as_view(), name='api-so-detail'),
|
||||
])),
|
||||
|
||||
# Sales order list view
|
||||
url(r'^.*$', SOList.as_view(), name='api-so-list'),
|
||||
])),
|
||||
|
||||
|
@ -567,6 +567,16 @@ class SalesOrder(Order):
|
||||
def is_pending(self):
|
||||
return self.status == SalesOrderStatus.PENDING
|
||||
|
||||
@property
|
||||
def stock_allocations(self):
|
||||
"""
|
||||
Return a queryset containing all allocations for this order
|
||||
"""
|
||||
|
||||
return SalesOrderAllocation.objects.filter(
|
||||
line__in=[line.pk for line in self.lines.all()]
|
||||
)
|
||||
|
||||
def is_fully_allocated(self):
|
||||
""" Return True if all line items are fully allocated """
|
||||
|
||||
@ -910,6 +920,10 @@ class SalesOrderShipment(models.Model):
|
||||
notes: Custom notes field for this shipment
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
return reverse('api-so-shipment-list')
|
||||
|
||||
order = models.ForeignKey(
|
||||
SalesOrder,
|
||||
on_delete=models.CASCADE,
|
||||
@ -1014,10 +1028,9 @@ class SalesOrderAllocation(models.Model):
|
||||
if self.item.serial and not self.quantity == 1:
|
||||
errors['quantity'] = _('Quantity must be 1 for serialized stock item')
|
||||
|
||||
|
||||
|
||||
# TODO: Ensure that the "shipment" points to the same "order"!
|
||||
|
||||
if self.line.order != self.shipment.order:
|
||||
errors['line'] = _('Sales order does not match shipment')
|
||||
errors['shipment'] = _('Shipment does not match sales order')
|
||||
|
||||
if len(errors) > 0:
|
||||
raise ValidationError(errors)
|
||||
@ -1026,7 +1039,8 @@ class SalesOrderAllocation(models.Model):
|
||||
SalesOrderLineItem,
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name=_('Line'),
|
||||
related_name='allocations')
|
||||
related_name='allocations'
|
||||
)
|
||||
|
||||
shipment = models.ForeignKey(
|
||||
SalesOrderShipment,
|
||||
|
@ -17,30 +17,29 @@ from rest_framework.serializers import ValidationError
|
||||
|
||||
from sql_util.utils import SubqueryCount
|
||||
|
||||
from common.settings import currency_code_mappings
|
||||
|
||||
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
|
||||
|
||||
from InvenTree.helpers import normalize
|
||||
from InvenTree.serializers import InvenTreeModelSerializer
|
||||
from InvenTree.serializers import InvenTreeAttachmentSerializer
|
||||
from InvenTree.serializers import InvenTreeMoneySerializer
|
||||
from InvenTree.serializers import InvenTreeAttachmentSerializerField
|
||||
|
||||
from InvenTree.status_codes import StockStatus
|
||||
|
||||
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
|
||||
import order.models
|
||||
|
||||
from part.serializers import PartBriefSerializer
|
||||
|
||||
import stock.models
|
||||
from stock.serializers import LocationBriefSerializer, StockItemSerializer, LocationSerializer
|
||||
|
||||
from .models import PurchaseOrder, PurchaseOrderLineItem
|
||||
from .models import PurchaseOrderAttachment, SalesOrderAttachment
|
||||
from .models import SalesOrder, SalesOrderLineItem
|
||||
from .models import SalesOrderShipment, SalesOrderAllocation
|
||||
|
||||
from common.settings import currency_code_mappings
|
||||
import stock.serializers
|
||||
|
||||
|
||||
class POSerializer(InvenTreeModelSerializer):
|
||||
""" Serializer for a PurchaseOrder object """
|
||||
"""
|
||||
Serializer for a PurchaseOrder object
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@ -67,7 +66,7 @@ class POSerializer(InvenTreeModelSerializer):
|
||||
queryset = queryset.annotate(
|
||||
overdue=Case(
|
||||
When(
|
||||
PurchaseOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()),
|
||||
order.models.PurchaseOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()),
|
||||
),
|
||||
default=Value(False, output_field=BooleanField())
|
||||
)
|
||||
@ -86,7 +85,7 @@ class POSerializer(InvenTreeModelSerializer):
|
||||
reference = serializers.CharField(required=True)
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrder
|
||||
model = order.models.PurchaseOrder
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
@ -160,7 +159,7 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
||||
|
||||
purchase_price_string = serializers.CharField(source='purchase_price', read_only=True)
|
||||
|
||||
destination_detail = LocationBriefSerializer(source='get_destination', read_only=True)
|
||||
destination_detail = stock.serializers.LocationBriefSerializer(source='get_destination', read_only=True)
|
||||
|
||||
purchase_price_currency = serializers.ChoiceField(
|
||||
choices=currency_code_mappings(),
|
||||
@ -168,7 +167,7 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrderLineItem
|
||||
model = order.models.PurchaseOrderLineItem
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
@ -195,7 +194,7 @@ class POLineItemReceiveSerializer(serializers.Serializer):
|
||||
"""
|
||||
|
||||
line_item = serializers.PrimaryKeyRelatedField(
|
||||
queryset=PurchaseOrderLineItem.objects.all(),
|
||||
queryset=order.models.PurchaseOrderLineItem.objects.all(),
|
||||
many=False,
|
||||
allow_null=False,
|
||||
required=True,
|
||||
@ -376,7 +375,7 @@ class POAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
attachment = InvenTreeAttachmentSerializerField(required=True)
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrderAttachment
|
||||
model = order.models.PurchaseOrderAttachment
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
@ -422,7 +421,7 @@ class SalesOrderSerializer(InvenTreeModelSerializer):
|
||||
queryset = queryset.annotate(
|
||||
overdue=Case(
|
||||
When(
|
||||
SalesOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()),
|
||||
order.models.SalesOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()),
|
||||
),
|
||||
default=Value(False, output_field=BooleanField())
|
||||
)
|
||||
@ -441,7 +440,7 @@ class SalesOrderSerializer(InvenTreeModelSerializer):
|
||||
reference = serializers.CharField(required=True)
|
||||
|
||||
class Meta:
|
||||
model = SalesOrder
|
||||
model = order.models.SalesOrder
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
@ -484,8 +483,8 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
|
||||
# Extra detail fields
|
||||
order_detail = SalesOrderSerializer(source='line.order', many=False, read_only=True)
|
||||
part_detail = PartBriefSerializer(source='item.part', many=False, read_only=True)
|
||||
item_detail = StockItemSerializer(source='item', many=False, read_only=True)
|
||||
location_detail = LocationSerializer(source='item.location', many=False, read_only=True)
|
||||
item_detail = stock.serializers.StockItemSerializer(source='item', many=False, read_only=True)
|
||||
location_detail = stock.serializers.LocationSerializer(source='item.location', many=False, read_only=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@ -509,7 +508,7 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
|
||||
self.fields.pop('location_detail')
|
||||
|
||||
class Meta:
|
||||
model = SalesOrderAllocation
|
||||
model = order.models.SalesOrderAllocation
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
@ -570,7 +569,7 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = SalesOrderLineItem
|
||||
model = order.models.SalesOrderLineItem
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
@ -598,7 +597,7 @@ class SalesOrderShipmentSerializer(InvenTreeModelSerializer):
|
||||
allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True)
|
||||
|
||||
class Meta:
|
||||
model = SalesOrderShipment
|
||||
model = order.models.SalesOrderShipment
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
@ -611,6 +610,160 @@ class SalesOrderShipmentSerializer(InvenTreeModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class SOShipmentAllocationItemSerializer(serializers.Serializer):
|
||||
"""
|
||||
A serializer for allocating a single stock-item against a SalesOrder shipment
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
'line_item',
|
||||
'stock_item',
|
||||
'quantity',
|
||||
]
|
||||
|
||||
line_item = serializers.PrimaryKeyRelatedField(
|
||||
queryset=order.models.SalesOrderLineItem.objects.all(),
|
||||
many=False,
|
||||
allow_null=False,
|
||||
required=True,
|
||||
label=_('Stock Item'),
|
||||
)
|
||||
|
||||
def validate_line_item(self, line_item):
|
||||
|
||||
order = self.context['order']
|
||||
|
||||
# Ensure that the line item points to the correct order
|
||||
if line_item.order != order:
|
||||
raise ValidationError(_("Line item is not associated with this order"))
|
||||
|
||||
return line_item
|
||||
|
||||
stock_item = serializers.PrimaryKeyRelatedField(
|
||||
queryset=stock.models.StockItem.objects.all(),
|
||||
many=False,
|
||||
allow_null=False,
|
||||
required=True,
|
||||
label=_('Stock Item'),
|
||||
)
|
||||
|
||||
quantity = serializers.DecimalField(
|
||||
max_digits=15,
|
||||
decimal_places=5,
|
||||
min_value=0,
|
||||
required=True
|
||||
)
|
||||
|
||||
def validate_quantity(self, quantity):
|
||||
|
||||
if quantity <= 0:
|
||||
raise ValidationError(_("Quantity must be positive"))
|
||||
|
||||
return quantity
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
super().validate(data)
|
||||
|
||||
stock_item = data['stock_item']
|
||||
quantity = data['quantity']
|
||||
|
||||
if stock_item.serialized and quantity != 1:
|
||||
raise ValidationError({
|
||||
'quantity': _("Quantity must be 1 for serialized stock item"),
|
||||
})
|
||||
|
||||
q = normalize(stock_item.unallocated_quantity())
|
||||
|
||||
if quantity > q:
|
||||
raise ValidationError({
|
||||
'quantity': _(f"Available quantity ({q}) exceeded")
|
||||
})
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class SOShipmentAllocationSerializer(serializers.Serializer):
|
||||
"""
|
||||
DRF serializer for allocation of stock items against a sales order / shipment
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
'items',
|
||||
'shipment',
|
||||
]
|
||||
|
||||
items = SOShipmentAllocationItemSerializer(many=True)
|
||||
|
||||
shipment = serializers.PrimaryKeyRelatedField(
|
||||
queryset=order.models.SalesOrderShipment.objects.all(),
|
||||
many=False,
|
||||
allow_null=False,
|
||||
required=True,
|
||||
label=_('Shipment'),
|
||||
)
|
||||
|
||||
def validate_shipment(self, shipment):
|
||||
"""
|
||||
Run validation against the provided shipment instance
|
||||
"""
|
||||
|
||||
order = self.context['order']
|
||||
|
||||
if shipment.shipment_date is not None:
|
||||
raise ValidationError(_("Shipment has already been shipped"))
|
||||
|
||||
if shipment.order != order:
|
||||
raise ValidationError(_("Shipment is not associated with this order"))
|
||||
|
||||
return shipment
|
||||
|
||||
def validate(self, data):
|
||||
"""
|
||||
Serializer validation
|
||||
"""
|
||||
|
||||
data = super().validate(data)
|
||||
|
||||
# Extract SalesOrder from serializer context
|
||||
# order = self.context['order']
|
||||
|
||||
items = data.get('items', [])
|
||||
|
||||
if len(items) == 0:
|
||||
raise ValidationError(_('Allocation items must be provided'))
|
||||
|
||||
return data
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
Perform the allocation of items against this order
|
||||
"""
|
||||
|
||||
data = self.validated_data
|
||||
|
||||
items = data['items']
|
||||
shipment = data['shipment']
|
||||
|
||||
with transaction.atomic():
|
||||
for entry in items:
|
||||
|
||||
# Create a new SalesOrderAllocation
|
||||
order.models.SalesOrderAllocation.objects.create(
|
||||
line=entry.get('line_item'),
|
||||
item=entry.get('stock_item'),
|
||||
quantity=entry.get('quantity'),
|
||||
shipment=shipment,
|
||||
)
|
||||
|
||||
try:
|
||||
pass
|
||||
except (ValidationError, DjangoValidationError) as exc:
|
||||
raise ValidationError(detail=serializers.as_serializer_error(exc))
|
||||
|
||||
|
||||
class SOAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
"""
|
||||
Serializers for the SalesOrderAttachment model
|
||||
@ -619,7 +772,7 @@ class SOAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
attachment = InvenTreeAttachmentSerializerField(required=True)
|
||||
|
||||
class Meta:
|
||||
model = SalesOrderAttachment
|
||||
model = order.models.SalesOrderAttachment
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
|
@ -11,9 +11,10 @@ from django.urls import reverse
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
from InvenTree.status_codes import PurchaseOrderStatus
|
||||
|
||||
from part.models import Part
|
||||
from stock.models import StockItem
|
||||
|
||||
from .models import PurchaseOrder, PurchaseOrderLineItem, SalesOrder
|
||||
import order.models as models
|
||||
|
||||
|
||||
class OrderTest(InvenTreeAPITestCase):
|
||||
@ -85,7 +86,7 @@ class PurchaseOrderTest(OrderTest):
|
||||
self.filter({'overdue': True}, 0)
|
||||
self.filter({'overdue': False}, 7)
|
||||
|
||||
order = PurchaseOrder.objects.get(pk=1)
|
||||
order = models.PurchaseOrder.objects.get(pk=1)
|
||||
order.target_date = datetime.now().date() - timedelta(days=10)
|
||||
order.save()
|
||||
|
||||
@ -118,7 +119,7 @@ class PurchaseOrderTest(OrderTest):
|
||||
Test that we can create / edit and delete a PurchaseOrder via the API
|
||||
"""
|
||||
|
||||
n = PurchaseOrder.objects.count()
|
||||
n = models.PurchaseOrder.objects.count()
|
||||
|
||||
url = reverse('api-po-list')
|
||||
|
||||
@ -135,7 +136,7 @@ class PurchaseOrderTest(OrderTest):
|
||||
)
|
||||
|
||||
# And no new PurchaseOrder objects should have been created
|
||||
self.assertEqual(PurchaseOrder.objects.count(), n)
|
||||
self.assertEqual(models.PurchaseOrder.objects.count(), n)
|
||||
|
||||
# Ok, now let's give this user the correct permission
|
||||
self.assignRole('purchase_order.add')
|
||||
@ -152,7 +153,7 @@ class PurchaseOrderTest(OrderTest):
|
||||
expected_code=201
|
||||
)
|
||||
|
||||
self.assertEqual(PurchaseOrder.objects.count(), n + 1)
|
||||
self.assertEqual(models.PurchaseOrder.objects.count(), n + 1)
|
||||
|
||||
pk = response.data['pk']
|
||||
|
||||
@ -167,7 +168,7 @@ class PurchaseOrderTest(OrderTest):
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
self.assertEqual(PurchaseOrder.objects.count(), n + 1)
|
||||
self.assertEqual(models.PurchaseOrder.objects.count(), n + 1)
|
||||
|
||||
url = reverse('api-po-detail', kwargs={'pk': pk})
|
||||
|
||||
@ -198,7 +199,7 @@ class PurchaseOrderTest(OrderTest):
|
||||
response = self.delete(url, expected_code=204)
|
||||
|
||||
# Number of PurchaseOrder objects should have decreased
|
||||
self.assertEqual(PurchaseOrder.objects.count(), n)
|
||||
self.assertEqual(models.PurchaseOrder.objects.count(), n)
|
||||
|
||||
# And if we try to access the detail view again, it has gone
|
||||
response = self.get(url, expected_code=404)
|
||||
@ -237,7 +238,7 @@ class PurchaseOrderReceiveTest(OrderTest):
|
||||
self.n = StockItem.objects.count()
|
||||
|
||||
# Mark the order as "placed" so we can receive line items
|
||||
order = PurchaseOrder.objects.get(pk=1)
|
||||
order = models.PurchaseOrder.objects.get(pk=1)
|
||||
order.status = PurchaseOrderStatus.PLACED
|
||||
order.save()
|
||||
|
||||
@ -409,8 +410,8 @@ class PurchaseOrderReceiveTest(OrderTest):
|
||||
Test receipt of valid data
|
||||
"""
|
||||
|
||||
line_1 = PurchaseOrderLineItem.objects.get(pk=1)
|
||||
line_2 = PurchaseOrderLineItem.objects.get(pk=2)
|
||||
line_1 = models.PurchaseOrderLineItem.objects.get(pk=1)
|
||||
line_2 = models.PurchaseOrderLineItem.objects.get(pk=2)
|
||||
|
||||
self.assertEqual(StockItem.objects.filter(supplier_part=line_1.part).count(), 0)
|
||||
self.assertEqual(StockItem.objects.filter(supplier_part=line_2.part).count(), 0)
|
||||
@ -437,7 +438,7 @@ class PurchaseOrderReceiveTest(OrderTest):
|
||||
|
||||
# Before posting "valid" data, we will mark the purchase order as "pending"
|
||||
# In this case we do expect an error!
|
||||
order = PurchaseOrder.objects.get(pk=1)
|
||||
order = models.PurchaseOrder.objects.get(pk=1)
|
||||
order.status = PurchaseOrderStatus.PENDING
|
||||
order.save()
|
||||
|
||||
@ -463,8 +464,8 @@ class PurchaseOrderReceiveTest(OrderTest):
|
||||
# There should be two newly created stock items
|
||||
self.assertEqual(self.n + 2, StockItem.objects.count())
|
||||
|
||||
line_1 = PurchaseOrderLineItem.objects.get(pk=1)
|
||||
line_2 = PurchaseOrderLineItem.objects.get(pk=2)
|
||||
line_1 = models.PurchaseOrderLineItem.objects.get(pk=1)
|
||||
line_2 = models.PurchaseOrderLineItem.objects.get(pk=2)
|
||||
|
||||
self.assertEqual(line_1.received, 50)
|
||||
self.assertEqual(line_2.received, 250)
|
||||
@ -519,7 +520,7 @@ class SalesOrderTest(OrderTest):
|
||||
self.filter({'overdue': False}, 5)
|
||||
|
||||
for pk in [1, 2]:
|
||||
order = SalesOrder.objects.get(pk=pk)
|
||||
order = models.SalesOrder.objects.get(pk=pk)
|
||||
order.target_date = datetime.now().date() - timedelta(days=10)
|
||||
order.save()
|
||||
|
||||
@ -547,7 +548,7 @@ class SalesOrderTest(OrderTest):
|
||||
Test that we can create / edit and delete a SalesOrder via the API
|
||||
"""
|
||||
|
||||
n = SalesOrder.objects.count()
|
||||
n = models.SalesOrder.objects.count()
|
||||
|
||||
url = reverse('api-so-list')
|
||||
|
||||
@ -577,7 +578,7 @@ class SalesOrderTest(OrderTest):
|
||||
)
|
||||
|
||||
# Check that the new order has been created
|
||||
self.assertEqual(SalesOrder.objects.count(), n + 1)
|
||||
self.assertEqual(models.SalesOrder.objects.count(), n + 1)
|
||||
|
||||
# Grab the PK for the newly created SalesOrder
|
||||
pk = response.data['pk']
|
||||
@ -620,7 +621,7 @@ class SalesOrderTest(OrderTest):
|
||||
response = self.delete(url, expected_code=204)
|
||||
|
||||
# Check that the number of sales orders has decreased
|
||||
self.assertEqual(SalesOrder.objects.count(), n)
|
||||
self.assertEqual(models.SalesOrder.objects.count(), n)
|
||||
|
||||
# And the resource should no longer be available
|
||||
response = self.get(url, expected_code=404)
|
||||
@ -641,3 +642,131 @@ class SalesOrderTest(OrderTest):
|
||||
},
|
||||
expected_code=201
|
||||
)
|
||||
|
||||
|
||||
class SalesOrderAllocateTest(OrderTest):
|
||||
"""
|
||||
Unit tests for allocating stock items against a SalesOrder
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.assignRole('sales_order.add')
|
||||
|
||||
self.url = reverse('api-so-allocate', kwargs={'pk': 1})
|
||||
|
||||
self.order = models.SalesOrder.objects.get(pk=1)
|
||||
|
||||
# Create some line items for this purchase order
|
||||
parts = Part.objects.filter(salable=True)
|
||||
|
||||
for part in parts:
|
||||
|
||||
# Create a new line item
|
||||
models.SalesOrderLineItem.objects.create(
|
||||
order=self.order,
|
||||
part=part,
|
||||
quantity=5,
|
||||
)
|
||||
|
||||
# Ensure we have stock!
|
||||
StockItem.objects.create(
|
||||
part=part,
|
||||
quantity=100,
|
||||
)
|
||||
|
||||
# Create a new shipment against this SalesOrder
|
||||
self.shipment = models.SalesOrderShipment.objects.create(
|
||||
order=self.order,
|
||||
)
|
||||
|
||||
def test_invalid(self):
|
||||
"""
|
||||
Test POST with invalid data
|
||||
"""
|
||||
|
||||
# No data
|
||||
response = self.post(self.url, {}, expected_code=400)
|
||||
|
||||
self.assertIn('This field is required', str(response.data['items']))
|
||||
self.assertIn('This field is required', str(response.data['shipment']))
|
||||
|
||||
# Test with a single line items
|
||||
line = self.order.lines.first()
|
||||
part = line.part
|
||||
|
||||
# Valid stock_item, but quantity is invalid
|
||||
data = {
|
||||
'items': [{
|
||||
"line_item": line.pk,
|
||||
"stock_item": part.stock_items.last().pk,
|
||||
"quantity": 0,
|
||||
}],
|
||||
}
|
||||
|
||||
response = self.post(self.url, data, expected_code=400)
|
||||
|
||||
self.assertIn('Quantity must be positive', str(response.data['items']))
|
||||
|
||||
# Valid stock item, too much quantity
|
||||
data['items'][0]['quantity'] = 250
|
||||
|
||||
response = self.post(self.url, data, expected_code=400)
|
||||
|
||||
self.assertIn('Available quantity (100) exceeded', str(response.data['items']))
|
||||
|
||||
# Valid stock item, valid quantity
|
||||
data['items'][0]['quantity'] = 50
|
||||
|
||||
# Invalid shipment!
|
||||
data['shipment'] = 9999
|
||||
|
||||
response = self.post(self.url, data, expected_code=400)
|
||||
|
||||
self.assertIn('does not exist', str(response.data['shipment']))
|
||||
|
||||
# Valid shipment, but points to the wrong order
|
||||
shipment = models.SalesOrderShipment.objects.create(
|
||||
order=models.SalesOrder.objects.get(pk=2),
|
||||
)
|
||||
|
||||
data['shipment'] = shipment.pk
|
||||
|
||||
response = self.post(self.url, data, expected_code=400)
|
||||
|
||||
self.assertIn('Shipment is not associated with this order', str(response.data['shipment']))
|
||||
|
||||
def test_allocate(self):
|
||||
"""
|
||||
Test the the allocation endpoint acts as expected,
|
||||
when provided with valid data!
|
||||
"""
|
||||
|
||||
# First, check that there are no line items allocated against this SalesOrder
|
||||
self.assertEqual(self.order.stock_allocations.count(), 0)
|
||||
|
||||
data = {
|
||||
"items": [],
|
||||
"shipment": self.shipment.pk,
|
||||
}
|
||||
|
||||
for line in self.order.lines.all():
|
||||
stock_item = line.part.stock_items.last()
|
||||
|
||||
# Fully-allocate each line
|
||||
data['items'].append({
|
||||
"line_item": line.pk,
|
||||
"stock_item": stock_item.pk,
|
||||
"quantity": 5
|
||||
})
|
||||
|
||||
self.post(self.url, data, expected_code=201)
|
||||
|
||||
# There should have been 1 stock item allocated against each line item
|
||||
n_lines = self.order.lines.count()
|
||||
|
||||
self.assertEqual(self.order.stock_allocations.count(), n_lines)
|
||||
|
||||
for line in self.order.lines.all():
|
||||
self.assertEqual(line.allocations.count(), 1)
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user