Further improvements to build allocation form

- Auto-allocation button ignores outputs which are complete
- StockItem API allows filtering by BomItem
- Quantity inputs are now auto-filled
- Display progress bar in the modal form
This commit is contained in:
Oliver 2021-10-05 08:25:10 +11:00
parent 0c04bfaa85
commit ae0efe73d1
9 changed files with 117 additions and 54 deletions

View File

@ -10,11 +10,15 @@ import common.models
INVENTREE_SW_VERSION = "0.6.0 dev" INVENTREE_SW_VERSION = "0.6.0 dev"
INVENTREE_API_VERSION = 12 INVENTREE_API_VERSION = 13
""" """
Increment this API version number whenever there is a significant change to the API that any clients need to know about Increment this API version number whenever there is a significant change to the API that any clients need to know about
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 v12 -> 2021-09-07
- Adds API endpoint to receive stock items against a PurchaseOrder - Adds API endpoint to receive stock items against a PurchaseOrder

View File

@ -9,8 +9,9 @@ import decimal
import os import os
from datetime import datetime from datetime import datetime
from django.contrib.auth.models import User
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.urls import reverse from django.urls import reverse
@ -1055,8 +1056,10 @@ class BuildItem(models.Model):
Attributes: Attributes:
build: Link to a Build object 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 stock_item: Link to a StockItem object
quantity: Number of units allocated quantity: Number of units allocated
install_into: Destination stock item (or None)
""" """
@staticmethod @staticmethod

View File

@ -433,16 +433,32 @@ $("#btn-auto-allocate").on('click', function() {
var bom_items = $("#allocation-table-untracked").bootstrapTable("getData"); 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( allocateStockToBuild(
{{ build.pk }}, {{ build.pk }},
{{ build.part.pk }}, {{ build.part.pk }},
bom_items, incomplete_bom_items,
{ {
success: function(data) { success: function(data) {
$('#allocation-table-untracked').bootstrapTable('refresh'); $('#allocation-table-untracked').bootstrapTable('refresh');
} }
} }
); );
}
}); });
$('#btn-unallocate').on('click', function() { $('#btn-unallocate').on('click', function() {

View File

@ -1,18 +1,6 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import os
import logging
from PIL import UnidentifiedImageError
from django.apps import AppConfig 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): class CompanyConfig(AppConfig):

View File

@ -1,13 +1,9 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import os
import logging import logging
from django.db.utils import OperationalError, ProgrammingError from django.db.utils import OperationalError, ProgrammingError
from django.apps import AppConfig from django.apps import AppConfig
from django.conf import settings
from PIL import UnidentifiedImageError
from InvenTree.ready import canAppAccessDatabase from InvenTree.ready import canAppAccessDatabase
@ -40,7 +36,7 @@ class PartConfig(AppConfig):
items = BomItem.objects.filter(part__trackable=False, sub_part__trackable=True) items = BomItem.objects.filter(part__trackable=False, sub_part__trackable=True)
for item in items: 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.trackable = True
item.part.clean() item.part.clean()
item.part.save() item.part.save()

View File

@ -2329,6 +2329,23 @@ class BomItem(models.Model):
def get_api_url(): def get_api_url():
return reverse('api-bom-list') 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): def save(self, *args, **kwargs):
self.clean() self.clean()

View File

@ -2,11 +2,18 @@
JSON API for the Stock app JSON API for the Stock app
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from decimal import Decimal, InvalidOperation
from datetime import datetime, timedelta
from django.utils.translation import ugettext_lazy as _
from django.conf.urls import url, include from django.conf.urls import url, include
from django.urls import reverse from django.urls import reverse
from django.http import JsonResponse from django.http import JsonResponse
from django.db.models import Q from django.db.models import Q
from django.utils.translation import ugettext_lazy as _
from rest_framework import status from rest_framework import status
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
@ -22,7 +29,7 @@ from .models import StockItemTracking
from .models import StockItemAttachment from .models import StockItemAttachment
from .models import StockItemTestResult from .models import StockItemTestResult
from part.models import Part, PartCategory from part.models import BomItem, Part, PartCategory
from part.serializers import PartBriefSerializer from part.serializers import PartBriefSerializer
from company.models import Company, SupplierPart from company.models import Company, SupplierPart
@ -45,10 +52,6 @@ from InvenTree.helpers import str2bool, isNull
from InvenTree.api import AttachmentMixin from InvenTree.api import AttachmentMixin
from InvenTree.filters import InvenTreeOrderingFilter from InvenTree.filters import InvenTreeOrderingFilter
from decimal import Decimal, InvalidOperation
from datetime import datetime, timedelta
class StockCategoryTree(TreeSerializer): class StockCategoryTree(TreeSerializer):
title = _('Stock') title = _('Stock')
@ -670,14 +673,14 @@ class StockList(generics.ListCreateAPIView):
return queryset return queryset
def filter_queryset(self, queryset): def filter_queryset(self, queryset):
"""
Custom filtering for the StockItem queryset
"""
params = self.request.query_params params = self.request.query_params
queryset = super().filter_queryset(queryset) 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) supplier_part = params.get('supplier_part', None)
if supplier_part: if supplier_part:
@ -843,6 +846,18 @@ class StockList(generics.ListCreateAPIView):
except (ValueError, PartCategory.DoesNotExist): except (ValueError, PartCategory.DoesNotExist):
raise ValidationError({"category": "Invalid category id specified"}) 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 # Filter by StockItem status
status = params.get('status', None) status = params.get('status', None)

View File

@ -345,18 +345,26 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
function requiredQuantity(row) { function requiredQuantity(row) {
// Return the requied quantity for a given row // Return the requied quantity for a given row
var quantity = 0;
if (output) { if (output) {
// "Tracked" parts are calculated against individual build outputs // "Tracked" parts are calculated against individual build outputs
return row.quantity * output.quantity; quantity = row.quantity * output.quantity;
} else { } else {
// "Untracked" parts are specified against the build itself // "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) { function sumAllocations(row) {
// Calculat total allocations for a given row // Calculat total allocations for a given row
if (!row.allocations) { if (!row.allocations) {
row.allocated = 0;
return 0; return 0;
} }
@ -366,6 +374,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
quantity += item.quantity; quantity += item.quantity;
}); });
row.allocated = quantity;
return quantity; return quantity;
} }
@ -394,7 +404,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
], ],
{ {
success: function(data) { success: function(data) {
// TODO: Reload table $(table).bootstrapTable('refresh');
}, },
} }
); );
@ -854,6 +864,11 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
} }
); );
var allocated_display = makeProgressBar(
bom_item.allocated,
bom_item.required,
);
var stock_input = constructField( var stock_input = constructField(
`items_stock_item_${pk}`, `items_stock_item_${pk}`,
{ {
@ -872,11 +887,12 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
<td id='part_${pk}'> <td id='part_${pk}'>
${thumb} ${sub_part.full_name} ${thumb} ${sub_part.full_name}
</td> </td>
<td id='allocated_${pk}'>
${allocated_display}
</td>
<td id='stock_item_${pk}'> <td id='stock_item_${pk}'>
${stock_input} ${stock_input}
</td> </td>
<td id='allocated_${pk}'>
</td>
<td id='quantity_${pk}'> <td id='quantity_${pk}'>
${quantity_input} ${quantity_input}
</td> </td>
@ -894,7 +910,15 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
for (var idx = 0; idx < bom_items.length; idx++) { for (var idx = 0; idx < bom_items.length; idx++) {
var bom_item = bom_items[idx]; var bom_item = bom_items[idx];
table_entries += renderBomItemRow(bom_item); 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) { if (bom_items.length == 0) {
@ -913,8 +937,8 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
<thead> <thead>
<tr> <tr>
<th>{% trans "Part" %}</th> <th>{% trans "Part" %}</th>
<th style='min-width: 250px;'>{% trans "Stock Item" %}</th>
<th>{% trans "Allocated" %}</th> <th>{% trans "Allocated" %}</th>
<th style='min-width: 250px;'>{% trans "Stock Item" %}</th>
<th>{% trans "Quantity" %}</th> <th>{% trans "Quantity" %}</th>
<th></th> <th></th>
</tr> </tr>
@ -942,7 +966,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
name: `items_stock_item_${bom_item.pk}`, name: `items_stock_item_${bom_item.pk}`,
api_url: '{% url "api-stock-list" %}', api_url: '{% url "api-stock-list" %}',
filters: { filters: {
part: bom_item.sub_part, bom_item: bom_item.pk,
in_stock: true, in_stock: true,
part_detail: false, part_detail: false,
location_detail: true, location_detail: true,
@ -968,7 +992,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
$(options.modal).find(`#allocation_row_${pk}`).remove(); $(options.modal).find(`#allocation_row_${pk}`).remove();
}); });
}, },
onSubmit: function(fields, options) { onSubmit: function(fields, opts) {
// Extract elements from the form // Extract elements from the form
var data = { var data = {
@ -983,7 +1007,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
`items_quantity_${item.pk}`, `items_quantity_${item.pk}`,
{}, {},
{ {
modal: options.modal, modal: opts.modal,
}, },
); );
@ -991,7 +1015,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
`items_stock_item_${item.pk}`, `items_stock_item_${item.pk}`,
{}, {},
{ {
modal: options.modal, modal: opts.modal,
} }
); );
@ -1007,18 +1031,18 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
}); });
// Provide nested values // Provide nested values
options.nested = { opts.nested = {
"items": item_pk_values "items": item_pk_values
}; };
inventreePut( inventreePut(
options.url, opts.url,
data, data,
{ {
method: 'POST', method: 'POST',
success: function(response) { success: function(response) {
// Hide the modal // Hide the modal
$(options.modal).modal('hide'); $(opts.modal).modal('hide');
if (options.success) { if (options.success) {
options.success(response); options.success(response);
@ -1027,10 +1051,10 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
error: function(xhr) { error: function(xhr) {
switch (xhr.status) { switch (xhr.status) {
case 400: case 400:
handleFormErrors(xhr.responseJSON, fields, options); handleFormErrors(xhr.responseJSON, fields, opts);
break; break;
default: default:
$(options.modal).modal('hide'); $(opts.modal).modal('hide');
showApiError(xhr); showApiError(xhr);
break; break;
} }