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:
Oliver 2021-10-26 23:51:36 +11:00
parent dd5eeb7c61
commit bff9f0828a
6 changed files with 437 additions and 113 deletions

View File

@ -422,7 +422,7 @@ class BuildAllocationSerializer(serializers.Serializer):
Validation
"""
super().validate(data)
data = super().validate(data)
items = data.get('items', [])

View File

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

View File

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

View File

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

View File

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

View File

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