Merge pull request #2013 from SchrodingersGat/receive-via-api

Receive via api
This commit is contained in:
Oliver 2021-09-07 23:56:44 +10:00 committed by GitHub
commit a70f4c86eb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 550 additions and 11 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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