mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'inventree:master' into fr-1421-sso
This commit is contained in:
commit
2d65f6f905
@ -141,3 +141,15 @@ class InvenTreeAPITestCase(APITestCase):
|
||||
self.assertEqual(response.status_code, expected_code)
|
||||
|
||||
return response
|
||||
|
||||
def options(self, url, expected_code=None):
|
||||
"""
|
||||
Issue an OPTIONS request
|
||||
"""
|
||||
|
||||
response = self.client.options(url, format='json')
|
||||
|
||||
if expected_code is not None:
|
||||
self.assertEqual(response.status_code, expected_code)
|
||||
|
||||
return response
|
||||
|
@ -0,0 +1,70 @@
|
||||
"""
|
||||
Custom management command to rebuild thumbnail images
|
||||
|
||||
- May be required after importing a new dataset, for example
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
|
||||
from PIL import UnidentifiedImageError
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.conf import settings
|
||||
from django.db.utils import OperationalError, ProgrammingError
|
||||
|
||||
from company.models import Company
|
||||
from part.models import Part
|
||||
|
||||
|
||||
logger = logging.getLogger("inventree-thumbnails")
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Rebuild all thumbnail images
|
||||
"""
|
||||
|
||||
def rebuild_thumbnail(self, model):
|
||||
"""
|
||||
Rebuild the thumbnail specified by the "image" field of the provided model
|
||||
"""
|
||||
|
||||
if not model.image:
|
||||
return
|
||||
|
||||
img = model.image
|
||||
url = img.thumbnail.name
|
||||
loc = os.path.join(settings.MEDIA_ROOT, url)
|
||||
|
||||
if not os.path.exists(loc):
|
||||
logger.info(f"Generating thumbnail image for '{img}'")
|
||||
|
||||
try:
|
||||
model.image.render_variations(replace=False)
|
||||
except FileNotFoundError:
|
||||
logger.error(f"ERROR: Image file '{img}' is missing")
|
||||
except UnidentifiedImageError:
|
||||
logger.error(f"ERROR: Image file '{img}' is not a valid image")
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
logger.info("Rebuilding Part thumbnails")
|
||||
|
||||
for part in Part.objects.exclude(image=None):
|
||||
try:
|
||||
self.rebuild_thumbnail(part)
|
||||
except (OperationalError, ProgrammingError):
|
||||
logger.error("ERROR: Database read error.")
|
||||
break
|
||||
|
||||
logger.info("Rebuilding Company thumbnails")
|
||||
|
||||
for company in Company.objects.exclude(image=None):
|
||||
try:
|
||||
self.rebuild_thumbnail(company)
|
||||
except (OperationalError, ProgrammingError):
|
||||
logger.error("ERROR: abase read error.")
|
||||
break
|
@ -10,11 +10,19 @@ import common.models
|
||||
|
||||
INVENTREE_SW_VERSION = "0.6.0 dev"
|
||||
|
||||
INVENTREE_API_VERSION = 12
|
||||
INVENTREE_API_VERSION = 14
|
||||
|
||||
"""
|
||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||
|
||||
v14 -> 2021-20-05
|
||||
- Stock adjustment actions API is improved, using native DRF serializer support
|
||||
- However adjustment actions now only support 'pk' as a lookup field
|
||||
|
||||
v13 -> 2021-10-05
|
||||
- Adds API endpoint to allocate stock items against a BuildOrder
|
||||
- Updates StockItem API with improved filtering against BomItem data
|
||||
|
||||
v12 -> 2021-09-07
|
||||
- Adds API endpoint to receive stock items against a PurchaseOrder
|
||||
|
||||
|
@ -5,10 +5,12 @@ JSON API for the Build 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 rest_framework import filters
|
||||
from rest_framework import generics
|
||||
from rest_framework import filters, generics
|
||||
from rest_framework.serializers import ValidationError
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django_filters import rest_framework as rest_filters
|
||||
@ -19,6 +21,7 @@ from InvenTree.status_codes import BuildStatus
|
||||
|
||||
from .models import Build, BuildItem, BuildOrderAttachment
|
||||
from .serializers import BuildAttachmentSerializer, BuildSerializer, BuildItemSerializer
|
||||
from .serializers import BuildAllocationSerializer
|
||||
|
||||
|
||||
class BuildFilter(rest_filters.FilterSet):
|
||||
@ -92,7 +95,7 @@ class BuildList(generics.ListCreateAPIView):
|
||||
as some of the fields don't natively play nicely with DRF
|
||||
"""
|
||||
|
||||
queryset = super().get_queryset().prefetch_related('part')
|
||||
queryset = super().get_queryset().select_related('part')
|
||||
|
||||
queryset = BuildSerializer.annotate_queryset(queryset)
|
||||
|
||||
@ -181,6 +184,58 @@ class BuildDetail(generics.RetrieveUpdateAPIView):
|
||||
serializer_class = BuildSerializer
|
||||
|
||||
|
||||
class BuildAllocate(generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint to allocate stock items to a build order
|
||||
|
||||
- The BuildOrder object is specified by the URL
|
||||
- Items to allocate are specified as a list called "items" with the following options:
|
||||
- bom_item: pk value of a given BomItem object (must match the part associated with this build)
|
||||
- stock_item: pk value of a given StockItem object
|
||||
- quantity: quantity to allocate
|
||||
- output: StockItem (build order output) to allocate stock against (optional)
|
||||
"""
|
||||
|
||||
queryset = Build.objects.none()
|
||||
|
||||
serializer_class = BuildAllocationSerializer
|
||||
|
||||
def get_build(self):
|
||||
"""
|
||||
Returns the BuildOrder associated with this API endpoint
|
||||
"""
|
||||
|
||||
pk = self.kwargs.get('pk', None)
|
||||
|
||||
try:
|
||||
build = Build.objects.get(pk=pk)
|
||||
except (Build.DoesNotExist, ValueError):
|
||||
raise ValidationError(_("Matching build order does not exist"))
|
||||
|
||||
return build
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""
|
||||
Provide the Build object to the serializer context
|
||||
"""
|
||||
|
||||
context = super().get_serializer_context()
|
||||
|
||||
context['build'] = self.get_build()
|
||||
context['request'] = self.request
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class BuildItemDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""
|
||||
API endpoint for detail view of a BuildItem object
|
||||
"""
|
||||
|
||||
queryset = BuildItem.objects.all()
|
||||
serializer_class = BuildItemSerializer
|
||||
|
||||
|
||||
class BuildItemList(generics.ListCreateAPIView):
|
||||
""" API endpoint for accessing a list of BuildItem objects
|
||||
|
||||
@ -210,9 +265,9 @@ class BuildItemList(generics.ListCreateAPIView):
|
||||
|
||||
query = BuildItem.objects.all()
|
||||
|
||||
query = query.select_related('stock_item')
|
||||
query = query.prefetch_related('stock_item__part')
|
||||
query = query.prefetch_related('stock_item__part__category')
|
||||
query = query.select_related('stock_item__location')
|
||||
query = query.select_related('stock_item__part')
|
||||
query = query.select_related('stock_item__part__category')
|
||||
|
||||
return query
|
||||
|
||||
@ -282,16 +337,20 @@ build_api_urls = [
|
||||
# Attachments
|
||||
url(r'^attachment/', include([
|
||||
url(r'^(?P<pk>\d+)/', BuildAttachmentDetail.as_view(), name='api-build-attachment-detail'),
|
||||
url('^.*$', BuildAttachmentList.as_view(), name='api-build-attachment-list'),
|
||||
url(r'^.*$', BuildAttachmentList.as_view(), name='api-build-attachment-list'),
|
||||
])),
|
||||
|
||||
# Build Items
|
||||
url(r'^item/', include([
|
||||
url('^.*$', BuildItemList.as_view(), name='api-build-item-list')
|
||||
url(r'^(?P<pk>\d+)/', BuildItemDetail.as_view(), name='api-build-item-detail'),
|
||||
url(r'^.*$', BuildItemList.as_view(), name='api-build-item-list'),
|
||||
])),
|
||||
|
||||
# Build Detail
|
||||
url(r'^(?P<pk>\d+)/', BuildDetail.as_view(), name='api-build-detail'),
|
||||
url(r'^(?P<pk>\d+)/', include([
|
||||
url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
|
||||
url(r'^.*$', BuildDetail.as_view(), name='api-build-detail'),
|
||||
])),
|
||||
|
||||
# Build List
|
||||
url(r'^.*$', BuildList.as_view(), name='api-build-list'),
|
||||
|
@ -3,7 +3,7 @@
|
||||
- model: build.build
|
||||
pk: 1
|
||||
fields:
|
||||
part: 25
|
||||
part: 100 # Build against part 100 "Bob"
|
||||
batch: 'B1'
|
||||
reference: "0001"
|
||||
title: 'Building 7 parts'
|
||||
|
@ -15,7 +15,7 @@ from InvenTree.fields import DatePickerFormField
|
||||
|
||||
from InvenTree.status_codes import StockStatus
|
||||
|
||||
from .models import Build, BuildItem
|
||||
from .models import Build
|
||||
|
||||
from stock.models import StockLocation, StockItem
|
||||
|
||||
@ -163,18 +163,6 @@ class UnallocateBuildForm(HelperForm):
|
||||
]
|
||||
|
||||
|
||||
class AutoAllocateForm(HelperForm):
|
||||
""" Form for auto-allocation of stock to a build """
|
||||
|
||||
confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Confirm stock allocation'))
|
||||
|
||||
class Meta:
|
||||
model = Build
|
||||
fields = [
|
||||
'confirm',
|
||||
]
|
||||
|
||||
|
||||
class CompleteBuildForm(HelperForm):
|
||||
"""
|
||||
Form for marking a build as complete
|
||||
@ -256,22 +244,3 @@ class CancelBuildForm(HelperForm):
|
||||
fields = [
|
||||
'confirm_cancel'
|
||||
]
|
||||
|
||||
|
||||
class EditBuildItemForm(HelperForm):
|
||||
"""
|
||||
Form for creating (or editing) a BuildItem object.
|
||||
"""
|
||||
|
||||
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'), help_text=_('Select quantity of stock to allocate'))
|
||||
|
||||
part_id = forms.IntegerField(required=False, widget=forms.HiddenInput())
|
||||
|
||||
class Meta:
|
||||
model = BuildItem
|
||||
fields = [
|
||||
'build',
|
||||
'stock_item',
|
||||
'quantity',
|
||||
'install_into',
|
||||
]
|
||||
|
@ -4,12 +4,14 @@ Build database model definitions
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
import decimal
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from django.urls import reverse
|
||||
@ -584,86 +586,6 @@ class Build(MPTTModel):
|
||||
self.status = BuildStatus.CANCELLED
|
||||
self.save()
|
||||
|
||||
def getAutoAllocations(self):
|
||||
"""
|
||||
Return a list of StockItem objects which will be allocated
|
||||
using the 'AutoAllocate' function.
|
||||
|
||||
For each item in the BOM for the attached Part,
|
||||
the following tests must *all* evaluate to True,
|
||||
for the part to be auto-allocated:
|
||||
|
||||
- The sub_item in the BOM line must *not* be trackable
|
||||
- There is only a single stock item available (which has not already been allocated to this build)
|
||||
- The stock item has an availability greater than zero
|
||||
|
||||
Returns:
|
||||
A list object containing the StockItem objects to be allocated (and the quantities).
|
||||
Each item in the list is a dict as follows:
|
||||
{
|
||||
'stock_item': stock_item,
|
||||
'quantity': stock_quantity,
|
||||
}
|
||||
"""
|
||||
|
||||
allocations = []
|
||||
|
||||
"""
|
||||
Iterate through each item in the BOM
|
||||
"""
|
||||
|
||||
for bom_item in self.bom_items:
|
||||
|
||||
part = bom_item.sub_part
|
||||
|
||||
# If the part is "trackable" it cannot be auto-allocated
|
||||
if part.trackable:
|
||||
continue
|
||||
|
||||
# Skip any parts which are already fully allocated
|
||||
if self.isPartFullyAllocated(part, None):
|
||||
continue
|
||||
|
||||
# How many parts are required to complete the output?
|
||||
required = self.unallocatedQuantity(part, None)
|
||||
|
||||
# Grab a list of stock items which are available
|
||||
stock_items = self.availableStockItems(part, None)
|
||||
|
||||
# Ensure that the available stock items are in the correct location
|
||||
if self.take_from is not None:
|
||||
# Filter for stock that is located downstream of the designated location
|
||||
stock_items = stock_items.filter(location__in=[loc for loc in self.take_from.getUniqueChildren()])
|
||||
|
||||
# Only one StockItem to choose from? Default to that one!
|
||||
if stock_items.count() == 1:
|
||||
stock_item = stock_items[0]
|
||||
|
||||
# Double check that we have not already allocated this stock-item against this build
|
||||
build_items = BuildItem.objects.filter(
|
||||
build=self,
|
||||
stock_item=stock_item,
|
||||
)
|
||||
|
||||
if len(build_items) > 0:
|
||||
continue
|
||||
|
||||
# How many items are actually available?
|
||||
if stock_item.quantity > 0:
|
||||
|
||||
# Only take as many as are available
|
||||
if stock_item.quantity < required:
|
||||
required = stock_item.quantity
|
||||
|
||||
allocation = {
|
||||
'stock_item': stock_item,
|
||||
'quantity': required,
|
||||
}
|
||||
|
||||
allocations.append(allocation)
|
||||
|
||||
return allocations
|
||||
|
||||
@transaction.atomic
|
||||
def unallocateOutput(self, output, part=None):
|
||||
"""
|
||||
@ -803,37 +725,6 @@ class Build(MPTTModel):
|
||||
# Remove the build output from the database
|
||||
output.delete()
|
||||
|
||||
@transaction.atomic
|
||||
def autoAllocate(self):
|
||||
"""
|
||||
Run auto-allocation routine to allocate StockItems to this Build.
|
||||
|
||||
Args:
|
||||
output: If specified, only auto-allocate against the given built output
|
||||
|
||||
Returns a list of dict objects with keys like:
|
||||
|
||||
{
|
||||
'stock_item': item,
|
||||
'quantity': quantity,
|
||||
}
|
||||
|
||||
See: getAutoAllocations()
|
||||
"""
|
||||
|
||||
allocations = self.getAutoAllocations()
|
||||
|
||||
for item in allocations:
|
||||
# Create a new allocation
|
||||
build_item = BuildItem(
|
||||
build=self,
|
||||
stock_item=item['stock_item'],
|
||||
quantity=item['quantity'],
|
||||
install_into=None
|
||||
)
|
||||
|
||||
build_item.save()
|
||||
|
||||
@transaction.atomic
|
||||
def subtractUntrackedStock(self, user):
|
||||
"""
|
||||
@ -1165,8 +1056,10 @@ class BuildItem(models.Model):
|
||||
|
||||
Attributes:
|
||||
build: Link to a Build object
|
||||
bom_item: Link to a BomItem object (may or may not point to the same part as the build)
|
||||
stock_item: Link to a StockItem object
|
||||
quantity: Number of units allocated
|
||||
install_into: Destination stock item (or None)
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@ -1185,35 +1078,13 @@ class BuildItem(models.Model):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
self.validate_unique()
|
||||
self.clean()
|
||||
|
||||
super().save()
|
||||
|
||||
def validate_unique(self, exclude=None):
|
||||
"""
|
||||
Test that this BuildItem object is "unique".
|
||||
Essentially we do not want a stock_item being allocated to a Build multiple times.
|
||||
"""
|
||||
|
||||
super().validate_unique(exclude)
|
||||
|
||||
items = BuildItem.objects.exclude(id=self.id).filter(
|
||||
build=self.build,
|
||||
stock_item=self.stock_item,
|
||||
install_into=self.install_into
|
||||
)
|
||||
|
||||
if items.exists():
|
||||
msg = _("BuildItem must be unique for build, stock_item and install_into")
|
||||
raise ValidationError({
|
||||
'build': msg,
|
||||
'stock_item': msg,
|
||||
'install_into': msg
|
||||
})
|
||||
|
||||
def clean(self):
|
||||
""" Check validity of the BuildItem model.
|
||||
"""
|
||||
Check validity of this BuildItem instance.
|
||||
The following checks are performed:
|
||||
|
||||
- StockItem.part must be in the BOM of the Part object referenced by Build
|
||||
@ -1224,8 +1095,6 @@ class BuildItem(models.Model):
|
||||
|
||||
super().clean()
|
||||
|
||||
errors = {}
|
||||
|
||||
try:
|
||||
|
||||
# If the 'part' is trackable, then the 'install_into' field must be set!
|
||||
@ -1234,29 +1103,39 @@ class BuildItem(models.Model):
|
||||
|
||||
# Allocated quantity cannot exceed available stock quantity
|
||||
if self.quantity > self.stock_item.quantity:
|
||||
errors['quantity'] = [_("Allocated quantity ({n}) must not exceed available quantity ({q})").format(
|
||||
n=normalize(self.quantity),
|
||||
q=normalize(self.stock_item.quantity)
|
||||
)]
|
||||
|
||||
q = normalize(self.quantity)
|
||||
a = normalize(self.stock_item.quantity)
|
||||
|
||||
raise ValidationError({
|
||||
'quantity': _(f'Allocated quantity ({q}) must not execed available stock quantity ({a})')
|
||||
})
|
||||
|
||||
# Allocated quantity cannot cause the stock item to be over-allocated
|
||||
if self.stock_item.quantity - self.stock_item.allocation_count() + self.quantity < self.quantity:
|
||||
errors['quantity'] = _('StockItem is over-allocated')
|
||||
available = decimal.Decimal(self.stock_item.quantity)
|
||||
allocated = decimal.Decimal(self.stock_item.allocation_count())
|
||||
quantity = decimal.Decimal(self.quantity)
|
||||
|
||||
if available - allocated + quantity < quantity:
|
||||
raise ValidationError({
|
||||
'quantity': _('Stock item is over-allocated')
|
||||
})
|
||||
|
||||
# Allocated quantity must be positive
|
||||
if self.quantity <= 0:
|
||||
errors['quantity'] = _('Allocation quantity must be greater than zero')
|
||||
raise ValidationError({
|
||||
'quantity': _('Allocation quantity must be greater than zero'),
|
||||
})
|
||||
|
||||
# Quantity must be 1 for serialized stock
|
||||
if self.stock_item.serialized and not self.quantity == 1:
|
||||
errors['quantity'] = _('Quantity must be 1 for serialized stock')
|
||||
raise ValidationError({
|
||||
'quantity': _('Quantity must be 1 for serialized stock')
|
||||
})
|
||||
|
||||
except (StockModels.StockItem.DoesNotExist, PartModels.Part.DoesNotExist):
|
||||
pass
|
||||
|
||||
if len(errors) > 0:
|
||||
raise ValidationError(errors)
|
||||
|
||||
"""
|
||||
Attempt to find the "BomItem" which links this BuildItem to the build.
|
||||
|
||||
@ -1269,7 +1148,7 @@ class BuildItem(models.Model):
|
||||
"""
|
||||
A BomItem object has already been assigned. This is valid if:
|
||||
|
||||
a) It points to the same "part" as the referened build
|
||||
a) It points to the same "part" as the referenced build
|
||||
b) Either:
|
||||
i) The sub_part points to the same part as the referenced StockItem
|
||||
ii) The BomItem allows variants and the part referenced by the StockItem
|
||||
@ -1309,7 +1188,7 @@ class BuildItem(models.Model):
|
||||
if not bom_item_valid:
|
||||
|
||||
raise ValidationError({
|
||||
'stock_item': _("Selected stock item not found in BOM for part '{p}'").format(p=self.build.part.full_name)
|
||||
'stock_item': _("Selected stock item not found in BOM")
|
||||
})
|
||||
|
||||
@transaction.atomic
|
||||
|
@ -5,16 +5,25 @@ JSON serializers for Build API
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import transaction
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from django.db.models import Case, When, Value
|
||||
from django.db.models import BooleanField
|
||||
|
||||
from rest_framework import serializers
|
||||
from rest_framework.serializers import ValidationError
|
||||
|
||||
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
|
||||
from InvenTree.serializers import InvenTreeAttachmentSerializerField, UserSerializerBrief
|
||||
|
||||
from stock.serializers import StockItemSerializerBrief
|
||||
from stock.serializers import LocationSerializer
|
||||
import InvenTree.helpers
|
||||
|
||||
from stock.models import StockItem
|
||||
from stock.serializers import StockItemSerializerBrief, LocationSerializer
|
||||
|
||||
from part.models import BomItem
|
||||
from part.serializers import PartSerializer, PartBriefSerializer
|
||||
from users.serializers import OwnerSerializer
|
||||
|
||||
@ -22,7 +31,9 @@ from .models import Build, BuildItem, BuildOrderAttachment
|
||||
|
||||
|
||||
class BuildSerializer(InvenTreeModelSerializer):
|
||||
""" Serializes a Build object """
|
||||
"""
|
||||
Serializes a Build object
|
||||
"""
|
||||
|
||||
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
||||
status_text = serializers.CharField(source='get_status_display', read_only=True)
|
||||
@ -109,6 +120,170 @@ class BuildSerializer(InvenTreeModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class BuildAllocationItemSerializer(serializers.Serializer):
|
||||
"""
|
||||
A serializer for allocating a single stock item against a build order
|
||||
"""
|
||||
|
||||
bom_item = serializers.PrimaryKeyRelatedField(
|
||||
queryset=BomItem.objects.all(),
|
||||
many=False,
|
||||
allow_null=False,
|
||||
required=True,
|
||||
label=_('BOM Item'),
|
||||
)
|
||||
|
||||
def validate_bom_item(self, bom_item):
|
||||
|
||||
build = self.context['build']
|
||||
|
||||
# BomItem must point to the same 'part' as the parent build
|
||||
if build.part != bom_item.part:
|
||||
raise ValidationError(_("bom_item.part must point to the same part as the build order"))
|
||||
|
||||
return bom_item
|
||||
|
||||
stock_item = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockItem.objects.all(),
|
||||
many=False,
|
||||
allow_null=False,
|
||||
required=True,
|
||||
label=_('Stock Item'),
|
||||
)
|
||||
|
||||
def validate_stock_item(self, stock_item):
|
||||
|
||||
if not stock_item.in_stock:
|
||||
raise ValidationError(_("Item must be in stock"))
|
||||
|
||||
return 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 greater than zero"))
|
||||
|
||||
return quantity
|
||||
|
||||
output = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockItem.objects.filter(is_building=True),
|
||||
many=False,
|
||||
allow_null=True,
|
||||
required=False,
|
||||
label=_('Build Output'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
'bom_item',
|
||||
'stock_item',
|
||||
'quantity',
|
||||
'output',
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
super().validate(data)
|
||||
|
||||
bom_item = data['bom_item']
|
||||
stock_item = data['stock_item']
|
||||
quantity = data['quantity']
|
||||
output = data.get('output', None)
|
||||
|
||||
# build = self.context['build']
|
||||
|
||||
# TODO: Check that the "stock item" is valid for the referenced "sub_part"
|
||||
# Note: Because of allow_variants options, it may not be a direct match!
|
||||
|
||||
# Check that the quantity does not exceed the available amount from the stock item
|
||||
q = stock_item.unallocated_quantity()
|
||||
|
||||
if quantity > q:
|
||||
|
||||
q = InvenTree.helpers.clean_decimal(q)
|
||||
|
||||
raise ValidationError({
|
||||
'quantity': _(f"Available quantity ({q}) exceeded")
|
||||
})
|
||||
|
||||
# Output *must* be set for trackable parts
|
||||
if output is None and bom_item.sub_part.trackable:
|
||||
raise ValidationError({
|
||||
'output': _('Build output must be specified for allocation of tracked parts')
|
||||
})
|
||||
|
||||
# Output *cannot* be set for un-tracked parts
|
||||
if output is not None and not bom_item.sub_part.trackable:
|
||||
|
||||
raise ValidationError({
|
||||
'output': _('Build output cannot be specified for allocation of untracked parts')
|
||||
})
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class BuildAllocationSerializer(serializers.Serializer):
|
||||
"""
|
||||
DRF serializer for allocation stock items against a build order
|
||||
"""
|
||||
|
||||
items = BuildAllocationItemSerializer(many=True)
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
'items',
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
"""
|
||||
Validation
|
||||
"""
|
||||
|
||||
super().validate(data)
|
||||
|
||||
items = data.get('items', [])
|
||||
|
||||
if len(items) == 0:
|
||||
raise ValidationError(_('Allocation items must be provided'))
|
||||
|
||||
return data
|
||||
|
||||
def save(self):
|
||||
|
||||
data = self.validated_data
|
||||
|
||||
items = data.get('items', [])
|
||||
|
||||
build = self.context['build']
|
||||
|
||||
with transaction.atomic():
|
||||
for item in items:
|
||||
bom_item = item['bom_item']
|
||||
stock_item = item['stock_item']
|
||||
quantity = item['quantity']
|
||||
output = item.get('output', None)
|
||||
|
||||
try:
|
||||
# Create a new BuildItem to allocate stock
|
||||
BuildItem.objects.create(
|
||||
build=build,
|
||||
bom_item=bom_item,
|
||||
stock_item=stock_item,
|
||||
quantity=quantity,
|
||||
install_into=output
|
||||
)
|
||||
except (ValidationError, DjangoValidationError) as exc:
|
||||
# Catch model errors and re-throw as DRF errors
|
||||
raise ValidationError(detail=serializers.as_serializer_error(exc))
|
||||
|
||||
|
||||
class BuildItemSerializer(InvenTreeModelSerializer):
|
||||
""" Serializes a BuildItem object """
|
||||
|
||||
|
@ -1,43 +0,0 @@
|
||||
{% extends "modal_form.html" %}
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
{% block pre_form_content %}
|
||||
|
||||
{{ block.super }}
|
||||
|
||||
<div class='alert alert-block alert-info'>
|
||||
<strong>{% trans "Automatically Allocate Stock" %}</strong><br>
|
||||
{% trans "The following stock items will be allocated to the specified build output" %}
|
||||
</div>
|
||||
{% if allocations %}
|
||||
<table class='table table-striped table-condensed'>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>{% trans "Part" %}</th>
|
||||
<th>{% trans "Quantity" %}</th>
|
||||
<th>{% trans "Location" %}</th>
|
||||
</tr>
|
||||
{% for item in allocations %}
|
||||
<tr>
|
||||
<td>
|
||||
{% include "hover_image.html" with image=item.stock_item.part.image hover=True %}
|
||||
</td>
|
||||
<td>
|
||||
{{ item.stock_item.part.full_name }}<br>
|
||||
<em>{{ item.stock_item.part.description }}</em>
|
||||
</td>
|
||||
<td>{% decimal item.quantity %}</td>
|
||||
<td>{{ item.stock_item.location }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
{% else %}
|
||||
<div class='alert alert-block alert-warning'>
|
||||
{% trans "No stock items found that can be automatically allocated to this build" %}
|
||||
<br>
|
||||
{% trans "Stock items will have to be manually allocated" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
@ -1,20 +0,0 @@
|
||||
{% extends "modal_form.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
<div class='alert alert-block alert-info'>
|
||||
<p>
|
||||
{% trans "Select a stock item to allocate to the selected build output" %}
|
||||
</p>
|
||||
{% if output %}
|
||||
<p>
|
||||
{% blocktrans %}The allocated stock will be installed into the following build output:<br><em>{{output}}</em>{% endblocktrans %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if no_stock %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% blocktrans %}No stock available for {{part}}{% endblocktrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
@ -1,14 +0,0 @@
|
||||
{% extends "modal_delete_form.html" %}
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
<div class='alert alert-block alert-danger'>
|
||||
<p>
|
||||
{% trans "Are you sure you want to unallocate this stock?" %}
|
||||
</p>
|
||||
<p>
|
||||
{% trans "The selected stock will be unallocated from the build output" %}
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
@ -170,7 +170,7 @@
|
||||
{% if build.active %}
|
||||
<div class='btn-group' role='group'>
|
||||
<button class='btn btn-success' type='button' id='btn-auto-allocate' title='{% trans "Allocate stock to build" %}'>
|
||||
<span class='fas fa-magic'></span> {% trans "Auto Allocate" %}
|
||||
<span class='fas fa-sign-in-alt'></span> {% trans "Allocate Stock" %}
|
||||
</button>
|
||||
<button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Unallocate stock" %}'>
|
||||
<span class='fas fa-minus-circle'></span> {% trans "Unallocate Stock" %}
|
||||
@ -191,7 +191,19 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<table class='table table-striped table-condensed' id='allocation-table-untracked'></table>
|
||||
<div id='unallocated-toolbar'>
|
||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||
<div class='btn-group'>
|
||||
<button id='allocate-selected-items' class='btn btn-success' title='{% trans "Allocate selected items" %}'>
|
||||
<span class='fas fa-sign-in-alt'></span>
|
||||
</button>
|
||||
<div class='filter-list' id='filter-list-build-items'>
|
||||
<!-- Empty div for table filters-->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class='table table-striped table-condensed' id='allocation-table-untracked' data-toolbar='#unallocated-toolbar'></table>
|
||||
{% else %}
|
||||
<div class='alert alert-block alert-info'>
|
||||
{% trans "This Build Order does not have any associated untracked BOM items" %}
|
||||
@ -292,6 +304,7 @@ loadStockTable($("#build-stock-table"), {
|
||||
location_detail: true,
|
||||
part_detail: true,
|
||||
build: {{ build.id }},
|
||||
is_building: false,
|
||||
},
|
||||
groupByField: 'location',
|
||||
buttons: [
|
||||
@ -305,6 +318,9 @@ var buildInfo = {
|
||||
quantity: {{ build.quantity }},
|
||||
completed: {{ build.completed }},
|
||||
part: {{ build.part.pk }},
|
||||
{% if build.take_from %}
|
||||
source_location: {{ build.take_from.pk }},
|
||||
{% endif %}
|
||||
};
|
||||
|
||||
{% for item in build.incomplete_outputs %}
|
||||
@ -400,13 +416,6 @@ $('#edit-notes').click(function() {
|
||||
});
|
||||
});
|
||||
|
||||
var buildInfo = {
|
||||
pk: {{ build.pk }},
|
||||
quantity: {{ build.quantity }},
|
||||
completed: {{ build.completed }},
|
||||
part: {{ build.part.pk }},
|
||||
};
|
||||
|
||||
{% if build.has_untracked_bom_items %}
|
||||
// Load allocation table for un-tracked parts
|
||||
loadBuildOutputAllocationTable(buildInfo, null);
|
||||
@ -418,12 +427,38 @@ function reloadTable() {
|
||||
|
||||
{% if build.active %}
|
||||
$("#btn-auto-allocate").on('click', function() {
|
||||
launchModalForm(
|
||||
"{% url 'build-auto-allocate' build.id %}",
|
||||
{
|
||||
success: reloadTable,
|
||||
|
||||
var bom_items = $("#allocation-table-untracked").bootstrapTable("getData");
|
||||
|
||||
var incomplete_bom_items = [];
|
||||
|
||||
bom_items.forEach(function(bom_item) {
|
||||
if (bom_item.required > bom_item.allocated) {
|
||||
incomplete_bom_items.push(bom_item);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
if (incomplete_bom_items.length == 0) {
|
||||
showAlertDialog(
|
||||
'{% trans "Allocation Complete" %}',
|
||||
'{% trans "All untracked stock items have been allocated" %}',
|
||||
);
|
||||
} else {
|
||||
|
||||
allocateStockToBuild(
|
||||
{{ build.pk }},
|
||||
{{ build.part.pk }},
|
||||
incomplete_bom_items,
|
||||
{
|
||||
{% if build.take_from %}
|
||||
source_location: {{ build.take_from.pk }},
|
||||
{% endif %}
|
||||
success: function(data) {
|
||||
$('#allocation-table-untracked').bootstrapTable('refresh');
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
$('#btn-unallocate').on('click', function() {
|
||||
@ -435,6 +470,25 @@ $('#btn-unallocate').on('click', function() {
|
||||
);
|
||||
});
|
||||
|
||||
$('#allocate-selected-items').click(function() {
|
||||
|
||||
var bom_items = $("#allocation-table-untracked").bootstrapTable("getSelections");
|
||||
|
||||
allocateStockToBuild(
|
||||
{{ build.pk }},
|
||||
{{ build.part.pk }},
|
||||
bom_items,
|
||||
{
|
||||
{% if build.take_from %}
|
||||
source_location: {{ build.take_from.pk }},
|
||||
{% endif %}
|
||||
success: function(data) {
|
||||
$('#allocation-table-untracked').bootstrapTable('refresh');
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$("#btn-order-parts").click(function() {
|
||||
launchModalForm("/order/purchase-order/order-parts/", {
|
||||
data: {
|
||||
|
@ -6,7 +6,7 @@ from datetime import datetime, timedelta
|
||||
from django.urls import reverse
|
||||
|
||||
from part.models import Part
|
||||
from build.models import Build
|
||||
from build.models import Build, BuildItem
|
||||
|
||||
from InvenTree.status_codes import BuildStatus
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
@ -23,6 +23,7 @@ class BuildAPITest(InvenTreeAPITestCase):
|
||||
'location',
|
||||
'bom',
|
||||
'build',
|
||||
'stock',
|
||||
]
|
||||
|
||||
# Required roles to access Build API endpoints
|
||||
@ -36,6 +37,192 @@ class BuildAPITest(InvenTreeAPITestCase):
|
||||
super().setUp()
|
||||
|
||||
|
||||
class BuildAllocationTest(BuildAPITest):
|
||||
"""
|
||||
Unit tests for allocation of stock items against a build order.
|
||||
|
||||
For this test, we will be using Build ID=1;
|
||||
|
||||
- This points to Part 100 (see fixture data in part.yaml)
|
||||
- This Part already has a BOM with 4 items (see fixture data in bom.yaml)
|
||||
- There are no BomItem objects yet created for this build
|
||||
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
self.assignRole('build.add')
|
||||
self.assignRole('build.change')
|
||||
|
||||
self.url = reverse('api-build-allocate', kwargs={'pk': 1})
|
||||
|
||||
self.build = Build.objects.get(pk=1)
|
||||
|
||||
# Record number of build items which exist at the start of each test
|
||||
self.n = BuildItem.objects.count()
|
||||
|
||||
def test_build_data(self):
|
||||
"""
|
||||
Check that our assumptions about the particular BuildOrder are correct
|
||||
"""
|
||||
|
||||
self.assertEqual(self.build.part.pk, 100)
|
||||
|
||||
# There should be 4x BOM items we can use
|
||||
self.assertEqual(self.build.part.bom_items.count(), 4)
|
||||
|
||||
# No items yet allocated to this build
|
||||
self.assertEqual(self.build.allocated_stock.count(), 0)
|
||||
|
||||
def test_get(self):
|
||||
"""
|
||||
A GET request to the endpoint should return an error
|
||||
"""
|
||||
|
||||
self.get(self.url, expected_code=405)
|
||||
|
||||
def test_options(self):
|
||||
"""
|
||||
An OPTIONS request to the endpoint should return information about the endpoint
|
||||
"""
|
||||
|
||||
response = self.options(self.url, expected_code=200)
|
||||
|
||||
self.assertIn("API endpoint to allocate stock items to a build order", str(response.data))
|
||||
|
||||
def test_empty(self):
|
||||
"""
|
||||
Test without any POST data
|
||||
"""
|
||||
|
||||
# Initially test with an empty data set
|
||||
data = self.post(self.url, {}, expected_code=400).data
|
||||
|
||||
self.assertIn('This field is required', str(data['items']))
|
||||
|
||||
# Now test but with an empty items list
|
||||
data = self.post(
|
||||
self.url,
|
||||
{
|
||||
"items": []
|
||||
},
|
||||
expected_code=400
|
||||
).data
|
||||
|
||||
self.assertIn('Allocation items must be provided', str(data))
|
||||
|
||||
# No new BuildItem objects have been created during this test
|
||||
self.assertEqual(self.n, BuildItem.objects.count())
|
||||
|
||||
def test_missing(self):
|
||||
"""
|
||||
Test with missing data
|
||||
"""
|
||||
|
||||
# Missing quantity
|
||||
data = self.post(
|
||||
self.url,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"bom_item": 1, # M2x4 LPHS
|
||||
"stock_item": 2, # 5,000 screws available
|
||||
}
|
||||
]
|
||||
},
|
||||
expected_code=400
|
||||
).data
|
||||
|
||||
self.assertIn('This field is required', str(data["items"][0]["quantity"]))
|
||||
|
||||
# Missing bom_item
|
||||
data = self.post(
|
||||
self.url,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"stock_item": 2,
|
||||
"quantity": 5000,
|
||||
}
|
||||
]
|
||||
},
|
||||
expected_code=400
|
||||
).data
|
||||
|
||||
self.assertIn("This field is required", str(data["items"][0]["bom_item"]))
|
||||
|
||||
# Missing stock_item
|
||||
data = self.post(
|
||||
self.url,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"bom_item": 1,
|
||||
"quantity": 5000,
|
||||
}
|
||||
]
|
||||
},
|
||||
expected_code=400
|
||||
).data
|
||||
|
||||
self.assertIn("This field is required", str(data["items"][0]["stock_item"]))
|
||||
|
||||
# No new BuildItem objects have been created during this test
|
||||
self.assertEqual(self.n, BuildItem.objects.count())
|
||||
|
||||
def test_invalid_bom_item(self):
|
||||
"""
|
||||
Test by passing an invalid BOM item
|
||||
"""
|
||||
|
||||
data = self.post(
|
||||
self.url,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"bom_item": 5,
|
||||
"stock_item": 11,
|
||||
"quantity": 500,
|
||||
}
|
||||
]
|
||||
},
|
||||
expected_code=400
|
||||
).data
|
||||
|
||||
self.assertIn('must point to the same part', str(data))
|
||||
|
||||
def test_valid_data(self):
|
||||
"""
|
||||
Test with valid data.
|
||||
This should result in creation of a new BuildItem object
|
||||
"""
|
||||
|
||||
self.post(
|
||||
self.url,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"bom_item": 1,
|
||||
"stock_item": 2,
|
||||
"quantity": 5000,
|
||||
}
|
||||
]
|
||||
},
|
||||
expected_code=201
|
||||
)
|
||||
|
||||
# A new BuildItem should have been created
|
||||
self.assertEqual(self.n + 1, BuildItem.objects.count())
|
||||
|
||||
allocation = BuildItem.objects.last()
|
||||
|
||||
self.assertEqual(allocation.quantity, 5000)
|
||||
self.assertEqual(allocation.bom_item.pk, 1)
|
||||
self.assertEqual(allocation.stock_item.pk, 2)
|
||||
|
||||
|
||||
class BuildListTest(BuildAPITest):
|
||||
"""
|
||||
Tests for the BuildOrder LIST API
|
||||
|
@ -269,25 +269,6 @@ class BuildTest(TestCase):
|
||||
|
||||
self.assertTrue(self.build.areUntrackedPartsFullyAllocated())
|
||||
|
||||
def test_auto_allocate(self):
|
||||
"""
|
||||
Test auto-allocation functionality against the build outputs.
|
||||
|
||||
Note: auto-allocations only work for un-tracked stock!
|
||||
"""
|
||||
|
||||
allocations = self.build.getAutoAllocations()
|
||||
|
||||
self.assertEqual(len(allocations), 1)
|
||||
|
||||
self.build.autoAllocate()
|
||||
self.assertEqual(BuildItem.objects.count(), 1)
|
||||
|
||||
# Check that one un-tracked part has been fully allocated to the build
|
||||
self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_2, None))
|
||||
|
||||
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1, None))
|
||||
|
||||
def test_cancel(self):
|
||||
"""
|
||||
Test cancellation of the build
|
||||
|
@ -172,7 +172,7 @@ class TestBuildAPI(APITestCase):
|
||||
|
||||
# Filter by 'part' status
|
||||
response = self.client.get(url, {'part': 25}, format='json')
|
||||
self.assertEqual(len(response.data), 2)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
|
||||
# Filter by an invalid part
|
||||
response = self.client.get(url, {'part': 99999}, format='json')
|
||||
@ -252,34 +252,6 @@ class TestBuildViews(TestCase):
|
||||
|
||||
self.assertIn(build.title, content)
|
||||
|
||||
def test_build_item_create(self):
|
||||
""" Test the BuildItem creation view (ajax form) """
|
||||
|
||||
url = reverse('build-item-create')
|
||||
|
||||
# Try without a part specified
|
||||
response = self.client.get(url, {'build': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Try with an invalid build ID
|
||||
response = self.client.get(url, {'build': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Try with a valid part specified
|
||||
response = self.client.get(url, {'build': 1, 'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Try with an invalid part specified
|
||||
response = self.client.get(url, {'build': 1, 'part': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_build_item_edit(self):
|
||||
""" Test the BuildItem edit view (ajax form) """
|
||||
|
||||
# TODO
|
||||
# url = reverse('build-item-edit')
|
||||
pass
|
||||
|
||||
def test_build_output_complete(self):
|
||||
"""
|
||||
Test the build output completion form
|
||||
|
@ -12,7 +12,6 @@ build_detail_urls = [
|
||||
url(r'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'),
|
||||
url(r'^delete-output/', views.BuildOutputDelete.as_view(), name='build-output-delete'),
|
||||
url(r'^complete-output/', views.BuildOutputComplete.as_view(), name='build-output-complete'),
|
||||
url(r'^auto-allocate/', views.BuildAutoAllocate.as_view(), name='build-auto-allocate'),
|
||||
url(r'^unallocate/', views.BuildUnallocate.as_view(), name='build-unallocate'),
|
||||
url(r'^complete/', views.BuildComplete.as_view(), name='build-complete'),
|
||||
|
||||
@ -20,13 +19,6 @@ build_detail_urls = [
|
||||
]
|
||||
|
||||
build_urls = [
|
||||
url(r'item/', include([
|
||||
url(r'^(?P<pk>\d+)/', include([
|
||||
url('^edit/', views.BuildItemEdit.as_view(), name='build-item-edit'),
|
||||
url('^delete/', views.BuildItemDelete.as_view(), name='build-item-delete'),
|
||||
])),
|
||||
url('^new/', views.BuildItemCreate.as_view(), name='build-item-create'),
|
||||
])),
|
||||
|
||||
url(r'^(?P<pk>\d+)/', include(build_detail_urls)),
|
||||
|
||||
|
@ -11,13 +11,13 @@ from django.views.generic import DetailView, ListView
|
||||
from django.forms import HiddenInput
|
||||
|
||||
from part.models import Part
|
||||
from .models import Build, BuildItem
|
||||
from .models import Build
|
||||
from . import forms
|
||||
from stock.models import StockLocation, StockItem
|
||||
|
||||
from InvenTree.views import AjaxUpdateView, AjaxCreateView, AjaxDeleteView
|
||||
from InvenTree.views import AjaxUpdateView, AjaxDeleteView
|
||||
from InvenTree.views import InvenTreeRoleMixin
|
||||
from InvenTree.helpers import str2bool, extract_serial_numbers, normalize, isNull
|
||||
from InvenTree.helpers import str2bool, extract_serial_numbers, isNull
|
||||
from InvenTree.status_codes import BuildStatus, StockStatus
|
||||
|
||||
|
||||
@ -77,67 +77,6 @@ class BuildCancel(AjaxUpdateView):
|
||||
}
|
||||
|
||||
|
||||
class BuildAutoAllocate(AjaxUpdateView):
|
||||
""" View to auto-allocate parts for a build.
|
||||
Follows a simple set of rules to automatically allocate StockItem objects.
|
||||
|
||||
Ref: build.models.Build.getAutoAllocations()
|
||||
"""
|
||||
|
||||
model = Build
|
||||
form_class = forms.AutoAllocateForm
|
||||
context_object_name = 'build'
|
||||
ajax_form_title = _('Allocate Stock')
|
||||
ajax_template_name = 'build/auto_allocate.html'
|
||||
|
||||
def get_initial(self):
|
||||
"""
|
||||
Initial values for the form.
|
||||
"""
|
||||
|
||||
initials = super().get_initial()
|
||||
|
||||
return initials
|
||||
|
||||
def get_context_data(self, *args, **kwargs):
|
||||
"""
|
||||
Get the context data for form rendering.
|
||||
"""
|
||||
|
||||
context = {}
|
||||
|
||||
build = self.get_object()
|
||||
|
||||
context['allocations'] = build.getAutoAllocations()
|
||||
|
||||
context['build'] = build
|
||||
|
||||
return context
|
||||
|
||||
def get_form(self):
|
||||
|
||||
form = super().get_form()
|
||||
|
||||
return form
|
||||
|
||||
def validate(self, build, form, **kwargs):
|
||||
|
||||
pass
|
||||
|
||||
def save(self, build, form, **kwargs):
|
||||
"""
|
||||
Once the form has been validated,
|
||||
perform auto-allocations
|
||||
"""
|
||||
|
||||
build.autoAllocate()
|
||||
|
||||
def get_data(self):
|
||||
return {
|
||||
'success': _('Allocated stock to build output'),
|
||||
}
|
||||
|
||||
|
||||
class BuildOutputCreate(AjaxUpdateView):
|
||||
"""
|
||||
Create a new build output (StockItem) for a given build.
|
||||
@ -626,268 +565,3 @@ class BuildDelete(AjaxDeleteView):
|
||||
model = Build
|
||||
ajax_template_name = 'build/delete_build.html'
|
||||
ajax_form_title = _('Delete Build Order')
|
||||
|
||||
|
||||
class BuildItemDelete(AjaxDeleteView):
|
||||
""" View to 'unallocate' a BuildItem.
|
||||
Really we are deleting the BuildItem object from the database.
|
||||
"""
|
||||
|
||||
model = BuildItem
|
||||
ajax_template_name = 'build/delete_build_item.html'
|
||||
ajax_form_title = _('Unallocate Stock')
|
||||
context_object_name = 'item'
|
||||
|
||||
def get_data(self):
|
||||
return {
|
||||
'danger': _('Removed parts from build allocation')
|
||||
}
|
||||
|
||||
|
||||
class BuildItemCreate(AjaxCreateView):
|
||||
"""
|
||||
View for allocating a StockItem to a build output.
|
||||
"""
|
||||
|
||||
model = BuildItem
|
||||
form_class = forms.EditBuildItemForm
|
||||
ajax_template_name = 'build/create_build_item.html'
|
||||
ajax_form_title = _('Allocate stock to build output')
|
||||
|
||||
# The output StockItem against which the allocation is being made
|
||||
output = None
|
||||
|
||||
# The "part" which is being allocated to the output
|
||||
part = None
|
||||
|
||||
available_stock = None
|
||||
|
||||
def get_context_data(self):
|
||||
"""
|
||||
Provide context data to the template which renders the form.
|
||||
"""
|
||||
|
||||
ctx = super().get_context_data()
|
||||
|
||||
if self.part:
|
||||
ctx['part'] = self.part
|
||||
|
||||
if self.output:
|
||||
ctx['output'] = self.output
|
||||
|
||||
if self.available_stock:
|
||||
ctx['stock'] = self.available_stock
|
||||
else:
|
||||
ctx['no_stock'] = True
|
||||
|
||||
return ctx
|
||||
|
||||
def validate(self, build_item, form, **kwargs):
|
||||
"""
|
||||
Extra validation steps as required
|
||||
"""
|
||||
|
||||
data = form.cleaned_data
|
||||
|
||||
stock_item = data.get('stock_item', None)
|
||||
quantity = data.get('quantity', None)
|
||||
|
||||
if stock_item:
|
||||
# Stock item must actually be in stock!
|
||||
if not stock_item.in_stock:
|
||||
form.add_error('stock_item', _('Item must be currently in stock'))
|
||||
|
||||
# Check that there are enough items available
|
||||
if quantity is not None:
|
||||
available = stock_item.unallocated_quantity()
|
||||
if quantity > available:
|
||||
form.add_error('stock_item', _('Stock item is over-allocated'))
|
||||
form.add_error('quantity', _('Available') + ': ' + str(normalize(available)))
|
||||
else:
|
||||
form.add_error('stock_item', _('Stock item must be selected'))
|
||||
|
||||
def get_form(self):
|
||||
""" Create Form for making / editing new Part object """
|
||||
|
||||
form = super(AjaxCreateView, self).get_form()
|
||||
|
||||
self.build = None
|
||||
self.part = None
|
||||
self.output = None
|
||||
|
||||
# If the Build object is specified, hide the input field.
|
||||
# We do not want the users to be able to move a BuildItem to a different build
|
||||
build_id = form['build'].value()
|
||||
|
||||
if build_id is not None:
|
||||
"""
|
||||
If the build has been provided, hide the widget to change the build selection.
|
||||
Additionally, update the allowable selections for other fields.
|
||||
"""
|
||||
form.fields['build'].widget = HiddenInput()
|
||||
form.fields['install_into'].queryset = StockItem.objects.filter(build=build_id, is_building=True)
|
||||
self.build = Build.objects.get(pk=build_id)
|
||||
else:
|
||||
"""
|
||||
Build has *not* been selected
|
||||
"""
|
||||
pass
|
||||
|
||||
# If the sub_part is supplied, limit to matching stock items
|
||||
part_id = form['part_id'].value()
|
||||
|
||||
if part_id:
|
||||
try:
|
||||
self.part = Part.objects.get(pk=part_id)
|
||||
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
pass
|
||||
|
||||
# If the output stock item is specified, hide the input field
|
||||
output_id = form['install_into'].value()
|
||||
|
||||
if output_id is not None:
|
||||
|
||||
try:
|
||||
self.output = StockItem.objects.get(pk=output_id)
|
||||
form.fields['install_into'].widget = HiddenInput()
|
||||
except (ValueError, StockItem.DoesNotExist):
|
||||
pass
|
||||
|
||||
else:
|
||||
# If the output is not specified, but we know that the part is non-trackable, hide the install_into field
|
||||
if self.part and not self.part.trackable:
|
||||
form.fields['install_into'].widget = HiddenInput()
|
||||
|
||||
if self.build and self.part:
|
||||
available_items = self.build.availableStockItems(self.part, self.output)
|
||||
|
||||
form.fields['stock_item'].queryset = available_items
|
||||
|
||||
self.available_stock = form.fields['stock_item'].queryset.all()
|
||||
|
||||
# If there is only a single stockitem available, select it!
|
||||
if len(self.available_stock) == 1:
|
||||
form.fields['stock_item'].initial = self.available_stock[0].pk
|
||||
|
||||
return form
|
||||
|
||||
def get_initial(self):
|
||||
""" Provide initial data for BomItem. Look for the folllowing in the GET data:
|
||||
|
||||
- build: pk of the Build object
|
||||
- part: pk of the Part object which we are assigning
|
||||
- output: pk of the StockItem object into which the allocated stock will be installed
|
||||
"""
|
||||
|
||||
initials = super(AjaxCreateView, self).get_initial().copy()
|
||||
|
||||
build_id = self.get_param('build')
|
||||
part_id = self.get_param('part')
|
||||
output_id = self.get_param('install_into')
|
||||
|
||||
# Reference to a Part object
|
||||
part = None
|
||||
|
||||
# Reference to a StockItem object
|
||||
item = None
|
||||
|
||||
# Reference to a Build object
|
||||
build = None
|
||||
|
||||
# Reference to a StockItem object
|
||||
output = None
|
||||
|
||||
if part_id:
|
||||
try:
|
||||
part = Part.objects.get(pk=part_id)
|
||||
initials['part_id'] = part.pk
|
||||
except Part.DoesNotExist:
|
||||
pass
|
||||
|
||||
if build_id:
|
||||
try:
|
||||
build = Build.objects.get(pk=build_id)
|
||||
initials['build'] = build
|
||||
except Build.DoesNotExist:
|
||||
pass
|
||||
|
||||
# If the output has been specified
|
||||
if output_id:
|
||||
try:
|
||||
output = StockItem.objects.get(pk=output_id)
|
||||
initials['install_into'] = output
|
||||
except (ValueError, StockItem.DoesNotExist):
|
||||
pass
|
||||
|
||||
# Work out how much stock is required
|
||||
if build and part:
|
||||
required_quantity = build.unallocatedQuantity(part, output)
|
||||
else:
|
||||
required_quantity = None
|
||||
|
||||
quantity = self.request.GET.get('quantity', None)
|
||||
|
||||
if quantity is not None:
|
||||
quantity = float(quantity)
|
||||
elif required_quantity is not None:
|
||||
quantity = required_quantity
|
||||
|
||||
item_id = self.get_param('item')
|
||||
|
||||
# If the request specifies a particular StockItem
|
||||
if item_id:
|
||||
try:
|
||||
item = StockItem.objects.get(pk=item_id)
|
||||
except (ValueError, StockItem.DoesNotExist):
|
||||
pass
|
||||
|
||||
# If a StockItem is not selected, try to auto-select one
|
||||
if item is None and part is not None:
|
||||
items = StockItem.objects.filter(part=part)
|
||||
if items.count() == 1:
|
||||
item = items.first()
|
||||
|
||||
# Finally, if a StockItem is selected, ensure the quantity is not too much
|
||||
if item is not None:
|
||||
if quantity is None:
|
||||
quantity = item.unallocated_quantity()
|
||||
else:
|
||||
quantity = min(quantity, item.unallocated_quantity())
|
||||
|
||||
if quantity is not None:
|
||||
initials['quantity'] = quantity
|
||||
|
||||
return initials
|
||||
|
||||
|
||||
class BuildItemEdit(AjaxUpdateView):
|
||||
""" View to edit a BuildItem object """
|
||||
|
||||
model = BuildItem
|
||||
ajax_template_name = 'build/edit_build_item.html'
|
||||
form_class = forms.EditBuildItemForm
|
||||
ajax_form_title = _('Edit Stock Allocation')
|
||||
|
||||
def get_data(self):
|
||||
return {
|
||||
'info': _('Updated Build Item'),
|
||||
}
|
||||
|
||||
def get_form(self):
|
||||
"""
|
||||
Create form for editing a BuildItem.
|
||||
|
||||
- Limit the StockItem options to items that match the part
|
||||
"""
|
||||
|
||||
form = super(BuildItemEdit, self).get_form()
|
||||
|
||||
# Hide fields which we do not wish the user to edit
|
||||
for field in ['build', 'stock_item']:
|
||||
if form[field].value():
|
||||
form.fields[field].widget = HiddenInput()
|
||||
|
||||
form.fields['install_into'].widget = HiddenInput()
|
||||
|
||||
return form
|
||||
|
@ -1,18 +1,6 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import os
|
||||
import logging
|
||||
|
||||
from PIL import UnidentifiedImageError
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.db.utils import OperationalError, ProgrammingError
|
||||
from django.conf import settings
|
||||
|
||||
from InvenTree.ready import canAppAccessDatabase
|
||||
|
||||
|
||||
logger = logging.getLogger("inventree")
|
||||
|
||||
|
||||
class CompanyConfig(AppConfig):
|
||||
@ -23,29 +11,4 @@ class CompanyConfig(AppConfig):
|
||||
This function is called whenever the Company app is loaded.
|
||||
"""
|
||||
|
||||
if canAppAccessDatabase():
|
||||
self.generate_company_thumbs()
|
||||
|
||||
def generate_company_thumbs(self):
|
||||
|
||||
from .models import Company
|
||||
|
||||
logger.debug("Checking Company image thumbnails")
|
||||
|
||||
try:
|
||||
for company in Company.objects.all():
|
||||
if company.image:
|
||||
url = company.image.thumbnail.name
|
||||
loc = os.path.join(settings.MEDIA_ROOT, url)
|
||||
|
||||
if not os.path.exists(loc):
|
||||
logger.info("InvenTree: Generating thumbnail for Company '{c}'".format(c=company.name))
|
||||
try:
|
||||
company.image.render_variations(replace=False)
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"Image file '{company.image}' missing")
|
||||
except UnidentifiedImageError:
|
||||
logger.warning(f"Image file '{company.image}' is invalid")
|
||||
except (OperationalError, ProgrammingError):
|
||||
# Getting here probably meant the database was in test mode
|
||||
pass
|
||||
pass
|
||||
|
17
InvenTree/company/migrations/0041_alter_company_options.py
Normal file
17
InvenTree/company/migrations/0041_alter_company_options.py
Normal file
@ -0,0 +1,17 @@
|
||||
# Generated by Django 3.2.5 on 2021-10-04 20:41
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('company', '0040_alter_company_currency'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='company',
|
||||
options={'ordering': ['name'], 'verbose_name_plural': 'Companies'},
|
||||
),
|
||||
]
|
@ -94,6 +94,7 @@ class Company(models.Model):
|
||||
constraints = [
|
||||
UniqueConstraint(fields=['name', 'email'], name='unique_name_email_pair')
|
||||
]
|
||||
verbose_name_plural = "Companies"
|
||||
|
||||
name = models.CharField(max_length=100, blank=False,
|
||||
help_text=_('Company name'),
|
||||
|
@ -7,14 +7,12 @@ 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.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.db.models import Q, F
|
||||
|
||||
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 import serializers
|
||||
from rest_framework.serializers import ValidationError
|
||||
|
||||
|
||||
@ -235,6 +233,7 @@ class POReceive(generics.CreateAPIView):
|
||||
|
||||
# Pass the purchase order through to the serializer for validation
|
||||
context['order'] = self.get_order()
|
||||
context['request'] = self.request
|
||||
|
||||
return context
|
||||
|
||||
@ -252,75 +251,38 @@ class POReceive(generics.CreateAPIView):
|
||||
|
||||
return order
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
|
||||
# Which purchase order are we receiving against?
|
||||
self.order = self.get_order()
|
||||
class POLineItemFilter(rest_filters.FilterSet):
|
||||
"""
|
||||
Custom filters for the POLineItemList endpoint
|
||||
"""
|
||||
|
||||
# Validate the serialized data
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
class Meta:
|
||||
model = PurchaseOrderLineItem
|
||||
fields = [
|
||||
'order',
|
||||
'part'
|
||||
]
|
||||
|
||||
# Receive the line items
|
||||
try:
|
||||
self.receive_items(serializer)
|
||||
except DjangoValidationError as exc:
|
||||
# Re-throw a django error as a DRF error
|
||||
raise ValidationError(detail=serializers.as_serializer_error(exc))
|
||||
completed = rest_filters.BooleanFilter(label='completed', method='filter_completed')
|
||||
|
||||
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):
|
||||
def filter_completed(self, queryset, name, value):
|
||||
"""
|
||||
Receive the items
|
||||
Filter by lines which are "completed" (or "not" completed)
|
||||
|
||||
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
|
||||
A line is completed when received >= quantity
|
||||
"""
|
||||
|
||||
data = serializer.validated_data
|
||||
value = str2bool(value)
|
||||
|
||||
location = data['location']
|
||||
q = Q(received__gte=F('quantity'))
|
||||
|
||||
items = data['items']
|
||||
if value:
|
||||
queryset = queryset.filter(q)
|
||||
else:
|
||||
queryset = queryset.exclude(q)
|
||||
|
||||
# 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', ''),
|
||||
)
|
||||
return queryset
|
||||
|
||||
|
||||
class POLineItemList(generics.ListCreateAPIView):
|
||||
@ -332,6 +294,7 @@ class POLineItemList(generics.ListCreateAPIView):
|
||||
|
||||
queryset = PurchaseOrderLineItem.objects.all()
|
||||
serializer_class = POLineItemSerializer
|
||||
filterset_class = POLineItemFilter
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
|
||||
|
@ -8,8 +8,6 @@ from __future__ import unicode_literals
|
||||
from django import forms
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from mptt.fields import TreeNodeChoiceField
|
||||
|
||||
from InvenTree.forms import HelperForm
|
||||
from InvenTree.fields import InvenTreeMoneyField, RoundingDecimalFormField
|
||||
|
||||
@ -19,7 +17,6 @@ from common.forms import MatchItemForm
|
||||
|
||||
import part.models
|
||||
|
||||
from stock.models import StockLocation
|
||||
from .models import PurchaseOrder
|
||||
from .models import SalesOrder, SalesOrderLineItem
|
||||
from .models import SalesOrderAllocation
|
||||
@ -80,22 +77,6 @@ class ShipSalesOrderForm(HelperForm):
|
||||
]
|
||||
|
||||
|
||||
class ReceivePurchaseOrderForm(HelperForm):
|
||||
|
||||
location = TreeNodeChoiceField(
|
||||
queryset=StockLocation.objects.all(),
|
||||
required=False,
|
||||
label=_("Destination"),
|
||||
help_text=_("Set all received parts listed above to this location (if left blank, use \"Destination\" column value in above table)"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrder
|
||||
fields = [
|
||||
"location",
|
||||
]
|
||||
|
||||
|
||||
class AllocateSerialsToSalesOrderForm(forms.Form):
|
||||
"""
|
||||
Form for assigning stock to a sales order,
|
||||
|
@ -7,7 +7,8 @@ from __future__ import unicode_literals
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from django.db import models
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Case, When, Value
|
||||
from django.db.models import BooleanField, ExpressionWrapper, F
|
||||
|
||||
@ -224,6 +225,13 @@ class POLineItemReceiveSerializer(serializers.Serializer):
|
||||
required=True,
|
||||
)
|
||||
|
||||
def validate_quantity(self, quantity):
|
||||
|
||||
if quantity <= 0:
|
||||
raise ValidationError(_("Quantity must be greater than zero"))
|
||||
|
||||
return quantity
|
||||
|
||||
status = serializers.ChoiceField(
|
||||
choices=list(StockStatus.items()),
|
||||
default=StockStatus.OK,
|
||||
@ -235,6 +243,7 @@ class POLineItemReceiveSerializer(serializers.Serializer):
|
||||
help_text=_('Unique identifier field'),
|
||||
default='',
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
)
|
||||
|
||||
def validate_barcode(self, barcode):
|
||||
@ -244,7 +253,7 @@ class POLineItemReceiveSerializer(serializers.Serializer):
|
||||
|
||||
# Ignore empty barcode values
|
||||
if not barcode or barcode.strip() == '':
|
||||
return
|
||||
return None
|
||||
|
||||
if stock.models.StockItem.objects.filter(uid=barcode).exists():
|
||||
raise ValidationError(_('Barcode is already in use'))
|
||||
@ -276,35 +285,81 @@ class POReceiveSerializer(serializers.Serializer):
|
||||
help_text=_('Select destination location for received items'),
|
||||
)
|
||||
|
||||
def is_valid(self, raise_exception=False):
|
||||
def validate(self, data):
|
||||
|
||||
super().is_valid(raise_exception)
|
||||
|
||||
# Custom validation
|
||||
data = self.validated_data
|
||||
super().validate(data)
|
||||
|
||||
items = data.get('items', [])
|
||||
|
||||
location = data.get('location', None)
|
||||
|
||||
if len(items) == 0:
|
||||
self._errors['items'] = _('Line items must be provided')
|
||||
else:
|
||||
# Ensure barcodes are unique
|
||||
unique_barcodes = set()
|
||||
raise ValidationError(_('Line items must be provided'))
|
||||
|
||||
# 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"),
|
||||
})
|
||||
|
||||
# Ensure barcodes are unique
|
||||
unique_barcodes = set()
|
||||
|
||||
for item in items:
|
||||
barcode = item.get('barcode', '')
|
||||
|
||||
if barcode:
|
||||
if barcode in unique_barcodes:
|
||||
raise ValidationError(_('Supplied barcode values must be unique'))
|
||||
else:
|
||||
unique_barcodes.add(barcode)
|
||||
|
||||
return data
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
Perform the actual database transaction to receive purchase order items
|
||||
"""
|
||||
|
||||
data = self.validated_data
|
||||
|
||||
request = self.context['request']
|
||||
order = self.context['order']
|
||||
|
||||
items = data['items']
|
||||
location = data.get('location', None)
|
||||
|
||||
# Now we can actually receive the items into stock
|
||||
with transaction.atomic():
|
||||
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)
|
||||
# Select location
|
||||
loc = item.get('location', None) or item['line_item'].get_destination() or location
|
||||
|
||||
if self._errors and raise_exception:
|
||||
raise ValidationError(self.errors)
|
||||
|
||||
return not bool(self._errors)
|
||||
try:
|
||||
order.receive_line_item(
|
||||
item['line_item'],
|
||||
loc,
|
||||
item['quantity'],
|
||||
request.user,
|
||||
status=item['status'],
|
||||
barcode=item.get('barcode', ''),
|
||||
)
|
||||
except (ValidationError, DjangoValidationError) as exc:
|
||||
# Catch model errors and re-throw as DRF errors
|
||||
raise ValidationError(detail=serializers.as_serializer_error(exc))
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
|
@ -49,7 +49,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
</button>
|
||||
{% elif order.status == PurchaseOrderStatus.PLACED %}
|
||||
<button type='button' class='btn btn-default' id='receive-order' title='{% trans "Receive items" %}'>
|
||||
<span class='fas fa-clipboard-check'></span>
|
||||
<span class='fas fa-sign-in-alt'></span>
|
||||
</button>
|
||||
<button type='button' class='btn btn-default' id='complete-order' title='{% trans "Mark order as complete" %}'>
|
||||
<span class='fas fa-check-circle'></span>
|
||||
@ -188,17 +188,27 @@ $("#edit-order").click(function() {
|
||||
});
|
||||
|
||||
$("#receive-order").click(function() {
|
||||
launchModalForm("{% url 'po-receive' order.id %}", {
|
||||
reload: true,
|
||||
secondary: [
|
||||
{
|
||||
field: 'location',
|
||||
label: '{% trans "New Location" %}',
|
||||
title: '{% trans "Create new stock location" %}',
|
||||
url: "{% url 'stock-location-create' %}",
|
||||
},
|
||||
]
|
||||
|
||||
// Auto select items which have not been fully allocated
|
||||
var items = $("#po-line-table").bootstrapTable('getData');
|
||||
|
||||
var items_to_receive = [];
|
||||
|
||||
items.forEach(function(item) {
|
||||
if (item.received < item.quantity) {
|
||||
items_to_receive.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
receivePurchaseOrderItems(
|
||||
{{ order.id }},
|
||||
items_to_receive,
|
||||
{
|
||||
success: function() {
|
||||
$("#po-line-table").bootstrapTable('refresh');
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$("#complete-order").click(function() {
|
||||
|
@ -18,14 +18,23 @@
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='order-toolbar-buttons' class='btn-group' style='float: right;'>
|
||||
{% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %}
|
||||
{% if roles.purchase_order.change %}
|
||||
{% if order.status == PurchaseOrderStatus.PENDING %}
|
||||
<button type='button' class='btn btn-success' id='new-po-line'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %}
|
||||
</button>
|
||||
<a class='btn btn-primary' href='{% url "po-upload" order.id %}' role='button'>
|
||||
<span class='fas fa-file-upload side-icon'></span> {% trans "Upload File" %}
|
||||
</a>
|
||||
{% elif order.status == PurchaseOrderStatus.PLACED %}
|
||||
<button type='button' class='btn btn-success' id='receive-selected-items' title='{% trans "Receive selected items" %}'>
|
||||
<span class='fas fa-sign-in-alt'></span> {% trans "Receive Items" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class='filter-list' id='filter-list-purchase-order-lines'>
|
||||
<!-- An empty div in which the filter list will be constructed-->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class='table table-striped table-condensed' id='po-line-table' data-toolbar='#order-toolbar-buttons'>
|
||||
@ -207,6 +216,22 @@ $('#new-po-line').click(function() {
|
||||
});
|
||||
});
|
||||
|
||||
{% elif order.status == PurchaseOrderStatus.PLACED %}
|
||||
|
||||
$('#receive-selected-items').click(function() {
|
||||
var items = $("#po-line-table").bootstrapTable('getSelections');
|
||||
|
||||
receivePurchaseOrderItems(
|
||||
{{ order.id }},
|
||||
items,
|
||||
{
|
||||
success: function() {
|
||||
$("#po-line-table").bootstrapTable('refresh');
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
{% endif %}
|
||||
|
||||
loadPurchaseOrderLineItemTable('#po-line-table', {
|
||||
|
@ -1,81 +0,0 @@
|
||||
{% extends "modal_form.html" %}
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
{% load status_codes %}
|
||||
|
||||
{% block form %}
|
||||
|
||||
{% blocktrans with desc=order.description %}Receive outstanding parts for <strong>{{order}}</strong> - <em>{{desc}}</em>{% endblocktrans %}
|
||||
|
||||
<form method='post' action='' class='js-modal-form' enctype='multipart/form-data'>
|
||||
{% csrf_token %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
<label class='control-label'>{% trans "Parts" %}</label>
|
||||
<p class='help-block'>{% trans "Fill out number of parts received, the status and destination" %}</p>
|
||||
|
||||
<table class='table table-striped'>
|
||||
<tr>
|
||||
<th>{% trans "Part" %}</th>
|
||||
<th>{% trans "Order Code" %}</th>
|
||||
<th>{% trans "On Order" %}</th>
|
||||
<th>{% trans "Received" %}</th>
|
||||
<th>{% trans "Receive" %}</th>
|
||||
<th>{% trans "Status" %}</th>
|
||||
<th>{% trans "Destination" %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
{% for line in lines %}
|
||||
<tr id='line_row_{{ line.id }}'>
|
||||
{% if line.part %}
|
||||
<td>
|
||||
{% include "hover_image.html" with image=line.part.part.image hover=False %}
|
||||
{{ line.part.part.full_name }}
|
||||
</td>
|
||||
<td>{{ line.part.SKU }}</td>
|
||||
{% else %}
|
||||
<td colspan='2'>{% trans "Error: Referenced part has been removed" %}</td>
|
||||
{% endif %}
|
||||
<td>{% decimal line.quantity %}</td>
|
||||
<td>{% decimal line.received %}</td>
|
||||
<td>
|
||||
<div class='control-group'>
|
||||
<div class='controls'>
|
||||
<input class='numberinput' type='number' min='0' value='{% decimal line.receive_quantity %}' name='line-{{ line.id }}'/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class='control-group'>
|
||||
<select class='select' name='status-{{ line.id }}'>
|
||||
{% for code in StockStatus.RECEIVING_CODES %}
|
||||
<option value="{{ code }}" {% if code|add:"0" == line.status_code|add:"0" %}selected="selected"{% endif %}>{% stock_status_text code %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class='control-group'>
|
||||
<select class='select' name='destination-{{ line.id }}'>
|
||||
<option value="">----------</option>
|
||||
{% for location in stock_locations %}
|
||||
<option value="{{ location.pk }}" {% if location == line.get_destination %}selected="selected"{% endif %}>{{ location }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button class='btn btn-default btn-remove' onClick="removeOrderRowFromOrderWizard()" id='del_item_{{ line.id }}' title='{% trans "Remove line" %}' type='button'>
|
||||
<span row='line_row_{{ line.id }}' class='fas fa-times-circle icon-red'></span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
{% crispy form %}
|
||||
|
||||
<div id='form-errors'>{{ form_errors }}</div>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
@ -251,7 +251,7 @@ class PurchaseOrderReceiveTest(OrderTest):
|
||||
expected_code=400
|
||||
).data
|
||||
|
||||
self.assertIn('Line items must be provided', str(data['items']))
|
||||
self.assertIn('Line items must be provided', str(data))
|
||||
|
||||
# No new stock items have been created
|
||||
self.assertEqual(self.n, StockItem.objects.count())
|
||||
|
@ -10,7 +10,7 @@ from django.contrib.auth.models import Group
|
||||
|
||||
from InvenTree.status_codes import PurchaseOrderStatus
|
||||
|
||||
from .models import PurchaseOrder, PurchaseOrderLineItem
|
||||
from .models import PurchaseOrder
|
||||
|
||||
import json
|
||||
|
||||
@ -103,86 +103,3 @@ class POTests(OrderViewTestCase):
|
||||
# Test that the order was actually placed
|
||||
order = PurchaseOrder.objects.get(pk=1)
|
||||
self.assertEqual(order.status, PurchaseOrderStatus.PLACED)
|
||||
|
||||
|
||||
class TestPOReceive(OrderViewTestCase):
|
||||
""" Tests for receiving a purchase order """
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.po = PurchaseOrder.objects.get(pk=1)
|
||||
self.po.status = PurchaseOrderStatus.PLACED
|
||||
self.po.save()
|
||||
self.url = reverse('po-receive', args=(1,))
|
||||
|
||||
def post(self, data, validate=None):
|
||||
|
||||
response = self.client.post(self.url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
|
||||
if validate is not None:
|
||||
|
||||
data = json.loads(response.content)
|
||||
|
||||
if validate:
|
||||
self.assertTrue(data['form_valid'])
|
||||
else:
|
||||
self.assertFalse(data['form_valid'])
|
||||
|
||||
return response
|
||||
|
||||
def test_get_dialog(self):
|
||||
|
||||
data = {
|
||||
}
|
||||
|
||||
self.client.get(self.url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
|
||||
def test_receive_lines(self):
|
||||
|
||||
post_data = {
|
||||
}
|
||||
|
||||
self.post(post_data, validate=False)
|
||||
|
||||
# Try with an invalid location
|
||||
post_data['location'] = 12345
|
||||
|
||||
self.post(post_data, validate=False)
|
||||
|
||||
# Try with a valid location
|
||||
post_data['location'] = 1
|
||||
|
||||
# Should fail due to invalid quantity
|
||||
self.post(post_data, validate=False)
|
||||
|
||||
# Try to receive against an invalid line
|
||||
post_data['line-800'] = 100
|
||||
|
||||
# Remove an invalid quantity of items
|
||||
post_data['line-1'] = '7x5q'
|
||||
|
||||
self.post(post_data, validate=False)
|
||||
|
||||
# Receive negative number
|
||||
post_data['line-1'] = -100
|
||||
|
||||
self.post(post_data, validate=False)
|
||||
|
||||
# Receive 75 items
|
||||
post_data['line-1'] = 75
|
||||
|
||||
self.post(post_data, validate=True)
|
||||
|
||||
line = PurchaseOrderLineItem.objects.get(pk=1)
|
||||
|
||||
self.assertEqual(line.received, 75)
|
||||
|
||||
# Receive 30 more items
|
||||
post_data['line-1'] = 30
|
||||
|
||||
self.post(post_data, validate=True)
|
||||
|
||||
line = PurchaseOrderLineItem.objects.get(pk=1)
|
||||
|
||||
self.assertEqual(line.received, 105)
|
||||
|
@ -13,7 +13,6 @@ purchase_order_detail_urls = [
|
||||
|
||||
url(r'^cancel/', views.PurchaseOrderCancel.as_view(), name='po-cancel'),
|
||||
url(r'^issue/', views.PurchaseOrderIssue.as_view(), name='po-issue'),
|
||||
url(r'^receive/', views.PurchaseOrderReceive.as_view(), name='po-receive'),
|
||||
url(r'^complete/', views.PurchaseOrderComplete.as_view(), name='po-complete'),
|
||||
|
||||
url(r'^upload/', views.PurchaseOrderUpload.as_view(), name='po-upload'),
|
||||
|
@ -26,7 +26,7 @@ from .models import SalesOrderAllocation
|
||||
from .admin import POLineItemResource
|
||||
from build.models import Build
|
||||
from company.models import Company, SupplierPart # ManufacturerPart
|
||||
from stock.models import StockItem, StockLocation
|
||||
from stock.models import StockItem
|
||||
from part.models import Part
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
@ -42,7 +42,7 @@ from InvenTree.helpers import DownloadFile, str2bool
|
||||
from InvenTree.helpers import extract_serial_numbers
|
||||
from InvenTree.views import InvenTreeRoleMixin
|
||||
|
||||
from InvenTree.status_codes import PurchaseOrderStatus, StockStatus
|
||||
from InvenTree.status_codes import PurchaseOrderStatus
|
||||
|
||||
|
||||
logger = logging.getLogger("inventree")
|
||||
@ -468,202 +468,6 @@ class PurchaseOrderExport(AjaxView):
|
||||
return DownloadFile(filedata, filename)
|
||||
|
||||
|
||||
class PurchaseOrderReceive(AjaxUpdateView):
|
||||
""" View for receiving parts which are outstanding against a PurchaseOrder.
|
||||
|
||||
Any parts which are outstanding are listed.
|
||||
If all parts are marked as received, the order is closed out.
|
||||
|
||||
"""
|
||||
|
||||
form_class = order_forms.ReceivePurchaseOrderForm
|
||||
ajax_form_title = _("Receive Parts")
|
||||
ajax_template_name = "order/receive_parts.html"
|
||||
|
||||
# Specify role as we do not specify a Model against this view
|
||||
role_required = 'purchase_order.change'
|
||||
|
||||
# Where the parts will be going (selected in POST request)
|
||||
destination = None
|
||||
|
||||
def get_context_data(self):
|
||||
|
||||
ctx = {
|
||||
'order': self.order,
|
||||
'lines': self.lines,
|
||||
'stock_locations': StockLocation.objects.all(),
|
||||
}
|
||||
|
||||
return ctx
|
||||
|
||||
def get_lines(self):
|
||||
"""
|
||||
Extract particular line items from the request,
|
||||
or default to *all* pending line items if none are provided
|
||||
"""
|
||||
|
||||
lines = None
|
||||
|
||||
if 'line' in self.request.GET:
|
||||
line_id = self.request.GET.get('line')
|
||||
|
||||
try:
|
||||
lines = PurchaseOrderLineItem.objects.filter(pk=line_id)
|
||||
except (PurchaseOrderLineItem.DoesNotExist, ValueError):
|
||||
pass
|
||||
|
||||
# TODO - Option to pass multiple lines?
|
||||
|
||||
# No lines specified - default selection
|
||||
if lines is None:
|
||||
lines = self.order.pending_line_items()
|
||||
|
||||
return lines
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
""" Respond to a GET request. Determines which parts are outstanding,
|
||||
and presents a list of these parts to the user.
|
||||
"""
|
||||
|
||||
self.request = request
|
||||
self.order = get_object_or_404(PurchaseOrder, pk=self.kwargs['pk'])
|
||||
|
||||
self.lines = self.get_lines()
|
||||
|
||||
for line in self.lines:
|
||||
# Pre-fill the remaining quantity
|
||||
line.receive_quantity = line.remaining()
|
||||
|
||||
return self.renderJsonResponse(request, form=self.get_form())
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
""" Respond to a POST request. Data checking and error handling.
|
||||
If the request is valid, new StockItem objects will be made
|
||||
for each received item.
|
||||
"""
|
||||
|
||||
self.request = request
|
||||
self.order = get_object_or_404(PurchaseOrder, pk=self.kwargs['pk'])
|
||||
errors = False
|
||||
|
||||
self.lines = []
|
||||
self.destination = None
|
||||
|
||||
msg = _("Items received")
|
||||
|
||||
# Extract the destination for received parts
|
||||
if 'location' in request.POST:
|
||||
pk = request.POST['location']
|
||||
try:
|
||||
self.destination = StockLocation.objects.get(id=pk)
|
||||
except (StockLocation.DoesNotExist, ValueError):
|
||||
pass
|
||||
|
||||
# Extract information on all submitted line items
|
||||
for item in request.POST:
|
||||
if item.startswith('line-'):
|
||||
pk = item.replace('line-', '')
|
||||
|
||||
try:
|
||||
line = PurchaseOrderLineItem.objects.get(id=pk)
|
||||
except (PurchaseOrderLineItem.DoesNotExist, ValueError):
|
||||
continue
|
||||
|
||||
# Check that the StockStatus was set
|
||||
status_key = 'status-{pk}'.format(pk=pk)
|
||||
status = request.POST.get(status_key, StockStatus.OK)
|
||||
|
||||
try:
|
||||
status = int(status)
|
||||
except ValueError:
|
||||
status = StockStatus.OK
|
||||
|
||||
if status in StockStatus.RECEIVING_CODES:
|
||||
line.status_code = status
|
||||
else:
|
||||
line.status_code = StockStatus.OK
|
||||
|
||||
# Check the destination field
|
||||
line.destination = None
|
||||
if self.destination:
|
||||
# If global destination is set, overwrite line value
|
||||
line.destination = self.destination
|
||||
else:
|
||||
destination_key = f'destination-{pk}'
|
||||
destination = request.POST.get(destination_key, None)
|
||||
|
||||
if destination:
|
||||
try:
|
||||
line.destination = StockLocation.objects.get(pk=destination)
|
||||
except (StockLocation.DoesNotExist, ValueError):
|
||||
pass
|
||||
|
||||
# Check that line matches the order
|
||||
if not line.order == self.order:
|
||||
# TODO - Display a non-field error?
|
||||
continue
|
||||
|
||||
# Ignore a part that doesn't map to a SupplierPart
|
||||
try:
|
||||
if line.part is None:
|
||||
continue
|
||||
except SupplierPart.DoesNotExist:
|
||||
continue
|
||||
|
||||
receive = self.request.POST[item]
|
||||
|
||||
try:
|
||||
receive = Decimal(receive)
|
||||
except InvalidOperation:
|
||||
# In the case on an invalid input, reset to default
|
||||
receive = line.remaining()
|
||||
msg = _("Error converting quantity to number")
|
||||
errors = True
|
||||
|
||||
if receive < 0:
|
||||
receive = 0
|
||||
errors = True
|
||||
msg = _("Receive quantity less than zero")
|
||||
|
||||
line.receive_quantity = receive
|
||||
self.lines.append(line)
|
||||
|
||||
if len(self.lines) == 0:
|
||||
msg = _("No lines specified")
|
||||
errors = True
|
||||
|
||||
# No errors? Receive the submitted parts!
|
||||
if errors is False:
|
||||
self.receive_parts()
|
||||
|
||||
data = {
|
||||
'form_valid': errors is False,
|
||||
'success': msg,
|
||||
}
|
||||
|
||||
return self.renderJsonResponse(request, data=data, form=self.get_form())
|
||||
|
||||
@transaction.atomic
|
||||
def receive_parts(self):
|
||||
""" Called once the form has been validated.
|
||||
Create new stockitems against received parts.
|
||||
"""
|
||||
|
||||
for line in self.lines:
|
||||
|
||||
if not line.part:
|
||||
continue
|
||||
|
||||
self.order.receive_line_item(
|
||||
line,
|
||||
line.destination,
|
||||
line.receive_quantity,
|
||||
self.request.user,
|
||||
status=line.status_code,
|
||||
purchase_price=line.purchase_price,
|
||||
)
|
||||
|
||||
|
||||
class OrderParts(AjaxView):
|
||||
""" View for adding various SupplierPart items to a Purchase Order.
|
||||
|
||||
|
@ -1,13 +1,9 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import os
|
||||
import logging
|
||||
|
||||
from django.db.utils import OperationalError, ProgrammingError
|
||||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
|
||||
from PIL import UnidentifiedImageError
|
||||
|
||||
from InvenTree.ready import canAppAccessDatabase
|
||||
|
||||
@ -24,40 +20,8 @@ class PartConfig(AppConfig):
|
||||
"""
|
||||
|
||||
if canAppAccessDatabase():
|
||||
self.generate_part_thumbnails()
|
||||
self.update_trackable_status()
|
||||
|
||||
def generate_part_thumbnails(self):
|
||||
"""
|
||||
Generate thumbnail images for any Part that does not have one.
|
||||
This function exists mainly for legacy support,
|
||||
as any *new* image uploaded will have a thumbnail generated automatically.
|
||||
"""
|
||||
|
||||
from .models import Part
|
||||
|
||||
logger.debug("InvenTree: Checking Part image thumbnails")
|
||||
|
||||
try:
|
||||
# Only check parts which have images
|
||||
for part in Part.objects.exclude(image=None):
|
||||
if part.image:
|
||||
url = part.image.thumbnail.name
|
||||
loc = os.path.join(settings.MEDIA_ROOT, url)
|
||||
|
||||
if not os.path.exists(loc):
|
||||
logger.info("InvenTree: Generating thumbnail for Part '{p}'".format(p=part.name))
|
||||
try:
|
||||
part.image.render_variations(replace=False)
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"Image file '{part.image}' missing")
|
||||
pass
|
||||
except UnidentifiedImageError:
|
||||
logger.warning(f"Image file '{part.image}' is invalid")
|
||||
except (OperationalError, ProgrammingError):
|
||||
# Exception if the database has not been migrated yet
|
||||
pass
|
||||
|
||||
def update_trackable_status(self):
|
||||
"""
|
||||
Check for any instances where a trackable part is used in the BOM
|
||||
@ -72,7 +36,7 @@ class PartConfig(AppConfig):
|
||||
items = BomItem.objects.filter(part__trackable=False, sub_part__trackable=True)
|
||||
|
||||
for item in items:
|
||||
print(f"Marking part '{item.part.name}' as trackable")
|
||||
logger.info(f"Marking part '{item.part.name}' as trackable")
|
||||
item.part.trackable = True
|
||||
item.part.clean()
|
||||
item.part.save()
|
||||
|
@ -30,4 +30,11 @@
|
||||
fields:
|
||||
part: 100
|
||||
sub_part: 50
|
||||
quantity: 3
|
||||
quantity: 3
|
||||
|
||||
- model: part.bomitem
|
||||
pk: 5
|
||||
fields:
|
||||
part: 1
|
||||
sub_part: 5
|
||||
quantity: 3
|
||||
|
@ -4,6 +4,7 @@ Part database model definitions
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
import decimal
|
||||
|
||||
import os
|
||||
import logging
|
||||
@ -1530,10 +1531,13 @@ class Part(MPTTModel):
|
||||
for item in self.get_bom_items().all().select_related('sub_part'):
|
||||
|
||||
if item.sub_part.pk == self.pk:
|
||||
print("Warning: Item contains itself in BOM")
|
||||
logger.warning(f"WARNING: BomItem ID {item.pk} contains itself in BOM")
|
||||
continue
|
||||
|
||||
prices = item.sub_part.get_price_range(quantity * item.quantity, internal=internal, purchase=purchase)
|
||||
q = decimal.Decimal(quantity)
|
||||
i = decimal.Decimal(item.quantity)
|
||||
|
||||
prices = item.sub_part.get_price_range(q * i, internal=internal, purchase=purchase)
|
||||
|
||||
if prices is None:
|
||||
continue
|
||||
@ -2329,6 +2333,23 @@ class BomItem(models.Model):
|
||||
def get_api_url():
|
||||
return reverse('api-bom-list')
|
||||
|
||||
def get_stock_filter(self):
|
||||
"""
|
||||
Return a queryset filter for selecting StockItems which match this BomItem
|
||||
|
||||
- If allow_variants is True, allow all part variants
|
||||
|
||||
"""
|
||||
|
||||
# Target part
|
||||
part = self.sub_part
|
||||
|
||||
if self.allow_variants:
|
||||
variants = part.get_descendants(include_self=True)
|
||||
return Q(part__in=[v.pk for v in variants])
|
||||
else:
|
||||
return Q(part=part)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
self.clean()
|
||||
|
@ -193,6 +193,7 @@ class PartBriefSerializer(InvenTreeModelSerializer):
|
||||
fields = [
|
||||
'pk',
|
||||
'IPN',
|
||||
'default_location',
|
||||
'name',
|
||||
'revision',
|
||||
'full_name',
|
||||
|
@ -372,7 +372,7 @@
|
||||
{
|
||||
success: function(items) {
|
||||
adjustStock(action, items, {
|
||||
onSuccess: function() {
|
||||
success: function() {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
|
@ -277,7 +277,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
""" There should be 4 BomItem objects in the database """
|
||||
url = reverse('api-bom-list')
|
||||
response = self.client.get(url, format='json')
|
||||
self.assertEqual(len(response.data), 4)
|
||||
self.assertEqual(len(response.data), 5)
|
||||
|
||||
def test_get_bom_detail(self):
|
||||
# Get the detail for a single BomItem
|
||||
|
@ -120,7 +120,13 @@ class BomItemTest(TestCase):
|
||||
|
||||
def test_pricing(self):
|
||||
self.bob.get_price(1)
|
||||
self.assertEqual(self.bob.get_bom_price_range(1, internal=True), (Decimal(84.5), Decimal(89.5)))
|
||||
self.assertEqual(
|
||||
self.bob.get_bom_price_range(1, internal=True),
|
||||
(Decimal(29.5), Decimal(89.5))
|
||||
)
|
||||
# remove internal price for R_2K2_0805
|
||||
self.r1.internal_price_breaks.delete()
|
||||
self.assertEqual(self.bob.get_bom_price_range(1, internal=True), (Decimal(82.5), Decimal(87.5)))
|
||||
self.assertEqual(
|
||||
self.bob.get_bom_price_range(1, internal=True),
|
||||
(Decimal(27.5), Decimal(87.5))
|
||||
)
|
||||
|
@ -2,15 +2,20 @@
|
||||
JSON API for the Stock app
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from django.conf.urls import url, include
|
||||
from django.urls import reverse
|
||||
from django.http import JsonResponse
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.serializers import ValidationError
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import generics, filters, permissions
|
||||
|
||||
@ -22,7 +27,7 @@ from .models import StockItemTracking
|
||||
from .models import StockItemAttachment
|
||||
from .models import StockItemTestResult
|
||||
|
||||
from part.models import Part, PartCategory
|
||||
from part.models import BomItem, Part, PartCategory
|
||||
from part.serializers import PartBriefSerializer
|
||||
|
||||
from company.models import Company, SupplierPart
|
||||
@ -34,21 +39,13 @@ from order.serializers import POSerializer
|
||||
import common.settings
|
||||
import common.models
|
||||
|
||||
from .serializers import StockItemSerializer
|
||||
from .serializers import LocationSerializer, LocationBriefSerializer
|
||||
from .serializers import StockTrackingSerializer
|
||||
from .serializers import StockItemAttachmentSerializer
|
||||
from .serializers import StockItemTestResultSerializer
|
||||
import stock.serializers as StockSerializers
|
||||
|
||||
from InvenTree.views import TreeSerializer
|
||||
from InvenTree.helpers import str2bool, isNull
|
||||
from InvenTree.api import AttachmentMixin
|
||||
from InvenTree.filters import InvenTreeOrderingFilter
|
||||
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
class StockCategoryTree(TreeSerializer):
|
||||
title = _('Stock')
|
||||
@ -80,12 +77,12 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""
|
||||
|
||||
queryset = StockItem.objects.all()
|
||||
serializer_class = StockItemSerializer
|
||||
serializer_class = StockSerializers.StockItemSerializer
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
queryset = StockItemSerializer.annotate_queryset(queryset)
|
||||
queryset = StockSerializers.StockItemSerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
@ -121,7 +118,7 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
instance.mark_for_deletion()
|
||||
|
||||
|
||||
class StockAdjust(APIView):
|
||||
class StockAdjustView(generics.CreateAPIView):
|
||||
"""
|
||||
A generic class for handling stocktake actions.
|
||||
|
||||
@ -135,184 +132,57 @@ class StockAdjust(APIView):
|
||||
|
||||
queryset = StockItem.objects.none()
|
||||
|
||||
allow_missing_quantity = False
|
||||
def get_serializer_context(self):
|
||||
|
||||
context = super().get_serializer_context()
|
||||
|
||||
def get_items(self, request):
|
||||
"""
|
||||
Return a list of items posted to the endpoint.
|
||||
Will raise validation errors if the items are not
|
||||
correctly formatted.
|
||||
"""
|
||||
context['request'] = self.request
|
||||
|
||||
_items = []
|
||||
|
||||
if 'item' in request.data:
|
||||
_items = [request.data['item']]
|
||||
elif 'items' in request.data:
|
||||
_items = request.data['items']
|
||||
else:
|
||||
_items = []
|
||||
|
||||
if len(_items) == 0:
|
||||
raise ValidationError(_('Request must contain list of stock items'))
|
||||
|
||||
# List of validated items
|
||||
self.items = []
|
||||
|
||||
for entry in _items:
|
||||
|
||||
if not type(entry) == dict:
|
||||
raise ValidationError(_('Improperly formatted data'))
|
||||
|
||||
# Look for a 'pk' value (use 'id' as a backup)
|
||||
pk = entry.get('pk', entry.get('id', None))
|
||||
|
||||
try:
|
||||
pk = int(pk)
|
||||
except (ValueError, TypeError):
|
||||
raise ValidationError(_('Each entry must contain a valid integer primary-key'))
|
||||
|
||||
try:
|
||||
item = StockItem.objects.get(pk=pk)
|
||||
except (StockItem.DoesNotExist):
|
||||
raise ValidationError({
|
||||
pk: [_('Primary key does not match valid stock item')]
|
||||
})
|
||||
|
||||
if self.allow_missing_quantity and 'quantity' not in entry:
|
||||
entry['quantity'] = item.quantity
|
||||
|
||||
try:
|
||||
quantity = Decimal(str(entry.get('quantity', None)))
|
||||
except (ValueError, TypeError, InvalidOperation):
|
||||
raise ValidationError({
|
||||
pk: [_('Invalid quantity value')]
|
||||
})
|
||||
|
||||
if quantity < 0:
|
||||
raise ValidationError({
|
||||
pk: [_('Quantity must not be less than zero')]
|
||||
})
|
||||
|
||||
self.items.append({
|
||||
'item': item,
|
||||
'quantity': quantity
|
||||
})
|
||||
|
||||
# Extract 'notes' field
|
||||
self.notes = str(request.data.get('notes', ''))
|
||||
return context
|
||||
|
||||
|
||||
class StockCount(StockAdjust):
|
||||
class StockCount(StockAdjustView):
|
||||
"""
|
||||
Endpoint for counting stock (performing a stocktake).
|
||||
"""
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
self.get_items(request)
|
||||
|
||||
n = 0
|
||||
|
||||
for item in self.items:
|
||||
|
||||
if item['item'].stocktake(item['quantity'], request.user, notes=self.notes):
|
||||
n += 1
|
||||
|
||||
return Response({'success': _('Updated stock for {n} items').format(n=n)})
|
||||
serializer_class = StockSerializers.StockCountSerializer
|
||||
|
||||
|
||||
class StockAdd(StockAdjust):
|
||||
class StockAdd(StockAdjustView):
|
||||
"""
|
||||
Endpoint for adding a quantity of stock to an existing StockItem
|
||||
"""
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
self.get_items(request)
|
||||
|
||||
n = 0
|
||||
|
||||
for item in self.items:
|
||||
if item['item'].add_stock(item['quantity'], request.user, notes=self.notes):
|
||||
n += 1
|
||||
|
||||
return Response({"success": "Added stock for {n} items".format(n=n)})
|
||||
serializer_class = StockSerializers.StockAddSerializer
|
||||
|
||||
|
||||
class StockRemove(StockAdjust):
|
||||
class StockRemove(StockAdjustView):
|
||||
"""
|
||||
Endpoint for removing a quantity of stock from an existing StockItem.
|
||||
"""
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
self.get_items(request)
|
||||
|
||||
n = 0
|
||||
|
||||
for item in self.items:
|
||||
|
||||
if item['quantity'] > item['item'].quantity:
|
||||
raise ValidationError({
|
||||
item['item'].pk: [_('Specified quantity exceeds stock quantity')]
|
||||
})
|
||||
|
||||
if item['item'].take_stock(item['quantity'], request.user, notes=self.notes):
|
||||
n += 1
|
||||
|
||||
return Response({"success": "Removed stock for {n} items".format(n=n)})
|
||||
serializer_class = StockSerializers.StockRemoveSerializer
|
||||
|
||||
|
||||
class StockTransfer(StockAdjust):
|
||||
class StockTransfer(StockAdjustView):
|
||||
"""
|
||||
API endpoint for performing stock movements
|
||||
"""
|
||||
|
||||
allow_missing_quantity = True
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
data = request.data
|
||||
|
||||
try:
|
||||
location = StockLocation.objects.get(pk=data.get('location', None))
|
||||
except (ValueError, StockLocation.DoesNotExist):
|
||||
raise ValidationError({'location': [_('Valid location must be specified')]})
|
||||
|
||||
n = 0
|
||||
|
||||
self.get_items(request)
|
||||
|
||||
for item in self.items:
|
||||
|
||||
if item['quantity'] > item['item'].quantity:
|
||||
raise ValidationError({
|
||||
item['item'].pk: [_('Specified quantity exceeds stock quantity')]
|
||||
})
|
||||
|
||||
# If quantity is not specified, move the entire stock
|
||||
if item['quantity'] in [0, None]:
|
||||
item['quantity'] = item['item'].quantity
|
||||
|
||||
if item['item'].move(location, self.notes, request.user, quantity=item['quantity']):
|
||||
n += 1
|
||||
|
||||
return Response({'success': _('Moved {n} parts to {loc}').format(
|
||||
n=n,
|
||||
loc=str(location),
|
||||
)})
|
||||
serializer_class = StockSerializers.StockTransferSerializer
|
||||
|
||||
|
||||
class StockLocationList(generics.ListCreateAPIView):
|
||||
""" API endpoint for list view of StockLocation objects:
|
||||
"""
|
||||
API endpoint for list view of StockLocation objects:
|
||||
|
||||
- GET: Return list of StockLocation objects
|
||||
- POST: Create a new StockLocation
|
||||
"""
|
||||
|
||||
queryset = StockLocation.objects.all()
|
||||
serializer_class = LocationSerializer
|
||||
serializer_class = StockSerializers.LocationSerializer
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""
|
||||
@ -514,7 +384,7 @@ class StockList(generics.ListCreateAPIView):
|
||||
- POST: Create a new StockItem
|
||||
"""
|
||||
|
||||
serializer_class = StockItemSerializer
|
||||
serializer_class = StockSerializers.StockItemSerializer
|
||||
queryset = StockItem.objects.all()
|
||||
filterset_class = StockFilter
|
||||
|
||||
@ -636,7 +506,7 @@ class StockList(generics.ListCreateAPIView):
|
||||
|
||||
# Serialize each StockLocation object
|
||||
for location in locations:
|
||||
location_map[location.pk] = LocationBriefSerializer(location).data
|
||||
location_map[location.pk] = StockSerializers.LocationBriefSerializer(location).data
|
||||
|
||||
# Now update each StockItem with the related StockLocation data
|
||||
for stock_item in data:
|
||||
@ -662,7 +532,7 @@ class StockList(generics.ListCreateAPIView):
|
||||
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
queryset = StockItemSerializer.annotate_queryset(queryset)
|
||||
queryset = StockSerializers.StockItemSerializer.annotate_queryset(queryset)
|
||||
|
||||
# Do not expose StockItem objects which are scheduled for deletion
|
||||
queryset = queryset.filter(scheduled_for_deletion=False)
|
||||
@ -670,14 +540,14 @@ class StockList(generics.ListCreateAPIView):
|
||||
return queryset
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""
|
||||
Custom filtering for the StockItem queryset
|
||||
"""
|
||||
|
||||
params = self.request.query_params
|
||||
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
# Perform basic filtering:
|
||||
# Note: We do not let DRF filter here, it be slow AF
|
||||
|
||||
supplier_part = params.get('supplier_part', None)
|
||||
|
||||
if supplier_part:
|
||||
@ -818,7 +688,7 @@ class StockList(generics.ListCreateAPIView):
|
||||
if loc_id is not None:
|
||||
|
||||
# Filter by 'null' location (i.e. top-level items)
|
||||
if isNull(loc_id):
|
||||
if isNull(loc_id) and not cascade:
|
||||
queryset = queryset.filter(location=None)
|
||||
else:
|
||||
try:
|
||||
@ -843,6 +713,18 @@ class StockList(generics.ListCreateAPIView):
|
||||
except (ValueError, PartCategory.DoesNotExist):
|
||||
raise ValidationError({"category": "Invalid category id specified"})
|
||||
|
||||
# Does the client wish to filter by BomItem
|
||||
bom_item_id = params.get('bom_item', None)
|
||||
|
||||
if bom_item_id is not None:
|
||||
try:
|
||||
bom_item = BomItem.objects.get(pk=bom_item_id)
|
||||
|
||||
queryset = queryset.filter(bom_item.get_stock_filter())
|
||||
|
||||
except (ValueError, BomItem.DoesNotExist):
|
||||
pass
|
||||
|
||||
# Filter by StockItem status
|
||||
status = params.get('status', None)
|
||||
|
||||
@ -939,7 +821,7 @@ class StockAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
||||
"""
|
||||
|
||||
queryset = StockItemAttachment.objects.all()
|
||||
serializer_class = StockItemAttachmentSerializer
|
||||
serializer_class = StockSerializers.StockItemAttachmentSerializer
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
@ -958,7 +840,7 @@ class StockAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMix
|
||||
"""
|
||||
|
||||
queryset = StockItemAttachment.objects.all()
|
||||
serializer_class = StockItemAttachmentSerializer
|
||||
serializer_class = StockSerializers.StockItemAttachmentSerializer
|
||||
|
||||
|
||||
class StockItemTestResultDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
@ -967,7 +849,7 @@ class StockItemTestResultDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""
|
||||
|
||||
queryset = StockItemTestResult.objects.all()
|
||||
serializer_class = StockItemTestResultSerializer
|
||||
serializer_class = StockSerializers.StockItemTestResultSerializer
|
||||
|
||||
|
||||
class StockItemTestResultList(generics.ListCreateAPIView):
|
||||
@ -976,7 +858,7 @@ class StockItemTestResultList(generics.ListCreateAPIView):
|
||||
"""
|
||||
|
||||
queryset = StockItemTestResult.objects.all()
|
||||
serializer_class = StockItemTestResultSerializer
|
||||
serializer_class = StockSerializers.StockItemTestResultSerializer
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
@ -1024,7 +906,7 @@ class StockTrackingDetail(generics.RetrieveAPIView):
|
||||
"""
|
||||
|
||||
queryset = StockItemTracking.objects.all()
|
||||
serializer_class = StockTrackingSerializer
|
||||
serializer_class = StockSerializers.StockTrackingSerializer
|
||||
|
||||
|
||||
class StockTrackingList(generics.ListAPIView):
|
||||
@ -1037,7 +919,7 @@ class StockTrackingList(generics.ListAPIView):
|
||||
"""
|
||||
|
||||
queryset = StockItemTracking.objects.all()
|
||||
serializer_class = StockTrackingSerializer
|
||||
serializer_class = StockSerializers.StockTrackingSerializer
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
try:
|
||||
@ -1073,7 +955,7 @@ class StockTrackingList(generics.ListAPIView):
|
||||
if 'location' in deltas:
|
||||
try:
|
||||
location = StockLocation.objects.get(pk=deltas['location'])
|
||||
serializer = LocationSerializer(location)
|
||||
serializer = StockSerializers.LocationSerializer(location)
|
||||
deltas['location_detail'] = serializer.data
|
||||
except:
|
||||
pass
|
||||
@ -1082,7 +964,7 @@ class StockTrackingList(generics.ListAPIView):
|
||||
if 'stockitem' in deltas:
|
||||
try:
|
||||
stockitem = StockItem.objects.get(pk=deltas['stockitem'])
|
||||
serializer = StockItemSerializer(stockitem)
|
||||
serializer = StockSerializers.StockItemSerializer(stockitem)
|
||||
deltas['stockitem_detail'] = serializer.data
|
||||
except:
|
||||
pass
|
||||
@ -1164,7 +1046,7 @@ class LocationDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""
|
||||
|
||||
queryset = StockLocation.objects.all()
|
||||
serializer_class = LocationSerializer
|
||||
serializer_class = StockSerializers.LocationSerializer
|
||||
|
||||
|
||||
stock_api_urls = [
|
||||
|
@ -2,27 +2,29 @@
|
||||
JSON serializers for Stock app
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from decimal import Decimal
|
||||
from datetime import datetime, timedelta
|
||||
from django.db import transaction
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db.models import Case, When, Value
|
||||
from django.db.models import BooleanField
|
||||
from django.db.models import Q
|
||||
|
||||
from rest_framework import serializers
|
||||
from rest_framework.serializers import ValidationError
|
||||
|
||||
from sql_util.utils import SubquerySum, SubqueryCount
|
||||
|
||||
from .models import StockItem, StockLocation
|
||||
from .models import StockItemTracking
|
||||
from .models import StockItemAttachment
|
||||
from .models import StockItemTestResult
|
||||
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
from django.db.models import Case, When, Value
|
||||
from django.db.models import BooleanField
|
||||
from django.db.models import Q
|
||||
|
||||
from sql_util.utils import SubquerySum, SubqueryCount
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import common.models
|
||||
from common.settings import currency_code_default, currency_code_mappings
|
||||
|
||||
@ -64,6 +66,7 @@ class StockItemSerializerBrief(InvenTreeModelSerializer):
|
||||
'location',
|
||||
'location_name',
|
||||
'quantity',
|
||||
'serial',
|
||||
]
|
||||
|
||||
|
||||
@ -395,3 +398,196 @@ class StockTrackingSerializer(InvenTreeModelSerializer):
|
||||
'label',
|
||||
'tracking_type',
|
||||
]
|
||||
|
||||
|
||||
class StockAdjustmentItemSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for a single StockItem within a stock adjument request.
|
||||
|
||||
Fields:
|
||||
- item: StockItem object
|
||||
- quantity: Numerical quantity
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
'item',
|
||||
'quantity'
|
||||
]
|
||||
|
||||
pk = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockItem.objects.all(),
|
||||
many=False,
|
||||
allow_null=False,
|
||||
required=True,
|
||||
label='stock_item',
|
||||
help_text=_('StockItem primary key value')
|
||||
)
|
||||
|
||||
quantity = serializers.DecimalField(
|
||||
max_digits=15,
|
||||
decimal_places=5,
|
||||
min_value=0,
|
||||
required=True
|
||||
)
|
||||
|
||||
|
||||
class StockAdjustmentSerializer(serializers.Serializer):
|
||||
"""
|
||||
Base class for managing stock adjustment actions via the API
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
'items',
|
||||
'notes',
|
||||
]
|
||||
|
||||
items = StockAdjustmentItemSerializer(many=True)
|
||||
|
||||
notes = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
label=_("Notes"),
|
||||
help_text=_("Stock transaction notes"),
|
||||
)
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
super().validate(data)
|
||||
|
||||
items = data.get('items', [])
|
||||
|
||||
if len(items) == 0:
|
||||
raise ValidationError(_("A list of stock items must be provided"))
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class StockCountSerializer(StockAdjustmentSerializer):
|
||||
"""
|
||||
Serializer for counting stock items
|
||||
"""
|
||||
|
||||
def save(self):
|
||||
|
||||
request = self.context['request']
|
||||
|
||||
data = self.validated_data
|
||||
items = data['items']
|
||||
notes = data.get('notes', '')
|
||||
|
||||
with transaction.atomic():
|
||||
for item in items:
|
||||
|
||||
stock_item = item['pk']
|
||||
quantity = item['quantity']
|
||||
|
||||
stock_item.stocktake(
|
||||
quantity,
|
||||
request.user,
|
||||
notes=notes
|
||||
)
|
||||
|
||||
|
||||
class StockAddSerializer(StockAdjustmentSerializer):
|
||||
"""
|
||||
Serializer for adding stock to stock item(s)
|
||||
"""
|
||||
|
||||
def save(self):
|
||||
|
||||
request = self.context['request']
|
||||
|
||||
data = self.validated_data
|
||||
notes = data.get('notes', '')
|
||||
|
||||
with transaction.atomic():
|
||||
for item in data['items']:
|
||||
|
||||
stock_item = item['pk']
|
||||
quantity = item['quantity']
|
||||
|
||||
stock_item.add_stock(
|
||||
quantity,
|
||||
request.user,
|
||||
notes=notes
|
||||
)
|
||||
|
||||
|
||||
class StockRemoveSerializer(StockAdjustmentSerializer):
|
||||
"""
|
||||
Serializer for removing stock from stock item(s)
|
||||
"""
|
||||
|
||||
def save(self):
|
||||
|
||||
request = self.context['request']
|
||||
|
||||
data = self.validated_data
|
||||
notes = data.get('notes', '')
|
||||
|
||||
with transaction.atomic():
|
||||
for item in data['items']:
|
||||
|
||||
stock_item = item['pk']
|
||||
quantity = item['quantity']
|
||||
|
||||
stock_item.take_stock(
|
||||
quantity,
|
||||
request.user,
|
||||
notes=notes
|
||||
)
|
||||
|
||||
|
||||
class StockTransferSerializer(StockAdjustmentSerializer):
|
||||
"""
|
||||
Serializer for transferring (moving) stock item(s)
|
||||
"""
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockLocation.objects.all(),
|
||||
many=False,
|
||||
required=True,
|
||||
allow_null=False,
|
||||
label=_('Location'),
|
||||
help_text=_('Destination stock location'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
'items',
|
||||
'notes',
|
||||
'location',
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
super().validate(data)
|
||||
|
||||
# TODO: Any specific validation of location field?
|
||||
|
||||
return data
|
||||
|
||||
def save(self):
|
||||
|
||||
request = self.context['request']
|
||||
|
||||
data = self.validated_data
|
||||
|
||||
items = data['items']
|
||||
notes = data.get('notes', '')
|
||||
location = data['location']
|
||||
|
||||
with transaction.atomic():
|
||||
for item in items:
|
||||
|
||||
stock_item = item['pk']
|
||||
quantity = item['quantity']
|
||||
|
||||
stock_item.move(
|
||||
location,
|
||||
notes,
|
||||
request.user,
|
||||
quantity=quantity
|
||||
)
|
||||
|
@ -561,7 +561,7 @@ function itemAdjust(action) {
|
||||
{
|
||||
success: function(item) {
|
||||
adjustStock(action, [item], {
|
||||
onSuccess: function() {
|
||||
success: function() {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
|
@ -287,7 +287,7 @@
|
||||
{
|
||||
success: function(items) {
|
||||
adjustStock(action, items, {
|
||||
onSuccess: function() {
|
||||
success: function() {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
|
@ -513,31 +513,34 @@ class StocktakeTest(StockAPITestCase):
|
||||
|
||||
# POST with a valid action
|
||||
response = self.post(url, data)
|
||||
self.assertContains(response, "must contain list", status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
self.assertIn("This field is required", str(response.data["items"]))
|
||||
|
||||
data['items'] = [{
|
||||
'no': 'aa'
|
||||
}]
|
||||
|
||||
# POST without a PK
|
||||
response = self.post(url, data)
|
||||
self.assertContains(response, 'must contain a valid integer primary-key', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
response = self.post(url, data, expected_code=400)
|
||||
|
||||
self.assertIn('This field is required', str(response.data))
|
||||
|
||||
# POST with an invalid PK
|
||||
data['items'] = [{
|
||||
'pk': 10
|
||||
}]
|
||||
|
||||
response = self.post(url, data)
|
||||
self.assertContains(response, 'does not match valid stock item', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
response = self.post(url, data, expected_code=400)
|
||||
|
||||
self.assertContains(response, 'object does not exist', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# POST with missing quantity value
|
||||
data['items'] = [{
|
||||
'pk': 1234
|
||||
}]
|
||||
|
||||
response = self.post(url, data)
|
||||
self.assertContains(response, 'Invalid quantity value', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
response = self.post(url, data, expected_code=400)
|
||||
self.assertContains(response, 'This field is required', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# POST with an invalid quantity value
|
||||
data['items'] = [{
|
||||
@ -546,7 +549,7 @@ class StocktakeTest(StockAPITestCase):
|
||||
}]
|
||||
|
||||
response = self.post(url, data)
|
||||
self.assertContains(response, 'Invalid quantity value', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
self.assertContains(response, 'A valid number is required', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
data['items'] = [{
|
||||
'pk': 1234,
|
||||
@ -554,18 +557,7 @@ class StocktakeTest(StockAPITestCase):
|
||||
}]
|
||||
|
||||
response = self.post(url, data)
|
||||
self.assertContains(response, 'must not be less than zero', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Test with a single item
|
||||
data = {
|
||||
'item': {
|
||||
'pk': 1234,
|
||||
'quantity': '10',
|
||||
}
|
||||
}
|
||||
|
||||
response = self.post(url, data)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertContains(response, 'Ensure this value is greater than or equal to 0', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_transfer(self):
|
||||
"""
|
||||
@ -573,24 +565,27 @@ class StocktakeTest(StockAPITestCase):
|
||||
"""
|
||||
|
||||
data = {
|
||||
'item': {
|
||||
'pk': 1234,
|
||||
'quantity': 10,
|
||||
},
|
||||
'items': [
|
||||
{
|
||||
'pk': 1234,
|
||||
'quantity': 10,
|
||||
}
|
||||
],
|
||||
'location': 1,
|
||||
'notes': "Moving to a new location"
|
||||
}
|
||||
|
||||
url = reverse('api-stock-transfer')
|
||||
|
||||
response = self.post(url, data)
|
||||
self.assertContains(response, "Moved 1 parts to", status_code=status.HTTP_200_OK)
|
||||
# This should succeed
|
||||
response = self.post(url, data, expected_code=201)
|
||||
|
||||
# Now try one which will fail due to a bad location
|
||||
data['location'] = 'not a location'
|
||||
|
||||
response = self.post(url, data)
|
||||
self.assertContains(response, 'Valid location must be specified', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
response = self.post(url, data, expected_code=400)
|
||||
|
||||
self.assertContains(response, 'Incorrect type. Expected pk value', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class StockItemDeletionTest(StockAPITestCase):
|
||||
|
@ -7,7 +7,7 @@ from __future__ import unicode_literals
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.views.generic.edit import FormMixin
|
||||
from django.views.generic import DetailView, ListView, UpdateView
|
||||
from django.views.generic import DetailView, ListView
|
||||
from django.forms.models import model_to_dict
|
||||
from django.forms import HiddenInput
|
||||
from django.urls import reverse
|
||||
@ -145,29 +145,6 @@ class StockItemDetail(InvenTreeRoleMixin, DetailView):
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
|
||||
class StockItemNotes(InvenTreeRoleMixin, UpdateView):
|
||||
""" View for editing the 'notes' field of a StockItem object """
|
||||
|
||||
context_object_name = 'item'
|
||||
template_name = 'stock/item_notes.html'
|
||||
model = StockItem
|
||||
|
||||
role_required = 'stock.view'
|
||||
|
||||
fields = ['notes']
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('stock-item-notes', kwargs={'pk': self.get_object().id})
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
|
||||
ctx['editing'] = str2bool(self.request.GET.get('edit', ''))
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
class StockLocationEdit(AjaxUpdateView):
|
||||
"""
|
||||
View for editing details of a StockLocation.
|
||||
|
@ -4,7 +4,6 @@
|
||||
/* globals
|
||||
buildStatusDisplay,
|
||||
constructForm,
|
||||
getFieldByName,
|
||||
global_settings,
|
||||
imageHoverIcon,
|
||||
inventreeGet,
|
||||
@ -20,6 +19,7 @@
|
||||
*/
|
||||
|
||||
/* exported
|
||||
allocateStockToBuild,
|
||||
editBuildOrder,
|
||||
loadAllocationTable,
|
||||
loadBuildOrderAllocationTable,
|
||||
@ -102,6 +102,7 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) {
|
||||
*/
|
||||
|
||||
var buildId = buildInfo.pk;
|
||||
var partId = buildInfo.part;
|
||||
|
||||
var outputId = 'untracked';
|
||||
|
||||
@ -120,11 +121,10 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) {
|
||||
|
||||
var html = `<div class='btn-group float-right' role='group'>`;
|
||||
|
||||
// "Auto" allocation only works for untracked stock items
|
||||
if (!output && lines > 0) {
|
||||
if (lines > 0) {
|
||||
html += makeIconButton(
|
||||
'fa-magic icon-blue', 'button-output-auto', outputId,
|
||||
'{% trans "Auto-allocate stock items to this output" %}',
|
||||
'fa-sign-in-alt icon-blue', 'button-output-auto', outputId,
|
||||
'{% trans "Allocate stock items to this build output" %}',
|
||||
);
|
||||
}
|
||||
|
||||
@ -136,7 +136,6 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (output) {
|
||||
|
||||
// Add a button to "complete" the particular build output
|
||||
@ -163,11 +162,17 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) {
|
||||
|
||||
// Add callbacks for the buttons
|
||||
$(panel).find(`#button-output-auto-${outputId}`).click(function() {
|
||||
|
||||
var bom_items = $(panel).find(`#allocation-table-${outputId}`).bootstrapTable('getData');
|
||||
|
||||
// Launch modal dialog to perform auto-allocation
|
||||
launchModalForm(`/build/${buildId}/auto-allocate/`,
|
||||
allocateStockToBuild(
|
||||
buildId,
|
||||
partId,
|
||||
bom_items,
|
||||
{
|
||||
data: {
|
||||
},
|
||||
source_location: buildInfo.source_location,
|
||||
output: outputId,
|
||||
success: reloadTable,
|
||||
}
|
||||
);
|
||||
@ -344,18 +349,26 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
function requiredQuantity(row) {
|
||||
// Return the requied quantity for a given row
|
||||
|
||||
var quantity = 0;
|
||||
|
||||
if (output) {
|
||||
// "Tracked" parts are calculated against individual build outputs
|
||||
return row.quantity * output.quantity;
|
||||
quantity = row.quantity * output.quantity;
|
||||
} else {
|
||||
// "Untracked" parts are specified against the build itself
|
||||
return row.quantity * buildInfo.quantity;
|
||||
quantity = row.quantity * buildInfo.quantity;
|
||||
}
|
||||
|
||||
// Store the required quantity in the row data
|
||||
row.required = quantity;
|
||||
|
||||
return quantity;
|
||||
}
|
||||
|
||||
function sumAllocations(row) {
|
||||
// Calculat total allocations for a given row
|
||||
if (!row.allocations) {
|
||||
row.allocated = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
@ -365,6 +378,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
quantity += item.quantity;
|
||||
});
|
||||
|
||||
row.allocated = quantity;
|
||||
|
||||
return quantity;
|
||||
}
|
||||
|
||||
@ -377,52 +392,28 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
// Primary key of the 'sub_part'
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
// Launch form to allocate new stock against this output
|
||||
launchModalForm('{% url "build-item-create" %}', {
|
||||
success: reloadTable,
|
||||
data: {
|
||||
part: pk,
|
||||
build: buildId,
|
||||
install_into: outputId,
|
||||
},
|
||||
secondary: [
|
||||
{
|
||||
field: 'stock_item',
|
||||
label: '{% trans "New Stock Item" %}',
|
||||
title: '{% trans "Create new Stock Item" %}',
|
||||
url: '{% url "stock-item-create" %}',
|
||||
data: {
|
||||
part: pk,
|
||||
},
|
||||
},
|
||||
// Extract BomItem information from this row
|
||||
var row = $(table).bootstrapTable('getRowByUniqueId', pk);
|
||||
|
||||
if (!row) {
|
||||
console.log('WARNING: getRowByUniqueId returned null');
|
||||
return;
|
||||
}
|
||||
|
||||
allocateStockToBuild(
|
||||
buildId,
|
||||
partId,
|
||||
[
|
||||
row,
|
||||
],
|
||||
callback: [
|
||||
{
|
||||
field: 'stock_item',
|
||||
action: function(value) {
|
||||
inventreeGet(
|
||||
`/api/stock/${value}/`, {},
|
||||
{
|
||||
success: function(response) {
|
||||
|
||||
// How many items are actually available for the given stock item?
|
||||
var available = response.quantity - response.allocated;
|
||||
|
||||
var field = getFieldByName('#modal-form', 'quantity');
|
||||
|
||||
// Allocation quantity initial value
|
||||
var initial = field.attr('value');
|
||||
|
||||
if (available < initial) {
|
||||
field.val(available);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
{
|
||||
source_location: buildInfo.source_location,
|
||||
success: function(data) {
|
||||
$(table).bootstrapTable('refresh');
|
||||
},
|
||||
output: output == null ? null : output.pk,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Callback for 'buy' button
|
||||
@ -623,17 +614,22 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
|
||||
var url = '';
|
||||
|
||||
if (row.serial && row.quantity == 1) {
|
||||
text = `{% trans "Serial Number" %}: ${row.serial}`;
|
||||
|
||||
var serial = row.serial;
|
||||
|
||||
if (row.stock_item_detail) {
|
||||
serial = row.stock_item_detail.serial;
|
||||
}
|
||||
|
||||
if (serial && row.quantity == 1) {
|
||||
text = `{% trans "Serial Number" %}: ${serial}`;
|
||||
} else {
|
||||
text = `{% trans "Quantity" %}: ${row.quantity}`;
|
||||
}
|
||||
|
||||
{% if build.status == BuildStatus.COMPLETE %}
|
||||
url = `/stock/item/${row.pk}/`;
|
||||
{% else %}
|
||||
url = `/stock/item/${row.stock_item}/`;
|
||||
{% endif %}
|
||||
var pk = row.stock_item || row.pk;
|
||||
|
||||
url = `/stock/item/${pk}/`;
|
||||
|
||||
return renderLink(text, url);
|
||||
}
|
||||
@ -680,22 +676,31 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
// Assign button callbacks to the newly created allocation buttons
|
||||
subTable.find('.button-allocation-edit').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
launchModalForm(`/build/item/${pk}/edit/`, {
|
||||
success: reloadTable,
|
||||
|
||||
constructForm(`/api/build/item/${pk}/`, {
|
||||
fields: {
|
||||
quantity: {},
|
||||
},
|
||||
title: '{% trans "Edit Allocation" %}',
|
||||
onSuccess: reloadTable,
|
||||
});
|
||||
});
|
||||
|
||||
subTable.find('.button-allocation-delete').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
launchModalForm(`/build/item/${pk}/delete/`, {
|
||||
success: reloadTable,
|
||||
|
||||
constructForm(`/api/build/item/${pk}/`, {
|
||||
method: 'DELETE',
|
||||
title: '{% trans "Remove Allocation" %}',
|
||||
onSuccess: reloadTable,
|
||||
});
|
||||
});
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
field: 'pk',
|
||||
visible: false,
|
||||
visible: true,
|
||||
switchable: false,
|
||||
checkbox: true,
|
||||
},
|
||||
{
|
||||
field: 'sub_part_detail.full_name',
|
||||
@ -817,6 +822,316 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Allocate stock items to a build
|
||||
*
|
||||
* arguments:
|
||||
* - buildId: ID / PK value for the build
|
||||
* - partId: ID / PK value for the part being built
|
||||
* - bom_items: A list of BomItem objects to be allocated
|
||||
*
|
||||
* options:
|
||||
* - output: ID / PK of the associated build output (or null for untracked items)
|
||||
* - source_location: ID / PK of the top-level StockLocation to take parts from (or null)
|
||||
*/
|
||||
function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
|
||||
|
||||
// ID of the associated "build output" (or null)
|
||||
var output_id = options.output || null;
|
||||
|
||||
var source_location = options.source_location;
|
||||
|
||||
function renderBomItemRow(bom_item, quantity) {
|
||||
|
||||
var pk = bom_item.pk;
|
||||
var sub_part = bom_item.sub_part_detail;
|
||||
|
||||
var thumb = thumbnailImage(bom_item.sub_part_detail.thumbnail);
|
||||
|
||||
var delete_button = `<div class='btn-group float-right' role='group'>`;
|
||||
|
||||
delete_button += makeIconButton(
|
||||
'fa-times icon-red',
|
||||
'button-row-remove',
|
||||
pk,
|
||||
'{% trans "Remove row" %}',
|
||||
);
|
||||
|
||||
delete_button += `</div>`;
|
||||
|
||||
var quantity_input = constructField(
|
||||
`items_quantity_${pk}`,
|
||||
{
|
||||
type: 'decimal',
|
||||
min_value: 0,
|
||||
value: quantity || 0,
|
||||
title: '{% trans "Specify stock allocation quantity" %}',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
hideLabels: true,
|
||||
}
|
||||
);
|
||||
|
||||
var allocated_display = makeProgressBar(
|
||||
bom_item.allocated,
|
||||
bom_item.required,
|
||||
);
|
||||
|
||||
var stock_input = constructField(
|
||||
`items_stock_item_${pk}`,
|
||||
{
|
||||
type: 'related field',
|
||||
required: 'true',
|
||||
},
|
||||
{
|
||||
hideLabels: true,
|
||||
}
|
||||
);
|
||||
|
||||
// var stock_input = constructRelatedFieldInput(`items_stock_item_${pk}`);
|
||||
|
||||
var html = `
|
||||
<tr id='allocation_row_${pk}' class='part-allocation-row'>
|
||||
<td id='part_${pk}'>
|
||||
${thumb} ${sub_part.full_name}
|
||||
</td>
|
||||
<td id='allocated_${pk}'>
|
||||
${allocated_display}
|
||||
</td>
|
||||
<td id='stock_item_${pk}'>
|
||||
${stock_input}
|
||||
</td>
|
||||
<td id='quantity_${pk}'>
|
||||
${quantity_input}
|
||||
</td>
|
||||
<td id='buttons_${pk}'>
|
||||
${delete_button}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
var table_entries = '';
|
||||
|
||||
for (var idx = 0; idx < bom_items.length; idx++) {
|
||||
var bom_item = bom_items[idx];
|
||||
|
||||
var required = bom_item.required || 0;
|
||||
var allocated = bom_item.allocated || 0;
|
||||
var remaining = required - allocated;
|
||||
|
||||
if (remaining < 0) {
|
||||
remaining = 0;
|
||||
}
|
||||
|
||||
table_entries += renderBomItemRow(bom_item, remaining);
|
||||
}
|
||||
|
||||
if (bom_items.length == 0) {
|
||||
|
||||
showAlertDialog(
|
||||
'{% trans "Select Parts" %}',
|
||||
'{% trans "You must select at least one part to allocate" %}',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var html = ``;
|
||||
|
||||
// Render a "take from" input
|
||||
html += constructField(
|
||||
'take_from',
|
||||
{
|
||||
type: 'related field',
|
||||
label: '{% trans "Source Location" %}',
|
||||
help_text: '{% trans "Select source location (leave blank to take from all locations)" %}',
|
||||
required: false,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
// Create table of parts
|
||||
html += `
|
||||
<table class='table table-striped table-condensed' id='stock-allocation-table'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Part" %}</th>
|
||||
<th>{% trans "Allocated" %}</th>
|
||||
<th style='min-width: 250px;'>{% trans "Stock Item" %}</th>
|
||||
<th>{% trans "Quantity" %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${table_entries}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
constructForm(`/api/build/${build_id}/allocate/`, {
|
||||
method: 'POST',
|
||||
fields: {},
|
||||
preFormContent: html,
|
||||
confirm: true,
|
||||
confirmMessage: '{% trans "Confirm stock allocation" %}',
|
||||
title: '{% trans "Allocate Stock Items to Build Order" %}',
|
||||
afterRender: function(fields, options) {
|
||||
|
||||
var take_from_field = {
|
||||
name: 'take_from',
|
||||
model: 'stocklocation',
|
||||
api_url: '{% url "api-location-list" %}',
|
||||
required: false,
|
||||
type: 'related field',
|
||||
value: source_location,
|
||||
noResults: function(query) {
|
||||
return '{% trans "No matching stock locations" %}';
|
||||
},
|
||||
};
|
||||
|
||||
// Initialize "take from" field
|
||||
initializeRelatedField(
|
||||
take_from_field,
|
||||
null,
|
||||
options,
|
||||
);
|
||||
|
||||
// Initialize stock item fields
|
||||
bom_items.forEach(function(bom_item) {
|
||||
initializeRelatedField(
|
||||
{
|
||||
name: `items_stock_item_${bom_item.pk}`,
|
||||
api_url: '{% url "api-stock-list" %}',
|
||||
filters: {
|
||||
bom_item: bom_item.pk,
|
||||
in_stock: true,
|
||||
part_detail: false,
|
||||
location_detail: true,
|
||||
},
|
||||
model: 'stockitem',
|
||||
required: true,
|
||||
render_part_detail: false,
|
||||
render_location_detail: true,
|
||||
auto_fill: true,
|
||||
adjustFilters: function(filters) {
|
||||
// Restrict query to the selected location
|
||||
var location = getFormFieldValue(
|
||||
'take_from',
|
||||
{},
|
||||
{
|
||||
modal: options.modal,
|
||||
}
|
||||
);
|
||||
|
||||
filters.location = location;
|
||||
filters.cascade = true;
|
||||
|
||||
return filters;
|
||||
},
|
||||
noResults: function(query) {
|
||||
return '{% trans "No matching stock items" %}';
|
||||
}
|
||||
},
|
||||
null,
|
||||
options,
|
||||
);
|
||||
});
|
||||
|
||||
// Add callback to "clear" button for take_from field
|
||||
addClearCallback(
|
||||
'take_from',
|
||||
take_from_field,
|
||||
options,
|
||||
);
|
||||
|
||||
// Add button callbacks
|
||||
$(options.modal).find('.button-row-remove').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
$(options.modal).find(`#allocation_row_${pk}`).remove();
|
||||
});
|
||||
},
|
||||
onSubmit: function(fields, opts) {
|
||||
|
||||
// Extract elements from the form
|
||||
var data = {
|
||||
items: []
|
||||
};
|
||||
|
||||
var item_pk_values = [];
|
||||
|
||||
bom_items.forEach(function(item) {
|
||||
|
||||
var quantity = getFormFieldValue(
|
||||
`items_quantity_${item.pk}`,
|
||||
{},
|
||||
{
|
||||
modal: opts.modal,
|
||||
},
|
||||
);
|
||||
|
||||
var stock_item = getFormFieldValue(
|
||||
`items_stock_item_${item.pk}`,
|
||||
{},
|
||||
{
|
||||
modal: opts.modal,
|
||||
}
|
||||
);
|
||||
|
||||
if (quantity != null) {
|
||||
data.items.push({
|
||||
bom_item: item.pk,
|
||||
stock_item: stock_item,
|
||||
quantity: quantity,
|
||||
output: output_id,
|
||||
});
|
||||
|
||||
item_pk_values.push(item.pk);
|
||||
}
|
||||
});
|
||||
|
||||
// Provide nested values
|
||||
opts.nested = {
|
||||
'items': item_pk_values
|
||||
};
|
||||
|
||||
inventreePut(
|
||||
opts.url,
|
||||
data,
|
||||
{
|
||||
method: 'POST',
|
||||
success: function(response) {
|
||||
// Hide the modal
|
||||
$(opts.modal).modal('hide');
|
||||
|
||||
if (options.success) {
|
||||
options.success(response);
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
switch (xhr.status) {
|
||||
case 400:
|
||||
handleFormErrors(xhr.responseJSON, fields, opts);
|
||||
break;
|
||||
default:
|
||||
$(opts.modal).modal('hide');
|
||||
showApiError(xhr);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
function loadBuildTable(table, options) {
|
||||
// Display a table of Build objects
|
||||
|
||||
|
@ -273,6 +273,11 @@ function setupFilterList(tableKey, table, target) {
|
||||
|
||||
var element = $(target);
|
||||
|
||||
if (!element) {
|
||||
console.log(`WARNING: setupFilterList could not find target '${target}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
// One blank slate, please
|
||||
element.empty();
|
||||
|
||||
|
@ -728,10 +728,17 @@ function updateFieldValues(fields, options) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Update the value of a named field
|
||||
*/
|
||||
function updateFieldValue(name, value, field, options) {
|
||||
var el = $(options.modal).find(`#id_${name}`);
|
||||
|
||||
if (!el) {
|
||||
console.log(`WARNING: updateFieldValue could not find field '${name}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (field.type) {
|
||||
case 'boolean':
|
||||
el.prop('checked', value);
|
||||
@ -864,6 +871,78 @@ function clearFormErrors(options) {
|
||||
$(options.modal).find('#non-field-errors').html('');
|
||||
}
|
||||
|
||||
/*
|
||||
* Display form error messages as returned from the server,
|
||||
* specifically for errors returned in an array.
|
||||
*
|
||||
* We need to know the unique ID of each item in the array,
|
||||
* and the array length must equal the length of the array returned from the server
|
||||
*
|
||||
* arguments:
|
||||
* - response: The JSON error response from the server
|
||||
* - parent: The name of the parent field e.g. "items"
|
||||
* - options: The global options struct
|
||||
*
|
||||
* options:
|
||||
* - nested: A map of nested ID values for the "parent" field
|
||||
* e.g.
|
||||
* {
|
||||
* "items": [
|
||||
* 1,
|
||||
* 2,
|
||||
* 12
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
*/
|
||||
|
||||
function handleNestedErrors(errors, field_name, options) {
|
||||
|
||||
var error_list = errors[field_name];
|
||||
|
||||
// Ignore null or empty list
|
||||
if (!error_list) {
|
||||
return;
|
||||
}
|
||||
|
||||
var nest_list = nest_list = options['nested'][field_name];
|
||||
|
||||
// Nest list must be provided!
|
||||
if (!nest_list) {
|
||||
console.log(`WARNING: handleNestedErrors missing nesting options for field '${fieldName}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (var idx = 0; idx < error_list.length; idx++) {
|
||||
|
||||
var error_item = error_list[idx];
|
||||
|
||||
if (idx >= nest_list.length) {
|
||||
console.log(`WARNING: handleNestedErrors returned greater number of errors (${error_list.length}) than could be handled (${nest_list.length})`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Extract the particular ID of the nested item
|
||||
var nest_id = nest_list[idx];
|
||||
|
||||
// Here, error_item is a map of field names to error messages
|
||||
for (sub_field_name in error_item) {
|
||||
var errors = error_item[sub_field_name];
|
||||
|
||||
// Find the target (nested) field
|
||||
var target = `${field_name}_${sub_field_name}_${nest_id}`;
|
||||
|
||||
for (var ii = errors.length-1; ii >= 0; ii--) {
|
||||
|
||||
var error_text = errors[ii];
|
||||
|
||||
addFieldErrorMessage(target, error_text, ii, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*
|
||||
* Display form error messages as returned from the server.
|
||||
@ -913,28 +992,30 @@ function handleFormErrors(errors, fields, options) {
|
||||
|
||||
for (var field_name in errors) {
|
||||
|
||||
// Add the 'has-error' class
|
||||
$(options.modal).find(`#div_id_${field_name}`).addClass('has-error');
|
||||
if (field_name in fields) {
|
||||
|
||||
var field_dom = $(options.modal).find(`#errors-${field_name}`); // $(options.modal).find(`#id_${field_name}`);
|
||||
var field = fields[field_name];
|
||||
|
||||
var field_errors = errors[field_name];
|
||||
if ((field.type == 'field') && ('child' in field)) {
|
||||
// This is a "nested" field
|
||||
handleNestedErrors(errors, field_name, options);
|
||||
} else {
|
||||
// This is a "simple" field
|
||||
|
||||
if (field_errors && !first_error_field && isFieldVisible(field_name, options)) {
|
||||
first_error_field = field_name;
|
||||
}
|
||||
var field_errors = errors[field_name];
|
||||
|
||||
// Add an entry for each returned error message
|
||||
for (var ii = field_errors.length-1; ii >= 0; ii--) {
|
||||
if (field_errors && !first_error_field && isFieldVisible(field_name, options)) {
|
||||
first_error_field = field_name;
|
||||
}
|
||||
|
||||
var error_text = field_errors[ii];
|
||||
// Add an entry for each returned error message
|
||||
for (var ii = field_errors.length-1; ii >= 0; ii--) {
|
||||
|
||||
var error_html = `
|
||||
<span id='error_${ii+1}_id_${field_name}' class='help-block form-error-message'>
|
||||
<strong>${error_text}</strong>
|
||||
</span>`;
|
||||
var error_text = field_errors[ii];
|
||||
|
||||
field_dom.append(error_html);
|
||||
addFieldErrorMessage(field_name, error_text, ii, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -952,6 +1033,30 @@ function handleFormErrors(errors, fields, options) {
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Add a rendered error message to the provided field
|
||||
*/
|
||||
function addFieldErrorMessage(field_name, error_text, error_idx, options) {
|
||||
|
||||
// Add the 'has-error' class
|
||||
$(options.modal).find(`#div_id_${field_name}`).addClass('has-error');
|
||||
|
||||
var field_dom = $(options.modal).find(`#errors-${field_name}`);
|
||||
|
||||
if (field_dom) {
|
||||
|
||||
var error_html = `
|
||||
<span id='error_${error_idx}_id_${field_name}' class='help-block form-error-message'>
|
||||
<strong>${error_text}</strong>
|
||||
</span>`;
|
||||
|
||||
field_dom.append(error_html);
|
||||
} else {
|
||||
console.log(`WARNING: addFieldErrorMessage could not locate field '${field_name}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function isFieldVisible(field, options) {
|
||||
|
||||
return $(options.modal).find(`#div_id_${field}`).is(':visible');
|
||||
@ -1007,7 +1112,14 @@ function addClearCallbacks(fields, options) {
|
||||
|
||||
function addClearCallback(name, field, options) {
|
||||
|
||||
$(options.modal).find(`#clear_${name}`).click(function() {
|
||||
var el = $(options.modal).find(`#clear_${name}`);
|
||||
|
||||
if (!el) {
|
||||
console.log(`WARNING: addClearCallback could not find field '${name}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
el.click(function() {
|
||||
updateFieldValue(name, null, field, options);
|
||||
});
|
||||
}
|
||||
@ -1168,7 +1280,7 @@ function addSecondaryModal(field, fields, options) {
|
||||
|
||||
|
||||
/*
|
||||
* Initializea single related-field
|
||||
* Initialize a single related-field
|
||||
*
|
||||
* argument:
|
||||
* - modal: DOM identifier for the modal window
|
||||
@ -1182,7 +1294,7 @@ function initializeRelatedField(field, fields, options) {
|
||||
|
||||
if (!field.api_url) {
|
||||
// TODO: Provide manual api_url option?
|
||||
console.log(`Related field '${name}' missing 'api_url' parameter.`);
|
||||
console.log(`WARNING: Related field '${name}' missing 'api_url' parameter.`);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1203,6 +1315,15 @@ function initializeRelatedField(field, fields, options) {
|
||||
placeholder: '',
|
||||
dropdownParent: $(options.modal),
|
||||
dropdownAutoWidth: false,
|
||||
language: {
|
||||
noResults: function(query) {
|
||||
if (field.noResults) {
|
||||
return field.noResults(query);
|
||||
} else {
|
||||
return '{% trans "No results found" %}';
|
||||
}
|
||||
}
|
||||
},
|
||||
ajax: {
|
||||
url: field.api_url,
|
||||
dataType: 'json',
|
||||
@ -1225,6 +1346,11 @@ function initializeRelatedField(field, fields, options) {
|
||||
query.search = params.term;
|
||||
query.offset = offset;
|
||||
query.limit = pageSize;
|
||||
|
||||
// Allow custom run-time filter augmentation
|
||||
if ('adjustFilters' in field) {
|
||||
query = field.adjustFilters(query);
|
||||
}
|
||||
|
||||
return query;
|
||||
},
|
||||
@ -1319,6 +1445,7 @@ function initializeRelatedField(field, fields, options) {
|
||||
|
||||
// If a 'value' is already defined, grab the model info from the server
|
||||
if (field.value) {
|
||||
|
||||
var pk = field.value;
|
||||
var url = `${field.api_url}/${pk}/`.replace('//', '/');
|
||||
|
||||
@ -1327,6 +1454,24 @@ function initializeRelatedField(field, fields, options) {
|
||||
setRelatedFieldData(name, data, options);
|
||||
}
|
||||
});
|
||||
} else if (field.auto_fill) {
|
||||
// Attempt to auto-fill the field
|
||||
|
||||
var filters = field.filters || {};
|
||||
|
||||
// Enforce pagination, limit to a single return (for fast query)
|
||||
filters.limit = 1;
|
||||
filters.offset = 0;
|
||||
|
||||
inventreeGet(field.api_url, field.filters || {}, {
|
||||
success: function(data) {
|
||||
|
||||
// Only a single result is available, given the provided filters
|
||||
if (data.count == 1) {
|
||||
setRelatedFieldData(name, data.results[0], options);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1370,6 +1515,7 @@ function initializeChoiceField(field, fields, options) {
|
||||
select.select2({
|
||||
dropdownAutoWidth: false,
|
||||
dropdownParent: $(options.modal),
|
||||
width: '100%',
|
||||
});
|
||||
}
|
||||
|
||||
@ -1884,7 +2030,7 @@ function constructChoiceInput(name, parameters) {
|
||||
*/
|
||||
function constructRelatedFieldInput(name) {
|
||||
|
||||
var html = `<select id='id_${name}' class='select form-control' name='${name}'></select>`;
|
||||
var html = `<select id='id_${name}' class='select form-control' name='${name}' style='width: 100%;'></select>`;
|
||||
|
||||
// Don't load any options - they will be filled via an AJAX request
|
||||
|
||||
|
@ -65,7 +65,7 @@ function imageHoverIcon(url) {
|
||||
function thumbnailImage(url) {
|
||||
|
||||
if (!url) {
|
||||
url = '/static/img/blank_img.png';
|
||||
url = blankImage();
|
||||
}
|
||||
|
||||
// TODO: Support insertion of custom classes
|
||||
|
@ -37,7 +37,7 @@ function renderCompany(name, data, parameters, options) {
|
||||
|
||||
html += `<span><b>${data.name}</b></span> - <i>${data.description}</i>`;
|
||||
|
||||
html += `<span class='float-right'>{% trans "Company ID" %}: ${data.pk}</span>`;
|
||||
html += `<span class='float-right'><small>{% trans "Company ID" %}: ${data.pk}</small></span>`;
|
||||
|
||||
return html;
|
||||
}
|
||||
@ -47,22 +47,59 @@ function renderCompany(name, data, parameters, options) {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
function renderStockItem(name, data, parameters, options) {
|
||||
|
||||
var image = data.part_detail.thumbnail || data.part_detail.image || blankImage();
|
||||
|
||||
var html = `<img src='${image}' class='select2-thumbnail'>`;
|
||||
|
||||
html += ` <span>${data.part_detail.full_name || data.part_detail.name}</span>`;
|
||||
|
||||
if (data.serial && data.quantity == 1) {
|
||||
html += ` - <i>{% trans "Serial Number" %}: ${data.serial}`;
|
||||
} else {
|
||||
html += ` - <i>{% trans "Quantity" %}: ${data.quantity}`;
|
||||
var image = blankImage();
|
||||
|
||||
if (data.part_detail) {
|
||||
image = data.part_detail.thumbnail || data.part_detail.image || blankImage();
|
||||
}
|
||||
|
||||
if (data.part_detail.description) {
|
||||
var html = '';
|
||||
|
||||
var render_part_detail = true;
|
||||
|
||||
if ('render_part_detail' in parameters) {
|
||||
render_part_detail = parameters['render_part_detail'];
|
||||
}
|
||||
|
||||
if (render_part_detail) {
|
||||
html += `<img src='${image}' class='select2-thumbnail'>`;
|
||||
html += ` <span>${data.part_detail.full_name || data.part_detail.name}</span>`;
|
||||
}
|
||||
|
||||
html += '<span>';
|
||||
|
||||
if (data.serial && data.quantity == 1) {
|
||||
html += `{% trans "Serial Number" %}: ${data.serial}`;
|
||||
} else {
|
||||
html += `{% trans "Quantity" %}: ${data.quantity}`;
|
||||
}
|
||||
|
||||
html += '</span>';
|
||||
|
||||
if (render_part_detail && data.part_detail.description) {
|
||||
html += `<p><small>${data.part_detail.description}</small></p>`;
|
||||
}
|
||||
|
||||
var render_stock_id = true;
|
||||
|
||||
if ('render_stock_id' in parameters) {
|
||||
render_stock_id = parameters['render_stock_id'];
|
||||
}
|
||||
|
||||
if (render_stock_id) {
|
||||
html += `<span class='float-right'><small>{% trans "Stock ID" %}: ${data.pk}</small></span>`;
|
||||
}
|
||||
|
||||
var render_location_detail = false;
|
||||
|
||||
if ('render_location_detail' in parameters) {
|
||||
render_location_detail = parameters['render_location_detail'];
|
||||
}
|
||||
|
||||
if (render_location_detail && data.location_detail) {
|
||||
html += `<span> - ${data.location_detail.name}</span>`;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
@ -75,11 +112,17 @@ function renderStockLocation(name, data, parameters, options) {
|
||||
|
||||
var html = `<span>${level}${data.pathstring}</span>`;
|
||||
|
||||
if (data.description) {
|
||||
var render_description = true;
|
||||
|
||||
if ('render_description' in parameters) {
|
||||
render_description = parameters['render_description'];
|
||||
}
|
||||
|
||||
if (render_description && data.description) {
|
||||
html += ` - <i>${data.description}</i>`;
|
||||
}
|
||||
|
||||
html += `<span class='float-right'>{% trans "Location ID" %}: ${data.pk}</span>`;
|
||||
html += `<span class='float-right'><small>{% trans "Location ID" %}: ${data.pk}</small></span>`;
|
||||
|
||||
return html;
|
||||
}
|
||||
@ -96,7 +139,7 @@ function renderBuild(name, data, parameters, options) {
|
||||
var html = select2Thumbnail(image);
|
||||
|
||||
html += `<span><b>${data.reference}</b></span> - ${data.quantity} x ${data.part_detail.full_name}`;
|
||||
html += `<span class='float-right'>{% trans "Build ID" %}: ${data.pk}</span>`;
|
||||
html += `<span class='float-right'><small>{% trans "Build ID" %}: ${data.pk}</span></span>`;
|
||||
|
||||
html += `<p><i>${data.title}</i></p>`;
|
||||
|
||||
@ -116,7 +159,7 @@ function renderPart(name, data, parameters, options) {
|
||||
html += ` - <i>${data.description}</i>`;
|
||||
}
|
||||
|
||||
html += `<span class='float-right'>{% trans "Part ID" %}: ${data.pk}</span>`;
|
||||
html += `<span class='float-right'><small>{% trans "Part ID" %}: ${data.pk}</small></span>`;
|
||||
|
||||
return html;
|
||||
}
|
||||
@ -168,7 +211,7 @@ function renderPartCategory(name, data, parameters, options) {
|
||||
html += ` - <i>${data.description}</i>`;
|
||||
}
|
||||
|
||||
html += `<span class='float-right'>{% trans "Category ID" %}: ${data.pk}</span>`;
|
||||
html += `<span class='float-right'><small>{% trans "Category ID" %}: ${data.pk}</small></span>`;
|
||||
|
||||
return html;
|
||||
}
|
||||
@ -205,7 +248,7 @@ function renderManufacturerPart(name, data, parameters, options) {
|
||||
html += ` <span><b>${data.manufacturer_detail.name}</b> - ${data.MPN}</span>`;
|
||||
html += ` - <i>${data.part_detail.full_name}</i>`;
|
||||
|
||||
html += `<span class='float-right'>{% trans "Manufacturer Part ID" %}: ${data.pk}</span>`;
|
||||
html += `<span class='float-right'><small>{% trans "Manufacturer Part ID" %}: ${data.pk}</small></span>`;
|
||||
|
||||
return html;
|
||||
}
|
||||
@ -234,7 +277,7 @@ function renderSupplierPart(name, data, parameters, options) {
|
||||
html += ` <span><b>${data.supplier_detail.name}</b> - ${data.SKU}</span>`;
|
||||
html += ` - <i>${data.part_detail.full_name}</i>`;
|
||||
|
||||
html += `<span class='float-right'>{% trans "Supplier Part ID" %}: ${data.pk}</span>`;
|
||||
html += `<span class='float-right'><small>{% trans "Supplier Part ID" %}: ${data.pk}</small></span>`;
|
||||
|
||||
|
||||
return html;
|
||||
|
@ -12,6 +12,7 @@
|
||||
loadTableFilters,
|
||||
makeIconBadge,
|
||||
purchaseOrderStatusDisplay,
|
||||
receivePurchaseOrderItems,
|
||||
renderLink,
|
||||
salesOrderStatusDisplay,
|
||||
setupFilterList,
|
||||
@ -234,6 +235,291 @@ function newPurchaseOrderFromOrderWizard(e) {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Receive stock items against a PurchaseOrder
|
||||
* Uses the POReceive API endpoint
|
||||
*
|
||||
* arguments:
|
||||
* - order_id, ID / PK for the PurchaseOrder instance
|
||||
* - line_items: A list of PurchaseOrderLineItems objects to be allocated
|
||||
*
|
||||
* options:
|
||||
* -
|
||||
*/
|
||||
function receivePurchaseOrderItems(order_id, line_items, options={}) {
|
||||
|
||||
if (line_items.length == 0) {
|
||||
showAlertDialog(
|
||||
'{% trans "Select Line Items" %}',
|
||||
'{% trans "At least one line item must be selected" %}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
function renderLineItem(line_item, opts={}) {
|
||||
|
||||
var pk = line_item.pk;
|
||||
|
||||
// Part thumbnail + description
|
||||
var thumb = thumbnailImage(line_item.part_detail.thumbnail);
|
||||
|
||||
var quantity = (line_item.quantity || 0) - (line_item.received || 0);
|
||||
|
||||
if (quantity < 0) {
|
||||
quantity = 0;
|
||||
}
|
||||
|
||||
// Quantity to Receive
|
||||
var quantity_input = constructField(
|
||||
`items_quantity_${pk}`,
|
||||
{
|
||||
type: 'decimal',
|
||||
min_value: 0,
|
||||
value: quantity,
|
||||
title: '{% trans "Quantity to receive" %}',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
hideLabels: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Construct list of StockItem status codes
|
||||
var choices = [];
|
||||
|
||||
for (var key in stockCodes) {
|
||||
choices.push({
|
||||
value: key,
|
||||
display_name: stockCodes[key].value,
|
||||
});
|
||||
}
|
||||
|
||||
var destination_input = constructField(
|
||||
`items_location_${pk}`,
|
||||
{
|
||||
type: 'related field',
|
||||
label: '{% trans "Location" %}',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
hideLabels: true,
|
||||
}
|
||||
);
|
||||
|
||||
var status_input = constructField(
|
||||
`items_status_${pk}`,
|
||||
{
|
||||
type: 'choice',
|
||||
label: '{% trans "Stock Status" %}',
|
||||
required: true,
|
||||
choices: choices,
|
||||
value: 10, // OK
|
||||
},
|
||||
{
|
||||
hideLabels: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Button to remove the row
|
||||
var delete_button = `<div class='btn-group float-right' role='group'>`;
|
||||
|
||||
delete_button += makeIconButton(
|
||||
'fa-times icon-red',
|
||||
'button-row-remove',
|
||||
pk,
|
||||
'{% trans "Remove row" %}',
|
||||
);
|
||||
|
||||
delete_button += '</div>';
|
||||
|
||||
var html = `
|
||||
<tr id='receive_row_${pk}' class='stock-receive-row'>
|
||||
<td id='part_${pk}'>
|
||||
${thumb} ${line_item.part_detail.full_name}
|
||||
</td>
|
||||
<td id='sku_${pk}'>
|
||||
${line_item.supplier_part_detail.SKU}
|
||||
</td>
|
||||
<td id='on_order_${pk}'>
|
||||
${line_item.quantity}
|
||||
</td>
|
||||
<td id='received_${pk}'>
|
||||
${line_item.received}
|
||||
</td>
|
||||
<td id='quantity_${pk}'>
|
||||
${quantity_input}
|
||||
</td>
|
||||
<td id='status_${pk}'>
|
||||
${status_input}
|
||||
</td>
|
||||
<td id='desination_${pk}'>
|
||||
${destination_input}
|
||||
</td>
|
||||
<td id='actions_${pk}'>
|
||||
${delete_button}
|
||||
</td>
|
||||
</tr>`;
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
var table_entries = '';
|
||||
|
||||
line_items.forEach(function(item) {
|
||||
table_entries += renderLineItem(item);
|
||||
});
|
||||
|
||||
var html = ``;
|
||||
|
||||
// Add table
|
||||
html += `
|
||||
<table class='table table-striped table-condensed' id='order-receive-table'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Part" %}</th>
|
||||
<th>{% trans "Order Code" %}</th>
|
||||
<th>{% trans "Ordered" %}</th>
|
||||
<th>{% trans "Received" %}</th>
|
||||
<th style='min-width: 50px;'>{% trans "Receive" %}</th>
|
||||
<th style='min-width: 150px;'>{% trans "Status" %}</th>
|
||||
<th style='min-width: 300px;'>{% trans "Destination" %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${table_entries}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
constructForm(`/api/order/po/${order_id}/receive/`, {
|
||||
method: 'POST',
|
||||
fields: {
|
||||
location: {},
|
||||
},
|
||||
preFormContent: html,
|
||||
confirm: true,
|
||||
confirmMessage: '{% trans "Confirm receipt of items" %}',
|
||||
title: '{% trans "Receive Purchase Order Items" %}',
|
||||
afterRender: function(fields, opts) {
|
||||
// Initialize the "destination" field for each item
|
||||
line_items.forEach(function(item) {
|
||||
|
||||
var pk = item.pk;
|
||||
|
||||
var name = `items_location_${pk}`;
|
||||
|
||||
var field_details = {
|
||||
name: name,
|
||||
api_url: '{% url "api-location-list" %}',
|
||||
filters: {
|
||||
|
||||
},
|
||||
type: 'related field',
|
||||
model: 'stocklocation',
|
||||
required: false,
|
||||
auto_fill: false,
|
||||
value: item.destination || item.part_detail.default_location,
|
||||
render_description: false,
|
||||
};
|
||||
|
||||
initializeRelatedField(
|
||||
field_details,
|
||||
null,
|
||||
opts,
|
||||
);
|
||||
|
||||
addClearCallback(
|
||||
name,
|
||||
field_details,
|
||||
opts
|
||||
);
|
||||
|
||||
initializeChoiceField(
|
||||
{
|
||||
name: `items_status_${pk}`,
|
||||
},
|
||||
null,
|
||||
opts
|
||||
);
|
||||
});
|
||||
|
||||
// Add callbacks to remove rows
|
||||
$(opts.modal).find('.button-row-remove').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
$(opts.modal).find(`#receive_row_${pk}`).remove();
|
||||
});
|
||||
},
|
||||
onSubmit: function(fields, opts) {
|
||||
// Extract data elements from the form
|
||||
var data = {
|
||||
items: [],
|
||||
location: getFormFieldValue('location', {}, opts),
|
||||
};
|
||||
|
||||
var item_pk_values = [];
|
||||
|
||||
line_items.forEach(function(item) {
|
||||
|
||||
var pk = item.pk;
|
||||
|
||||
var quantity = getFormFieldValue(`items_quantity_${pk}`, {}, opts);
|
||||
|
||||
var status = getFormFieldValue(`items_status_${pk}`, {}, opts);
|
||||
|
||||
var location = getFormFieldValue(`items_location_${pk}`, {}, opts);
|
||||
|
||||
if (quantity != null) {
|
||||
data.items.push({
|
||||
line_item: pk,
|
||||
quantity: quantity,
|
||||
status: status,
|
||||
location: location,
|
||||
});
|
||||
|
||||
item_pk_values.push(pk);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
// Provide list of nested values
|
||||
opts.nested = {
|
||||
'items': item_pk_values,
|
||||
};
|
||||
|
||||
inventreePut(
|
||||
opts.url,
|
||||
data,
|
||||
{
|
||||
method: 'POST',
|
||||
success: function(response) {
|
||||
// Hide the modal
|
||||
$(opts.modal).modal('hide');
|
||||
|
||||
if (options.success) {
|
||||
options.success(response);
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
switch (xhr.status) {
|
||||
case 400:
|
||||
handleFormErrors(xhr.responseJSON, fields, opts);
|
||||
break;
|
||||
default:
|
||||
$(opts.modal).modal('hide');
|
||||
showApiError(xhr);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function editPurchaseOrderLineItem(e) {
|
||||
|
||||
/* Edit a purchase order line item in a modal form.
|
||||
@ -280,12 +566,10 @@ function loadPurchaseOrderTable(table, options) {
|
||||
filters[key] = options.params[key];
|
||||
}
|
||||
|
||||
options.url = options.url || '{% url "api-po-list" %}';
|
||||
|
||||
setupFilterList('purchaseorder', $(table));
|
||||
|
||||
$(table).inventreeTable({
|
||||
url: options.url,
|
||||
url: '{% url "api-po-list" %}',
|
||||
queryParams: filters,
|
||||
name: 'purchaseorder',
|
||||
groupBy: false,
|
||||
@ -379,6 +663,21 @@ function loadPurchaseOrderTable(table, options) {
|
||||
*/
|
||||
function loadPurchaseOrderLineItemTable(table, options={}) {
|
||||
|
||||
options.params = options.params || {};
|
||||
|
||||
options.params['order'] = options.order;
|
||||
options.params['part_detail'] = true;
|
||||
|
||||
var filters = loadTableFilters('purchaseorderlineitem');
|
||||
|
||||
for (var key in options.params) {
|
||||
filters[key] = options.params[key];
|
||||
}
|
||||
|
||||
var target = options.filter_target || '#filter-list-purchase-order-lines';
|
||||
|
||||
setupFilterList('purchaseorderlineitem', $(table), target);
|
||||
|
||||
function setupCallbacks() {
|
||||
if (options.allow_edit) {
|
||||
$(table).find('.button-line-edit').click(function() {
|
||||
@ -424,22 +723,24 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
|
||||
$(table).find('.button-line-receive').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
launchModalForm(`/order/purchase-order/${options.order}/receive/`, {
|
||||
success: function() {
|
||||
$(table).bootstrapTable('refresh');
|
||||
},
|
||||
data: {
|
||||
line: pk,
|
||||
},
|
||||
secondary: [
|
||||
{
|
||||
field: 'location',
|
||||
label: '{% trans "New Location" %}',
|
||||
title: '{% trans "Create new stock location" %}',
|
||||
url: '{% url "stock-location-create" %}',
|
||||
},
|
||||
]
|
||||
});
|
||||
var line_item = $(table).bootstrapTable('getRowByUniqueId', pk);
|
||||
|
||||
if (!line_item) {
|
||||
console.log('WARNING: getRowByUniqueId returned null');
|
||||
return;
|
||||
}
|
||||
|
||||
receivePurchaseOrderItems(
|
||||
options.order,
|
||||
[
|
||||
line_item,
|
||||
],
|
||||
{
|
||||
success: function() {
|
||||
$(table).bootstrapTable('refresh');
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -451,17 +752,15 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
|
||||
formatNoMatches: function() {
|
||||
return '{% trans "No line items found" %}';
|
||||
},
|
||||
queryParams: {
|
||||
order: options.order,
|
||||
part_detail: true
|
||||
},
|
||||
queryParams: filters,
|
||||
original: options.params,
|
||||
url: '{% url "api-po-line-list" %}',
|
||||
showFooter: true,
|
||||
uniqueId: 'pk',
|
||||
columns: [
|
||||
{
|
||||
field: 'pk',
|
||||
title: 'ID',
|
||||
visible: false,
|
||||
checkbox: true,
|
||||
visible: true,
|
||||
switchable: false,
|
||||
},
|
||||
{
|
||||
@ -618,7 +917,7 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
|
||||
}
|
||||
|
||||
if (options.allow_receive && row.received < row.quantity) {
|
||||
html += makeIconButton('fa-clipboard-check', 'button-line-receive', pk, '{% trans "Receive line item" %}');
|
||||
html += makeIconButton('fa-sign-in-alt', 'button-line-receive', pk, '{% trans "Receive line item" %}');
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
|
@ -4,15 +4,12 @@
|
||||
|
||||
/* globals
|
||||
attachSelect,
|
||||
attachToggle,
|
||||
blankImage,
|
||||
enableField,
|
||||
clearField,
|
||||
clearFieldOptions,
|
||||
closeModal,
|
||||
constructField,
|
||||
constructFormBody,
|
||||
constructNumberInput,
|
||||
createNewModal,
|
||||
getFormFieldValue,
|
||||
global_settings,
|
||||
handleFormErrors,
|
||||
@ -247,7 +244,7 @@ function adjustStock(action, items, options={}) {
|
||||
break;
|
||||
}
|
||||
|
||||
var image = item.part_detail.thumbnail || item.part_detail.image || blankImage();
|
||||
var thumb = thumbnailImage(item.part_detail.thumbnail || item.part_detail.image);
|
||||
|
||||
var status = stockStatusDisplay(item.status, {
|
||||
classes: 'float-right'
|
||||
@ -268,14 +265,18 @@ function adjustStock(action, items, options={}) {
|
||||
var actionInput = '';
|
||||
|
||||
if (actionTitle != null) {
|
||||
actionInput = constructNumberInput(
|
||||
item.pk,
|
||||
actionInput = constructField(
|
||||
`items_quantity_${pk}`,
|
||||
{
|
||||
value: value,
|
||||
type: 'decimal',
|
||||
min_value: minValue,
|
||||
max_value: maxValue,
|
||||
read_only: readonly,
|
||||
value: value,
|
||||
title: readonly ? '{% trans "Quantity cannot be adjusted for serialized stock" %}' : '{% trans "Specify stock quantity" %}',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
hideLabels: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -293,7 +294,7 @@ function adjustStock(action, items, options={}) {
|
||||
|
||||
html += `
|
||||
<tr id='stock_item_${pk}' class='stock-item-row'>
|
||||
<td id='part_${pk}'><img src='${image}' class='hover-img-thumb'> ${item.part_detail.full_name}</td>
|
||||
<td id='part_${pk}'>${thumb} ${item.part_detail.full_name}</td>
|
||||
<td id='stock_${pk}'>${quantity}${status}</td>
|
||||
<td id='location_${pk}'>${location}</td>
|
||||
<td id='action_${pk}'>
|
||||
@ -319,50 +320,89 @@ function adjustStock(action, items, options={}) {
|
||||
|
||||
html += `</tbody></table>`;
|
||||
|
||||
var modal = createNewModal({
|
||||
title: formTitle,
|
||||
});
|
||||
var extraFields = {};
|
||||
|
||||
// Extra fields
|
||||
var extraFields = {
|
||||
location: {
|
||||
label: '{% trans "Location" %}',
|
||||
help_text: '{% trans "Select destination stock location" %}',
|
||||
type: 'related field',
|
||||
required: true,
|
||||
api_url: `/api/stock/location/`,
|
||||
model: 'stocklocation',
|
||||
name: 'location',
|
||||
},
|
||||
notes: {
|
||||
label: '{% trans "Notes" %}',
|
||||
help_text: '{% trans "Stock transaction notes" %}',
|
||||
type: 'string',
|
||||
name: 'notes',
|
||||
}
|
||||
};
|
||||
|
||||
if (!specifyLocation) {
|
||||
delete extraFields.location;
|
||||
if (specifyLocation) {
|
||||
extraFields.location = {};
|
||||
}
|
||||
|
||||
constructFormBody({}, {
|
||||
preFormContent: html,
|
||||
if (action != 'delete') {
|
||||
extraFields.notes = {};
|
||||
}
|
||||
|
||||
constructForm(url, {
|
||||
method: 'POST',
|
||||
fields: extraFields,
|
||||
preFormContent: html,
|
||||
confirm: true,
|
||||
confirmMessage: '{% trans "Confirm stock adjustment" %}',
|
||||
modal: modal,
|
||||
onSubmit: function(fields) {
|
||||
title: formTitle,
|
||||
afterRender: function(fields, opts) {
|
||||
// Add button callbacks to remove rows
|
||||
$(opts.modal).find('.button-stock-item-remove').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
// "Delete" action gets handled differently
|
||||
$(opts.modal).find(`#stock_item_${pk}`).remove();
|
||||
});
|
||||
|
||||
// Initialize "location" field
|
||||
if (specifyLocation) {
|
||||
initializeRelatedField(
|
||||
{
|
||||
name: 'location',
|
||||
type: 'related field',
|
||||
model: 'stocklocation',
|
||||
required: true,
|
||||
},
|
||||
null,
|
||||
opts
|
||||
);
|
||||
}
|
||||
},
|
||||
onSubmit: function(fields, opts) {
|
||||
|
||||
// Extract data elements from the form
|
||||
var data = {
|
||||
items: [],
|
||||
};
|
||||
|
||||
if (action != 'delete') {
|
||||
data.notes = getFormFieldValue('notes', {}, opts);
|
||||
}
|
||||
|
||||
if (specifyLocation) {
|
||||
data.location = getFormFieldValue('location', {}, opts);
|
||||
}
|
||||
|
||||
var item_pk_values = [];
|
||||
|
||||
items.forEach(function(item) {
|
||||
var pk = item.pk;
|
||||
|
||||
// Does the row exist in the form?
|
||||
var row = $(opts.modal).find(`#stock_item_${pk}`);
|
||||
|
||||
if (row) {
|
||||
|
||||
item_pk_values.push(pk);
|
||||
|
||||
var quantity = getFormFieldValue(`items_quantity_${pk}`, {}, opts);
|
||||
|
||||
data.items.push({
|
||||
pk: pk,
|
||||
quantity: quantity,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Delete action is handled differently
|
||||
if (action == 'delete') {
|
||||
|
||||
var requests = [];
|
||||
|
||||
items.forEach(function(item) {
|
||||
item_pk_values.forEach(function(pk) {
|
||||
requests.push(
|
||||
inventreeDelete(
|
||||
`/api/stock/${item.pk}/`,
|
||||
`/api/stock/${pk}/`,
|
||||
)
|
||||
);
|
||||
});
|
||||
@ -370,72 +410,40 @@ function adjustStock(action, items, options={}) {
|
||||
// Wait for *all* the requests to complete
|
||||
$.when.apply($, requests).done(function() {
|
||||
// Destroy the modal window
|
||||
$(modal).modal('hide');
|
||||
$(opts.modal).modal('hide');
|
||||
|
||||
if (options.onSuccess) {
|
||||
options.onSuccess();
|
||||
if (options.success) {
|
||||
options.success();
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Data to transmit
|
||||
var data = {
|
||||
items: [],
|
||||
opts.nested = {
|
||||
'items': item_pk_values,
|
||||
};
|
||||
|
||||
// Add values for each selected stock item
|
||||
items.forEach(function(item) {
|
||||
|
||||
var q = getFormFieldValue(item.pk, {}, {modal: modal});
|
||||
|
||||
if (q != null) {
|
||||
data.items.push({pk: item.pk, quantity: q});
|
||||
}
|
||||
});
|
||||
|
||||
// Add in extra field data
|
||||
for (var field_name in extraFields) {
|
||||
data[field_name] = getFormFieldValue(
|
||||
field_name,
|
||||
fields[field_name],
|
||||
{
|
||||
modal: modal,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
inventreePut(
|
||||
url,
|
||||
data,
|
||||
{
|
||||
method: 'POST',
|
||||
success: function() {
|
||||
success: function(response) {
|
||||
// Hide the modal
|
||||
$(opts.modal).modal('hide');
|
||||
|
||||
// Destroy the modal window
|
||||
$(modal).modal('hide');
|
||||
|
||||
if (options.onSuccess) {
|
||||
options.onSuccess();
|
||||
if (options.success) {
|
||||
options.success(response);
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
switch (xhr.status) {
|
||||
case 400:
|
||||
|
||||
// Handle errors for standard fields
|
||||
handleFormErrors(
|
||||
xhr.responseJSON,
|
||||
extraFields,
|
||||
{
|
||||
modal: modal,
|
||||
}
|
||||
);
|
||||
|
||||
handleFormErrors(xhr.responseJSON, fields, opts);
|
||||
break;
|
||||
default:
|
||||
$(modal).modal('hide');
|
||||
$(opts.modal).modal('hide');
|
||||
showApiError(xhr);
|
||||
break;
|
||||
}
|
||||
@ -444,18 +452,6 @@ function adjustStock(action, items, options={}) {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Attach callbacks for the action buttons
|
||||
$(modal).find('.button-stock-item-remove').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
$(modal).find(`#stock_item_${pk}`).remove();
|
||||
});
|
||||
|
||||
attachToggle(modal);
|
||||
|
||||
$(modal + ' .select2-container').addClass('select-full-width');
|
||||
$(modal + ' .select2-container').css('width', '100%');
|
||||
}
|
||||
|
||||
|
||||
@ -1258,7 +1254,7 @@ function loadStockTable(table, options) {
|
||||
var items = $(table).bootstrapTable('getSelections');
|
||||
|
||||
adjustStock(action, items, {
|
||||
onSuccess: function() {
|
||||
success: function() {
|
||||
$(table).bootstrapTable('refresh');
|
||||
}
|
||||
});
|
||||
|
@ -274,7 +274,16 @@ function getAvailableTableFilters(tableKey) {
|
||||
};
|
||||
}
|
||||
|
||||
// Filters for the "Order" table
|
||||
// Filters for PurchaseOrderLineItem table
|
||||
if (tableKey == 'purchaseorderlineitem') {
|
||||
return {
|
||||
completed: {
|
||||
type: 'bool',
|
||||
title: '{% trans "Completed" %}',
|
||||
},
|
||||
};
|
||||
}
|
||||
// Filters for the PurchaseOrder table
|
||||
if (tableKey == 'purchaseorder') {
|
||||
|
||||
return {
|
||||
|
17
tasks.py
17
tasks.py
@ -127,13 +127,20 @@ def worker(c):
|
||||
|
||||
|
||||
@task
|
||||
def rebuild(c):
|
||||
def rebuild_models(c):
|
||||
"""
|
||||
Rebuild database models with MPTT structures
|
||||
"""
|
||||
|
||||
manage(c, "rebuild_models")
|
||||
manage(c, "rebuild_models", pty=True)
|
||||
|
||||
@task
|
||||
def rebuild_thumbnails(c):
|
||||
"""
|
||||
Rebuild missing image thumbnails
|
||||
"""
|
||||
|
||||
manage(c, "rebuild_thumbnails", pty=True)
|
||||
|
||||
@task
|
||||
def clean_settings(c):
|
||||
@ -143,7 +150,7 @@ def clean_settings(c):
|
||||
|
||||
manage(c, "clean_settings")
|
||||
|
||||
@task(post=[rebuild])
|
||||
@task(post=[rebuild_models, rebuild_thumbnails])
|
||||
def migrate(c):
|
||||
"""
|
||||
Performs database migrations.
|
||||
@ -341,7 +348,7 @@ def export_records(c, filename='data.json'):
|
||||
print("Data export completed")
|
||||
|
||||
|
||||
@task(help={'filename': 'Input filename'}, post=[rebuild])
|
||||
@task(help={'filename': 'Input filename'}, post=[rebuild_models, rebuild_thumbnails])
|
||||
def import_records(c, filename='data.json'):
|
||||
"""
|
||||
Import database records from a file
|
||||
@ -399,7 +406,7 @@ def delete_data(c, force=False):
|
||||
manage(c, 'flush')
|
||||
|
||||
|
||||
@task(post=[rebuild])
|
||||
@task(post=[rebuild_models, rebuild_thumbnails])
|
||||
def import_fixtures(c):
|
||||
"""
|
||||
Import fixture data into the database.
|
||||
|
Loading…
Reference in New Issue
Block a user