Merge branch 'inventree:master' into fr-1421-sso

This commit is contained in:
Matthias Mair 2021-10-06 02:01:16 +02:00 committed by GitHub
commit 2d65f6f905
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 2195 additions and 1717 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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'},
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -193,6 +193,7 @@ class PartBriefSerializer(InvenTreeModelSerializer):
fields = [
'pk',
'IPN',
'default_location',
'name',
'revision',
'full_name',

View File

@ -372,7 +372,7 @@
{
success: function(items) {
adjustStock(action, items, {
onSuccess: function() {
success: function() {
location.reload();
}
});

View File

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

View File

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

View File

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

View File

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

View File

@ -561,7 +561,7 @@ function itemAdjust(action) {
{
success: function(item) {
adjustStock(action, [item], {
onSuccess: function() {
success: function() {
location.reload();
}
});

View File

@ -287,7 +287,7 @@
{
success: function(items) {
adjustStock(action, items, {
onSuccess: function() {
success: function() {
location.reload();
}
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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