mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge pull request #2013 from SchrodingersGat/receive-via-api
Receive via api
This commit is contained in:
commit
a70f4c86eb
@ -10,11 +10,14 @@ import common.models
|
|||||||
|
|
||||||
INVENTREE_SW_VERSION = "0.5.0 pre"
|
INVENTREE_SW_VERSION = "0.5.0 pre"
|
||||||
|
|
||||||
INVENTREE_API_VERSION = 11
|
INVENTREE_API_VERSION = 12
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||||
|
|
||||||
|
v12 -> 2021-09-07
|
||||||
|
- Adds API endpoint to receive stock items against a PurchaseOrder
|
||||||
|
|
||||||
v11 -> 2021-08-26
|
v11 -> 2021-08-26
|
||||||
- Adds "units" field to PartBriefSerializer
|
- Adds "units" field to PartBriefSerializer
|
||||||
- This allows units to be introspected from the "part_detail" field in the StockItem serializer
|
- This allows units to be introspected from the "part_detail" field in the StockItem serializer
|
||||||
|
@ -5,12 +5,16 @@ JSON API for the Order app
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from django.conf.urls import url, include
|
from django.conf.urls import url, include
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
from django_filters import rest_framework as rest_filters
|
from django_filters import rest_framework as rest_filters
|
||||||
from rest_framework import generics
|
from rest_framework import generics
|
||||||
from rest_framework import filters, status
|
from rest_framework import filters, status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.serializers import ValidationError
|
||||||
|
|
||||||
|
|
||||||
from InvenTree.filters import InvenTreeOrderingFilter
|
from InvenTree.filters import InvenTreeOrderingFilter
|
||||||
from InvenTree.helpers import str2bool
|
from InvenTree.helpers import str2bool
|
||||||
@ -28,6 +32,7 @@ from .models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation
|
|||||||
from .models import SalesOrderAttachment
|
from .models import SalesOrderAttachment
|
||||||
from .serializers import SalesOrderSerializer, SOLineItemSerializer, SOAttachmentSerializer
|
from .serializers import SalesOrderSerializer, SOLineItemSerializer, SOAttachmentSerializer
|
||||||
from .serializers import SalesOrderAllocationSerializer
|
from .serializers import SalesOrderAllocationSerializer
|
||||||
|
from .serializers import POReceiveSerializer
|
||||||
|
|
||||||
|
|
||||||
class POList(generics.ListCreateAPIView):
|
class POList(generics.ListCreateAPIView):
|
||||||
@ -205,6 +210,111 @@ class PODetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
class POReceive(generics.CreateAPIView):
|
||||||
|
"""
|
||||||
|
API endpoint to receive stock items against a purchase order.
|
||||||
|
|
||||||
|
- The purchase order is specified in the URL.
|
||||||
|
- Items to receive are specified as a list called "items" with the following options:
|
||||||
|
- supplier_part: pk value of the supplier part
|
||||||
|
- quantity: quantity to receive
|
||||||
|
- status: stock item status
|
||||||
|
- location: destination for stock item (optional)
|
||||||
|
- A global location can also be specified
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = PurchaseOrderLineItem.objects.none()
|
||||||
|
|
||||||
|
serializer_class = POReceiveSerializer
|
||||||
|
|
||||||
|
def get_serializer_context(self):
|
||||||
|
|
||||||
|
context = super().get_serializer_context()
|
||||||
|
|
||||||
|
# Pass the purchase order through to the serializer for validation
|
||||||
|
context['order'] = self.get_order()
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
def get_order(self):
|
||||||
|
"""
|
||||||
|
Returns the PurchaseOrder associated with this API endpoint
|
||||||
|
"""
|
||||||
|
|
||||||
|
pk = self.kwargs.get('pk', None)
|
||||||
|
|
||||||
|
if pk is None:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
order = PurchaseOrder.objects.get(pk=self.kwargs['pk'])
|
||||||
|
return order
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
|
||||||
|
# Which purchase order are we receiving against?
|
||||||
|
self.order = self.get_order()
|
||||||
|
|
||||||
|
# Validate the serialized data
|
||||||
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
# Receive the line items
|
||||||
|
self.receive_items(serializer)
|
||||||
|
|
||||||
|
headers = self.get_success_headers(serializer.data)
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def receive_items(self, serializer):
|
||||||
|
"""
|
||||||
|
Receive the items
|
||||||
|
|
||||||
|
At this point, much of the heavy lifting has been done for us by DRF serializers!
|
||||||
|
|
||||||
|
We have a list of "items", each a dict which contains:
|
||||||
|
- line_item: A PurchaseOrderLineItem matching this order
|
||||||
|
- location: A destination location
|
||||||
|
- quantity: A validated numerical quantity
|
||||||
|
- status: The status code for the received item
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = serializer.validated_data
|
||||||
|
|
||||||
|
location = data['location']
|
||||||
|
|
||||||
|
items = data['items']
|
||||||
|
|
||||||
|
# Check if the location is not specified for any particular item
|
||||||
|
for item in items:
|
||||||
|
|
||||||
|
line = item['line_item']
|
||||||
|
|
||||||
|
if not item.get('location', None):
|
||||||
|
# If a global location is specified, use that
|
||||||
|
item['location'] = location
|
||||||
|
|
||||||
|
if not item['location']:
|
||||||
|
# The line item specifies a location?
|
||||||
|
item['location'] = line.get_destination()
|
||||||
|
|
||||||
|
if not item['location']:
|
||||||
|
raise ValidationError({
|
||||||
|
'location': _("Destination location must be specified"),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Now we can actually receive the items
|
||||||
|
for item in items:
|
||||||
|
|
||||||
|
self.order.receive_line_item(
|
||||||
|
item['line_item'],
|
||||||
|
item['location'],
|
||||||
|
item['quantity'],
|
||||||
|
self.request.user,
|
||||||
|
status=item['status'],
|
||||||
|
barcode=item.get('barcode', ''),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class POLineItemList(generics.ListCreateAPIView):
|
class POLineItemList(generics.ListCreateAPIView):
|
||||||
""" API endpoint for accessing a list of POLineItem objects
|
""" API endpoint for accessing a list of POLineItem objects
|
||||||
|
|
||||||
@ -641,13 +751,25 @@ class POAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin)
|
|||||||
|
|
||||||
|
|
||||||
order_api_urls = [
|
order_api_urls = [
|
||||||
|
|
||||||
# API endpoints for purchase orders
|
# API endpoints for purchase orders
|
||||||
url(r'po/attachment/', include([
|
url(r'^po/', include([
|
||||||
url(r'^(?P<pk>\d+)/$', POAttachmentDetail.as_view(), name='api-po-attachment-detail'),
|
|
||||||
url(r'^.*$', POAttachmentList.as_view(), name='api-po-attachment-list'),
|
# Purchase order attachments
|
||||||
|
url(r'attachment/', include([
|
||||||
|
url(r'^(?P<pk>\d+)/$', POAttachmentDetail.as_view(), name='api-po-attachment-detail'),
|
||||||
|
url(r'^.*$', POAttachmentList.as_view(), name='api-po-attachment-list'),
|
||||||
|
])),
|
||||||
|
|
||||||
|
# Individual purchase order detail URLs
|
||||||
|
url(r'^(?P<pk>\d+)/', include([
|
||||||
|
url(r'^receive/', POReceive.as_view(), name='api-po-receive'),
|
||||||
|
url(r'.*$', PODetail.as_view(), name='api-po-detail'),
|
||||||
|
])),
|
||||||
|
|
||||||
|
# Purchase order list
|
||||||
|
url(r'^.*$', POList.as_view(), name='api-po-list'),
|
||||||
])),
|
])),
|
||||||
url(r'^po/(?P<pk>\d+)/$', PODetail.as_view(), name='api-po-detail'),
|
|
||||||
url(r'^po/.*$', POList.as_view(), name='api-po-list'),
|
|
||||||
|
|
||||||
# API endpoints for purchase order line items
|
# API endpoints for purchase order line items
|
||||||
url(r'^po-line/(?P<pk>\d+)/$', POLineItemDetail.as_view(), name='api-po-line-detail'),
|
url(r'^po-line/(?P<pk>\d+)/$', POLineItemDetail.as_view(), name='api-po-line-detail'),
|
||||||
|
@ -411,6 +411,11 @@ class PurchaseOrder(Order):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
notes = kwargs.get('notes', '')
|
notes = kwargs.get('notes', '')
|
||||||
|
barcode = kwargs.get('barcode', '')
|
||||||
|
|
||||||
|
# Prevent null values for barcode
|
||||||
|
if barcode is None:
|
||||||
|
barcode = ''
|
||||||
|
|
||||||
if not self.status == PurchaseOrderStatus.PLACED:
|
if not self.status == PurchaseOrderStatus.PLACED:
|
||||||
raise ValidationError({"status": _("Lines can only be received against an order marked as 'Placed'")})
|
raise ValidationError({"status": _("Lines can only be received against an order marked as 'Placed'")})
|
||||||
@ -433,7 +438,8 @@ class PurchaseOrder(Order):
|
|||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
purchase_order=self,
|
purchase_order=self,
|
||||||
status=status,
|
status=status,
|
||||||
purchase_price=purchase_price,
|
purchase_price=line.purchase_price,
|
||||||
|
uid=barcode
|
||||||
)
|
)
|
||||||
|
|
||||||
stock.save(add_note=False)
|
stock.save(add_note=False)
|
||||||
|
@ -12,6 +12,8 @@ from django.db.models import Case, When, Value
|
|||||||
from django.db.models import BooleanField, ExpressionWrapper, F
|
from django.db.models import BooleanField, ExpressionWrapper, F
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
from rest_framework.serializers import ValidationError
|
||||||
|
|
||||||
from sql_util.utils import SubqueryCount
|
from sql_util.utils import SubqueryCount
|
||||||
|
|
||||||
from InvenTree.serializers import InvenTreeModelSerializer
|
from InvenTree.serializers import InvenTreeModelSerializer
|
||||||
@ -19,8 +21,13 @@ from InvenTree.serializers import InvenTreeAttachmentSerializer
|
|||||||
from InvenTree.serializers import InvenTreeMoneySerializer
|
from InvenTree.serializers import InvenTreeMoneySerializer
|
||||||
from InvenTree.serializers import InvenTreeAttachmentSerializerField
|
from InvenTree.serializers import InvenTreeAttachmentSerializerField
|
||||||
|
|
||||||
|
from InvenTree.status_codes import StockStatus
|
||||||
|
|
||||||
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
|
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
|
||||||
|
|
||||||
from part.serializers import PartBriefSerializer
|
from part.serializers import PartBriefSerializer
|
||||||
|
|
||||||
|
import stock.models
|
||||||
from stock.serializers import LocationBriefSerializer, StockItemSerializer, LocationSerializer
|
from stock.serializers import LocationBriefSerializer, StockItemSerializer, LocationSerializer
|
||||||
|
|
||||||
from .models import PurchaseOrder, PurchaseOrderLineItem
|
from .models import PurchaseOrder, PurchaseOrderLineItem
|
||||||
@ -137,7 +144,6 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
|||||||
self.fields.pop('part_detail')
|
self.fields.pop('part_detail')
|
||||||
self.fields.pop('supplier_part_detail')
|
self.fields.pop('supplier_part_detail')
|
||||||
|
|
||||||
# TODO: Once https://github.com/inventree/InvenTree/issues/1687 is fixed, remove default values
|
|
||||||
quantity = serializers.FloatField(default=1)
|
quantity = serializers.FloatField(default=1)
|
||||||
received = serializers.FloatField(default=0)
|
received = serializers.FloatField(default=0)
|
||||||
|
|
||||||
@ -182,6 +188,131 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class POLineItemReceiveSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
A serializer for receiving a single purchase order line item against a purchase order
|
||||||
|
"""
|
||||||
|
|
||||||
|
line_item = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=PurchaseOrderLineItem.objects.all(),
|
||||||
|
many=False,
|
||||||
|
allow_null=False,
|
||||||
|
required=True,
|
||||||
|
label=_('Line Item'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_line_item(self, item):
|
||||||
|
|
||||||
|
if item.order != self.context['order']:
|
||||||
|
raise ValidationError(_('Line item does not match purchase order'))
|
||||||
|
|
||||||
|
return item
|
||||||
|
|
||||||
|
location = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=stock.models.StockLocation.objects.all(),
|
||||||
|
many=False,
|
||||||
|
allow_null=True,
|
||||||
|
required=False,
|
||||||
|
label=_('Location'),
|
||||||
|
help_text=_('Select destination location for received items'),
|
||||||
|
)
|
||||||
|
|
||||||
|
quantity = serializers.DecimalField(
|
||||||
|
max_digits=15,
|
||||||
|
decimal_places=5,
|
||||||
|
min_value=0,
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
status = serializers.ChoiceField(
|
||||||
|
choices=list(StockStatus.items()),
|
||||||
|
default=StockStatus.OK,
|
||||||
|
label=_('Status'),
|
||||||
|
)
|
||||||
|
|
||||||
|
barcode = serializers.CharField(
|
||||||
|
label=_('Barcode Hash'),
|
||||||
|
help_text=_('Unique identifier field'),
|
||||||
|
default='',
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_barcode(self, barcode):
|
||||||
|
"""
|
||||||
|
Cannot check in a LineItem with a barcode that is already assigned
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Ignore empty barcode values
|
||||||
|
if not barcode or barcode.strip() == '':
|
||||||
|
return
|
||||||
|
|
||||||
|
if stock.models.StockItem.objects.filter(uid=barcode).exists():
|
||||||
|
raise ValidationError(_('Barcode is already in use'))
|
||||||
|
|
||||||
|
return barcode
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
fields = [
|
||||||
|
'barcode',
|
||||||
|
'line_item',
|
||||||
|
'location',
|
||||||
|
'quantity',
|
||||||
|
'status',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class POReceiveSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
Serializer for receiving items against a purchase order
|
||||||
|
"""
|
||||||
|
|
||||||
|
items = POLineItemReceiveSerializer(many=True)
|
||||||
|
|
||||||
|
location = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=stock.models.StockLocation.objects.all(),
|
||||||
|
many=False,
|
||||||
|
allow_null=True,
|
||||||
|
label=_('Location'),
|
||||||
|
help_text=_('Select destination location for received items'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_valid(self, raise_exception=False):
|
||||||
|
|
||||||
|
super().is_valid(raise_exception)
|
||||||
|
|
||||||
|
# Custom validation
|
||||||
|
data = self.validated_data
|
||||||
|
|
||||||
|
items = data.get('items', [])
|
||||||
|
|
||||||
|
if len(items) == 0:
|
||||||
|
self._errors['items'] = _('Line items must be provided')
|
||||||
|
else:
|
||||||
|
# Ensure barcodes are unique
|
||||||
|
unique_barcodes = set()
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
barcode = item.get('barcode', '')
|
||||||
|
|
||||||
|
if barcode:
|
||||||
|
if barcode in unique_barcodes:
|
||||||
|
self._errors['items'] = _('Supplied barcode values must be unique')
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
unique_barcodes.add(barcode)
|
||||||
|
|
||||||
|
if self._errors and raise_exception:
|
||||||
|
raise ValidationError(self.errors)
|
||||||
|
|
||||||
|
return not bool(self._errors)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
fields = [
|
||||||
|
'items',
|
||||||
|
'location',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class POAttachmentSerializer(InvenTreeAttachmentSerializer):
|
class POAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||||
"""
|
"""
|
||||||
Serializers for the PurchaseOrderAttachment model
|
Serializers for the PurchaseOrderAttachment model
|
||||||
|
@ -9,8 +9,11 @@ from rest_framework import status
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||||
|
from InvenTree.status_codes import PurchaseOrderStatus
|
||||||
|
|
||||||
from .models import PurchaseOrder, SalesOrder
|
from stock.models import StockItem
|
||||||
|
|
||||||
|
from .models import PurchaseOrder, PurchaseOrderLineItem, SalesOrder
|
||||||
|
|
||||||
|
|
||||||
class OrderTest(InvenTreeAPITestCase):
|
class OrderTest(InvenTreeAPITestCase):
|
||||||
@ -201,6 +204,250 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
response = self.get(url, expected_code=404)
|
response = self.get(url, expected_code=404)
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseOrderReceiveTest(OrderTest):
|
||||||
|
"""
|
||||||
|
Unit tests for receiving items against a PurchaseOrder
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
self.assignRole('purchase_order.add')
|
||||||
|
|
||||||
|
self.url = reverse('api-po-receive', kwargs={'pk': 1})
|
||||||
|
|
||||||
|
# Number of stock items which exist at the start of each test
|
||||||
|
self.n = StockItem.objects.count()
|
||||||
|
|
||||||
|
# Mark the order as "placed" so we can receive line items
|
||||||
|
order = PurchaseOrder.objects.get(pk=1)
|
||||||
|
order.status = PurchaseOrderStatus.PLACED
|
||||||
|
order.save()
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
"""
|
||||||
|
Test without any POST data
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = self.post(self.url, {}, expected_code=400).data
|
||||||
|
|
||||||
|
self.assertIn('This field is required', str(data['items']))
|
||||||
|
self.assertIn('This field is required', str(data['location']))
|
||||||
|
|
||||||
|
# No new stock items have been created
|
||||||
|
self.assertEqual(self.n, StockItem.objects.count())
|
||||||
|
|
||||||
|
def test_no_items(self):
|
||||||
|
"""
|
||||||
|
Test with an empty list of items
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = self.post(
|
||||||
|
self.url,
|
||||||
|
{
|
||||||
|
"items": [],
|
||||||
|
"location": None,
|
||||||
|
},
|
||||||
|
expected_code=400
|
||||||
|
).data
|
||||||
|
|
||||||
|
self.assertIn('Line items must be provided', str(data['items']))
|
||||||
|
|
||||||
|
# No new stock items have been created
|
||||||
|
self.assertEqual(self.n, StockItem.objects.count())
|
||||||
|
|
||||||
|
def test_invalid_items(self):
|
||||||
|
"""
|
||||||
|
Test than errors are returned as expected for invalid data
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = self.post(
|
||||||
|
self.url,
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"line_item": 12345,
|
||||||
|
"location": 12345
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
expected_code=400
|
||||||
|
).data
|
||||||
|
|
||||||
|
items = data['items'][0]
|
||||||
|
|
||||||
|
self.assertIn('Invalid pk "12345"', str(items['line_item']))
|
||||||
|
self.assertIn("object does not exist", str(items['location']))
|
||||||
|
|
||||||
|
# No new stock items have been created
|
||||||
|
self.assertEqual(self.n, StockItem.objects.count())
|
||||||
|
|
||||||
|
def test_invalid_status(self):
|
||||||
|
"""
|
||||||
|
Test with an invalid StockStatus value
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = self.post(
|
||||||
|
self.url,
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"line_item": 22,
|
||||||
|
"location": 1,
|
||||||
|
"status": 99999,
|
||||||
|
"quantity": 5,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
expected_code=400
|
||||||
|
).data
|
||||||
|
|
||||||
|
self.assertIn('"99999" is not a valid choice.', str(data))
|
||||||
|
|
||||||
|
# No new stock items have been created
|
||||||
|
self.assertEqual(self.n, StockItem.objects.count())
|
||||||
|
|
||||||
|
def test_mismatched_items(self):
|
||||||
|
"""
|
||||||
|
Test for supplier parts which *do* exist but do not match the order supplier
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = self.post(
|
||||||
|
self.url,
|
||||||
|
{
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'line_item': 22,
|
||||||
|
'quantity': 123,
|
||||||
|
'location': 1,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'location': None,
|
||||||
|
},
|
||||||
|
expected_code=400
|
||||||
|
).data
|
||||||
|
|
||||||
|
self.assertIn('Line item does not match purchase order', str(data))
|
||||||
|
|
||||||
|
# No new stock items have been created
|
||||||
|
self.assertEqual(self.n, StockItem.objects.count())
|
||||||
|
|
||||||
|
def test_invalid_barcodes(self):
|
||||||
|
"""
|
||||||
|
Tests for checking in items with invalid barcodes:
|
||||||
|
|
||||||
|
- Cannot check in "duplicate" barcodes
|
||||||
|
- Barcodes cannot match UID field for existing StockItem
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Set stock item barcode
|
||||||
|
item = StockItem.objects.get(pk=1)
|
||||||
|
item.uid = 'MY-BARCODE-HASH'
|
||||||
|
item.save()
|
||||||
|
|
||||||
|
response = self.post(
|
||||||
|
self.url,
|
||||||
|
{
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'line_item': 1,
|
||||||
|
'quantity': 50,
|
||||||
|
'barcode': 'MY-BARCODE-HASH',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'location': 1,
|
||||||
|
},
|
||||||
|
expected_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('Barcode is already in use', str(response.data))
|
||||||
|
|
||||||
|
response = self.post(
|
||||||
|
self.url,
|
||||||
|
{
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'line_item': 1,
|
||||||
|
'quantity': 5,
|
||||||
|
'barcode': 'MY-BARCODE-HASH-1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'line_item': 1,
|
||||||
|
'quantity': 5,
|
||||||
|
'barcode': 'MY-BARCODE-HASH-1'
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'location': 1,
|
||||||
|
},
|
||||||
|
expected_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('barcode values must be unique', str(response.data))
|
||||||
|
|
||||||
|
# No new stock items have been created
|
||||||
|
self.assertEqual(self.n, StockItem.objects.count())
|
||||||
|
|
||||||
|
def test_valid(self):
|
||||||
|
"""
|
||||||
|
Test receipt of valid data
|
||||||
|
"""
|
||||||
|
|
||||||
|
line_1 = PurchaseOrderLineItem.objects.get(pk=1)
|
||||||
|
line_2 = 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)
|
||||||
|
|
||||||
|
self.assertEqual(line_1.received, 0)
|
||||||
|
self.assertEqual(line_2.received, 50)
|
||||||
|
|
||||||
|
# Receive two separate line items against this order
|
||||||
|
self.post(
|
||||||
|
self.url,
|
||||||
|
{
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'line_item': 1,
|
||||||
|
'quantity': 50,
|
||||||
|
'barcode': 'MY-UNIQUE-BARCODE-123',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'line_item': 2,
|
||||||
|
'quantity': 200,
|
||||||
|
'location': 2, # Explicit location
|
||||||
|
'barcode': 'MY-UNIQUE-BARCODE-456',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'location': 1, # Default location
|
||||||
|
},
|
||||||
|
expected_code=201,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
self.assertEqual(line_1.received, 50)
|
||||||
|
self.assertEqual(line_2.received, 250)
|
||||||
|
|
||||||
|
stock_1 = StockItem.objects.filter(supplier_part=line_1.part)
|
||||||
|
stock_2 = StockItem.objects.filter(supplier_part=line_2.part)
|
||||||
|
|
||||||
|
# 1 new stock item created for each supplier part
|
||||||
|
self.assertEqual(stock_1.count(), 1)
|
||||||
|
self.assertEqual(stock_2.count(), 1)
|
||||||
|
|
||||||
|
# Different location for each received item
|
||||||
|
self.assertEqual(stock_1.last().location.pk, 1)
|
||||||
|
self.assertEqual(stock_2.last().location.pk, 2)
|
||||||
|
|
||||||
|
# Barcodes should have been assigned to the stock items
|
||||||
|
self.assertTrue(StockItem.objects.filter(uid='MY-UNIQUE-BARCODE-123').exists())
|
||||||
|
self.assertTrue(StockItem.objects.filter(uid='MY-UNIQUE-BARCODE-456').exists())
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderTest(OrderTest):
|
class SalesOrderTest(OrderTest):
|
||||||
"""
|
"""
|
||||||
Tests for the SalesOrder API
|
Tests for the SalesOrder API
|
||||||
|
@ -286,6 +286,8 @@ function constructForm(url, options) {
|
|||||||
constructFormBody({}, options);
|
constructFormBody({}, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
options.fields = options.fields || {};
|
||||||
|
|
||||||
// Save the URL
|
// Save the URL
|
||||||
options.url = url;
|
options.url = url;
|
||||||
|
|
||||||
@ -545,6 +547,11 @@ function constructFormBody(fields, options) {
|
|||||||
|
|
||||||
initializeGroups(fields, options);
|
initializeGroups(fields, options);
|
||||||
|
|
||||||
|
if (options.afterRender) {
|
||||||
|
// Custom callback function after form rendering
|
||||||
|
options.afterRender(fields, options);
|
||||||
|
}
|
||||||
|
|
||||||
// Scroll to the top
|
// Scroll to the top
|
||||||
$(options.modal).find('.modal-form-content-wrapper').scrollTop(0);
|
$(options.modal).find('.modal-form-content-wrapper').scrollTop(0);
|
||||||
}
|
}
|
||||||
@ -1542,7 +1549,9 @@ function constructField(name, parameters, options) {
|
|||||||
html += `<div id='div_${field_name}' class='${form_classes}'>`;
|
html += `<div id='div_${field_name}' class='${form_classes}'>`;
|
||||||
|
|
||||||
// Add a label
|
// Add a label
|
||||||
html += constructLabel(name, parameters);
|
if (!options.hideLabels) {
|
||||||
|
html += constructLabel(name, parameters);
|
||||||
|
}
|
||||||
|
|
||||||
html += `<div class='controls'>`;
|
html += `<div class='controls'>`;
|
||||||
|
|
||||||
@ -1589,7 +1598,7 @@ function constructField(name, parameters, options) {
|
|||||||
html += `</div>`; // input-group
|
html += `</div>`; // input-group
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parameters.help_text) {
|
if (parameters.help_text && !options.hideLabels) {
|
||||||
html += constructHelpText(name, parameters, options);
|
html += constructHelpText(name, parameters, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -10,6 +10,7 @@
|
|||||||
makeProgressBar,
|
makeProgressBar,
|
||||||
renderLink,
|
renderLink,
|
||||||
select2Thumbnail,
|
select2Thumbnail,
|
||||||
|
thumbnailImage
|
||||||
yesNoLabel,
|
yesNoLabel,
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -56,6 +57,26 @@ function imageHoverIcon(url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a simple thumbnail image
|
||||||
|
* @param {String} url is the image URL
|
||||||
|
* @returns html <img> tag
|
||||||
|
*/
|
||||||
|
function thumbnailImage(url) {
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
url = '/static/img/blank_img.png';
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Support insertion of custom classes
|
||||||
|
|
||||||
|
var html = `<img class='hover-img-thumb' src='${url}'>`;
|
||||||
|
|
||||||
|
return html;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Render a select2 thumbnail image
|
// Render a select2 thumbnail image
|
||||||
function select2Thumbnail(image) {
|
function select2Thumbnail(image) {
|
||||||
if (!image) {
|
if (!image) {
|
||||||
|
Loading…
Reference in New Issue
Block a user