Allocation by serial number now moved to the API

This commit is contained in:
Oliver 2021-12-04 13:08:00 +11:00
parent e9796676c0
commit 008c52ef39
8 changed files with 213 additions and 246 deletions

View File

@ -699,6 +699,30 @@ class SalesOrderComplete(generics.CreateAPIView):
return ctx return ctx
class SalesOrderAllocateSerials(generics.CreateAPIView):
"""
API endpoint to allocation stock items against a SalesOrder,
by specifying serial numbers.
"""
queryset = models.SalesOrder.objects.none()
serializer_class = serializers.SOSerialAllocationSerializer
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 SalesOrderAllocate(generics.CreateAPIView): class SalesOrderAllocate(generics.CreateAPIView):
""" """
API endpoint to allocate stock items against a SalesOrder API endpoint to allocate stock items against a SalesOrder
@ -944,6 +968,7 @@ order_api_urls = [
url(r'^(?P<pk>\d+)/', include([ url(r'^(?P<pk>\d+)/', include([
url(r'^complete/', SalesOrderComplete.as_view(), name='api-so-complete'), url(r'^complete/', SalesOrderComplete.as_view(), name='api-so-complete'),
url(r'^allocate/', SalesOrderAllocate.as_view(), name='api-so-allocate'), url(r'^allocate/', SalesOrderAllocate.as_view(), name='api-so-allocate'),
url(r'^allocate-serials/', SalesOrderAllocateSerials.as_view(), name='api-so-allocate-serials'),
url(r'^.*$', SODetail.as_view(), name='api-so-detail'), url(r'^.*$', SODetail.as_view(), name='api-so-detail'),
])), ])),

View File

@ -15,10 +15,8 @@ from InvenTree.helpers import clean_decimal
from common.forms import MatchItemForm from common.forms import MatchItemForm
import part.models
from .models import PurchaseOrder from .models import PurchaseOrder
from .models import SalesOrder, SalesOrderLineItem from .models import SalesOrder
class IssuePurchaseOrderForm(HelperForm): class IssuePurchaseOrderForm(HelperForm):
@ -65,46 +63,6 @@ class CancelSalesOrderForm(HelperForm):
] ]
class AllocateSerialsToSalesOrderForm(forms.Form):
"""
Form for assigning stock to a sales order,
by serial number lookup
TODO: Refactor this form / view to use the new API forms interface
"""
line = forms.ModelChoiceField(
queryset=SalesOrderLineItem.objects.all(),
)
part = forms.ModelChoiceField(
queryset=part.models.Part.objects.all(),
)
serials = forms.CharField(
label=_("Serial Numbers"),
required=True,
help_text=_('Enter stock item serial numbers'),
)
quantity = forms.IntegerField(
label=_('Quantity'),
required=True,
help_text=_('Enter quantity of stock items'),
initial=1,
min_value=1
)
class Meta:
fields = [
'line',
'part',
'serials',
'quantity',
]
class OrderMatchItemForm(MatchItemForm): class OrderMatchItemForm(MatchItemForm):
""" Override MatchItemForm fields """ """ Override MatchItemForm fields """

View File

@ -725,6 +725,7 @@ class SalesOrder(Order):
def pending_shipment_count(self): def pending_shipment_count(self):
return self.pending_shipments().count() return self.pending_shipments().count()
class PurchaseOrderAttachment(InvenTreeAttachment): class PurchaseOrderAttachment(InvenTreeAttachment):
""" """
Model for storing file attachments against a PurchaseOrder object Model for storing file attachments against a PurchaseOrder object

View File

@ -21,7 +21,7 @@ from common.settings import currency_code_mappings
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
from InvenTree.serializers import InvenTreeAttachmentSerializer from InvenTree.serializers import InvenTreeAttachmentSerializer
from InvenTree.helpers import normalize from InvenTree.helpers import normalize, extract_serial_numbers
from InvenTree.serializers import InvenTreeModelSerializer from InvenTree.serializers import InvenTreeModelSerializer
from InvenTree.serializers import InvenTreeDecimalField from InvenTree.serializers import InvenTreeDecimalField
from InvenTree.serializers import InvenTreeMoneySerializer from InvenTree.serializers import InvenTreeMoneySerializer
@ -724,7 +724,7 @@ class SOShipmentAllocationItemSerializer(serializers.Serializer):
def validate(self, data): def validate(self, data):
super().validate(data) data = super().validate(data)
stock_item = data['stock_item'] stock_item = data['stock_item']
quantity = data['quantity'] quantity = data['quantity']
@ -760,6 +760,169 @@ class SalesOrderCompleteSerializer(serializers.Serializer):
order.complete_order(user) order.complete_order(user)
class SOSerialAllocationSerializer(serializers.Serializer):
"""
DRF serializer for allocation of serial numbers against a sales order / shipment
"""
class Meta:
fields = [
'line_item',
'quantity',
'serial_numbers',
'shipment',
]
line_item = serializers.PrimaryKeyRelatedField(
queryset=order.models.SalesOrderLineItem.objects.all(),
many=False,
required=True,
allow_null=False,
label=_('Line Item'),
)
def validate_line_item(self, line_item):
"""
Ensure that the line_item is valid
"""
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
quantity = serializers.IntegerField(
min_value=1,
required=True,
allow_null=False,
label=_('Quantity'),
)
serial_numbers = serializers.CharField(
label=_("Serial Numbers"),
help_text=_("Enter serial numbers to allocate"),
required=True,
allow_blank=False,
)
shipment = serializers.PrimaryKeyRelatedField(
queryset=order.models.SalesOrderShipment.objects.all(),
many=False,
allow_null=False,
required=True,
label=_('Shipment'),
)
def validate_shipment(self, shipment):
"""
Validate the shipment:
- Must point to the same order
- Must not be shipped
"""
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):
"""
Validation for the serializer:
- Ensure the serial_numbers and quantity fields match
- Check that all serial numbers exist
- Check that the serial numbers are not yet allocated
"""
data = super().validate(data)
line_item = data['line_item']
quantity = data['quantity']
serial_numbers = data['serial_numbers']
part = line_item.part
try:
data['serials'] = extract_serial_numbers(serial_numbers, quantity)
except DjangoValidationError as e:
raise ValidationError({
'serial_numbers': e.messages,
})
serials_not_exist = []
serials_allocated = []
stock_items_to_allocate = []
for serial in data['serials']:
items = stock.models.StockItem.objects.filter(
part=part,
serial=serial,
quantity=1,
)
if not items.exists():
serials_not_exist.append(str(serial))
continue
stock_item = items[0]
if stock_item.unallocated_quantity() == 1:
stock_items_to_allocate.append(stock_item)
else:
serials_allocated.append(str(serial))
if len(serials_not_exist) > 0:
error_msg = _("No match found for the following serial numbers")
error_msg += ": "
error_msg += ",".join(serials_not_exist)
raise ValidationError({
'serial_numbers': error_msg
})
if len(serials_allocated) > 0:
error_msg = _("The following serial numbers are already allocated")
error_msg += ": "
error_msg += ",".join(serials_allocated)
raise ValidationError({
'serial_numbers': error_msg,
})
data['stock_items'] = stock_items_to_allocate
return data
def save(self):
data = self.validated_data
line_item = data['line_item']
stock_items = data['stock_items']
shipment = data['shipment']
with transaction.atomic():
for stock_item in stock_items:
# Create a new SalesOrderAllocation
order.models.SalesOrderAllocation.objects.create(
line=line_item,
item=stock_item,
quantity=1,
shipment=shipment
)
class SOShipmentAllocationSerializer(serializers.Serializer): class SOShipmentAllocationSerializer(serializers.Serializer):
""" """
DRF serializer for allocation of stock items against a sales order / shipment DRF serializer for allocation of stock items against a sales order / shipment
@ -833,11 +996,6 @@ class SOShipmentAllocationSerializer(serializers.Serializer):
shipment=shipment, shipment=shipment,
) )
try:
pass
except (ValidationError, DjangoValidationError) as exc:
raise ValidationError(detail=serializers.as_serializer_error(exc))
class SOAttachmentSerializer(InvenTreeAttachmentSerializer): class SOAttachmentSerializer(InvenTreeAttachmentSerializer):
""" """

View File

@ -1,12 +0,0 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
<div class='alert alert-block alert-info'>
{% include "hover_image.html" with image=part.image hover=true %}{{ part }}
<hr>
{% trans "Allocate stock items by serial number" %}
</div>
{% endblock %}

View File

@ -41,11 +41,6 @@ sales_order_detail_urls = [
] ]
sales_order_urls = [ sales_order_urls = [
# URLs for sales order allocations
url(r'^allocation/', include([
url(r'^assign-serials/', views.SalesOrderAssignSerials.as_view(), name='so-assign-serials'),
])),
# Display detail view for a single SalesOrder # Display detail view for a single SalesOrder
url(r'^(?P<pk>\d+)/', include(sales_order_detail_urls)), url(r'^(?P<pk>\d+)/', include(sales_order_detail_urls)),

View File

@ -9,12 +9,10 @@ from django.db import transaction
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from django.http.response import JsonResponse from django.http.response import JsonResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.core.exceptions import ValidationError
from django.urls import reverse from django.urls import reverse
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from django.views.generic.edit import FormMixin
from django.forms import HiddenInput, IntegerField from django.forms import HiddenInput, IntegerField
import logging import logging
@ -22,7 +20,6 @@ from decimal import Decimal, InvalidOperation
from .models import PurchaseOrder, PurchaseOrderLineItem from .models import PurchaseOrder, PurchaseOrderLineItem
from .models import SalesOrder, SalesOrderLineItem from .models import SalesOrder, SalesOrderLineItem
from .models import SalesOrderAllocation
from .admin import POLineItemResource, SOLineItemResource from .admin import POLineItemResource, SOLineItemResource
from build.models import Build from build.models import Build
from company.models import Company, SupplierPart # ManufacturerPart from company.models import Company, SupplierPart # ManufacturerPart
@ -38,7 +35,6 @@ from part.views import PartPricing
from InvenTree.views import AjaxView, AjaxUpdateView from InvenTree.views import AjaxView, AjaxUpdateView
from InvenTree.helpers import DownloadFile, str2bool from InvenTree.helpers import DownloadFile, str2bool
from InvenTree.helpers import extract_serial_numbers
from InvenTree.views import InvenTreeRoleMixin from InvenTree.views import InvenTreeRoleMixin
from InvenTree.status_codes import PurchaseOrderStatus from InvenTree.status_codes import PurchaseOrderStatus
@ -792,175 +788,6 @@ class OrderParts(AjaxView):
order.add_line_item(supplier_part, quantity, purchase_price=purchase_price) order.add_line_item(supplier_part, quantity, purchase_price=purchase_price)
class SalesOrderAssignSerials(AjaxView, FormMixin):
"""
View for assigning stock items to a sales order,
by serial number lookup.
"""
# TODO: Remove this class and replace with an API endpoint
model = SalesOrderAllocation
role_required = 'sales_order.change'
ajax_template_name = 'order/so_allocate_by_serial.html'
ajax_form_title = _('Allocate Serial Numbers')
form_class = order_forms.AllocateSerialsToSalesOrderForm
# Keep track of SalesOrderLineItem and Part references
line = None
part = None
def get_initial(self):
"""
Initial values are passed as query params
"""
initials = super().get_initial()
try:
self.line = SalesOrderLineItem.objects.get(pk=self.request.GET.get('line', None))
initials['line'] = self.line
except (ValueError, SalesOrderLineItem.DoesNotExist):
pass
try:
self.part = Part.objects.get(pk=self.request.GET.get('part', None))
initials['part'] = self.part
except (ValueError, Part.DoesNotExist):
pass
return initials
def post(self, request, *args, **kwargs):
self.form = self.get_form()
# Validate the form
self.form.is_valid()
self.validate()
valid = self.form.is_valid()
if valid:
self.allocate_items()
data = {
'form_valid': valid,
'form_errors': self.form.errors.as_json(),
'non_field_errors': self.form.non_field_errors().as_json(),
'success': _("Allocated {n} items").format(n=len(self.stock_items))
}
return self.renderJsonResponse(request, self.form, data)
def validate(self):
data = self.form.cleaned_data
# Extract hidden fields from posted data
self.line = data.get('line', None)
self.part = data.get('part', None)
if self.line:
self.form.fields['line'].widget = HiddenInput()
else:
self.form.add_error('line', _('Select line item'))
if self.part:
self.form.fields['part'].widget = HiddenInput()
else:
self.form.add_error('part', _('Select part'))
if not self.form.is_valid():
return
# Form is otherwise valid - check serial numbers
serials = data.get('serials', '')
quantity = data.get('quantity', 1)
# Save a list of serial_numbers
self.serial_numbers = None
self.stock_items = []
try:
self.serial_numbers = extract_serial_numbers(serials, quantity)
for serial in self.serial_numbers:
try:
# Find matching stock item
stock_item = StockItem.objects.get(
part=self.part,
serial=serial
)
except StockItem.DoesNotExist:
self.form.add_error(
'serials',
_('No matching item for serial {serial}').format(serial=serial)
)
continue
# Now we have a valid stock item - but can it be added to the sales order?
# If not in stock, cannot be added to the order
if not stock_item.in_stock:
self.form.add_error(
'serials',
_('{serial} is not in stock').format(serial=serial)
)
continue
# Already allocated to an order
if stock_item.is_allocated():
self.form.add_error(
'serials',
_('{serial} already allocated to an order').format(serial=serial)
)
continue
# Add it to the list!
self.stock_items.append(stock_item)
except ValidationError as e:
self.form.add_error('serials', e.messages)
def allocate_items(self):
"""
Create stock item allocations for each selected serial number
"""
for stock_item in self.stock_items:
SalesOrderAllocation.objects.create(
item=stock_item,
line=self.line,
quantity=1,
)
def get_form(self):
form = super().get_form()
if self.line:
form.fields['line'].widget = HiddenInput()
if self.part:
form.fields['part'].widget = HiddenInput()
return form
def get_context_data(self):
return {
'line': self.line,
'part': self.part,
}
def get(self, request, *args, **kwargs):
return self.renderJsonResponse(
request,
self.get_form(),
context=self.get_context_data(),
)
class LineItemPricing(PartPricing): class LineItemPricing(PartPricing):
""" View for inspecting part pricing information """ """ View for inspecting part pricing information """

View File

@ -2357,15 +2357,30 @@ function loadSalesOrderLineItemTable(table, options={}) {
$(table).find('.button-add-by-sn').click(function() { $(table).find('.button-add-by-sn').click(function() {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
// TODO: Migrate this form to the API forms
inventreeGet(`/api/order/so-line/${pk}/`, {}, inventreeGet(`/api/order/so-line/${pk}/`, {},
{ {
success: function(response) { success: function(response) {
launchModalForm('{% url "so-assign-serials" %}', {
success: reloadTable, constructForm(`/api/order/so/${options.order}/allocate-serials/`, {
data: { method: 'POST',
line: pk, title: '{% trans "Allocate Serial Numbers" %}',
part: response.part, fields: {
line_item: {
value: pk,
hidden: true,
},
quantity: {},
serial_numbers: {},
shipment: {
filters: {
order: options.order,
shipped: false,
},
auto_fill: true,
}
},
onSuccess: function() {
$(table).bootstrapTable('refresh');
} }
}); });
} }