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_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
v12 -> 2021-09-07
- Adds API endpoint to receive stock items against a PurchaseOrder
v11 -> 2021-08-26
- Adds "units" field to PartBriefSerializer
- 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 -*-
from __future__ import unicode_literals
from django.utils.translation import ugettext_lazy as _
from django.conf.urls import url, include
from django.db import transaction
from django_filters import rest_framework as rest_filters
from rest_framework import generics
from rest_framework import filters, status
from rest_framework.response import Response
from rest_framework.serializers import ValidationError
from InvenTree.filters import InvenTreeOrderingFilter
from InvenTree.helpers import str2bool
@ -28,6 +32,7 @@ from .models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation
from .models import SalesOrderAttachment
from .serializers import SalesOrderSerializer, SOLineItemSerializer, SOAttachmentSerializer
from .serializers import SalesOrderAllocationSerializer
from .serializers import POReceiveSerializer
class POList(generics.ListCreateAPIView):
@ -205,6 +210,111 @@ class PODetail(generics.RetrieveUpdateDestroyAPIView):
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):
""" API endpoint for accessing a list of POLineItem objects
@ -641,13 +751,25 @@ class POAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin)
order_api_urls = [
# API endpoints for purchase orders
url(r'po/attachment/', include([
url(r'^po/', include([
# 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'),
])),
url(r'^po/(?P<pk>\d+)/$', PODetail.as_view(), name='api-po-detail'),
url(r'^po/.*$', POList.as_view(), name='api-po-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'),
])),
# API endpoints for purchase order line items
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', '')
barcode = kwargs.get('barcode', '')
# Prevent null values for barcode
if barcode is None:
barcode = ''
if not self.status == PurchaseOrderStatus.PLACED:
raise ValidationError({"status": _("Lines can only be received against an order marked as 'Placed'")})
@ -433,7 +438,8 @@ class PurchaseOrder(Order):
quantity=quantity,
purchase_order=self,
status=status,
purchase_price=purchase_price,
purchase_price=line.purchase_price,
uid=barcode
)
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 rest_framework import serializers
from rest_framework.serializers import ValidationError
from sql_util.utils import SubqueryCount
from InvenTree.serializers import InvenTreeModelSerializer
@ -19,8 +21,13 @@ 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
from part.serializers import PartBriefSerializer
import stock.models
from stock.serializers import LocationBriefSerializer, StockItemSerializer, LocationSerializer
from .models import PurchaseOrder, PurchaseOrderLineItem
@ -137,7 +144,6 @@ class POLineItemSerializer(InvenTreeModelSerializer):
self.fields.pop('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)
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):
"""
Serializers for the PurchaseOrderAttachment model

View File

@ -9,8 +9,11 @@ from rest_framework import status
from django.urls import reverse
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):
@ -201,6 +204,250 @@ class PurchaseOrderTest(OrderTest):
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):
"""
Tests for the SalesOrder API

View File

@ -286,6 +286,8 @@ function constructForm(url, options) {
constructFormBody({}, options);
}
options.fields = options.fields || {};
// Save the URL
options.url = url;
@ -545,6 +547,11 @@ function constructFormBody(fields, options) {
initializeGroups(fields, options);
if (options.afterRender) {
// Custom callback function after form rendering
options.afterRender(fields, options);
}
// Scroll to the top
$(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}'>`;
// Add a label
if (!options.hideLabels) {
html += constructLabel(name, parameters);
}
html += `<div class='controls'>`;
@ -1589,7 +1598,7 @@ function constructField(name, parameters, options) {
html += `</div>`; // input-group
}
if (parameters.help_text) {
if (parameters.help_text && !options.hideLabels) {
html += constructHelpText(name, parameters, options);
}

View File

@ -10,6 +10,7 @@
makeProgressBar,
renderLink,
select2Thumbnail,
thumbnailImage
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
function select2Thumbnail(image) {
if (!image) {