Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver 2021-06-02 18:53:52 +10:00
commit 1f6fc5eb1a
15 changed files with 339 additions and 292 deletions

View File

@ -16,9 +16,13 @@ Increment thi API version number whenever there is a significant change to the A
v3 -> 2021-05-22:
- The updated StockItem "history tracking" now uses a different interface
v4 -> 2021-06-01
- BOM items can now accept "variant stock" to be assigned against them
- Many slight API tweaks were needed to get this to work properly!
"""
INVENTREE_API_VERSION = 3
INVENTREE_API_VERSION = 4
def inventreeInstanceName():

View File

@ -0,0 +1,20 @@
# Generated by Django 3.2 on 2021-06-01 05:23
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('part', '0066_bomitem_allow_variants'),
('build', '0027_auto_20210404_2016'),
]
operations = [
migrations.AddField(
model_name='builditem',
name='bom_item',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='allocate_build_items', to='part.bomitem'),
),
]

View File

@ -0,0 +1,62 @@
# Generated by Django 3.2 on 2021-06-01 05:25
from django.db import migrations
def assign_bom_items(apps, schema_editor):
"""
Run through existing BuildItem objects,
and assign a matching BomItem
"""
BuildItem = apps.get_model('build', 'builditem')
BomItem = apps.get_model('part', 'bomitem')
Part = apps.get_model('part', 'part')
print("Assigning BomItems to existing BuildItem objects")
count_valid = 0
count_total = 0
for build_item in BuildItem.objects.all():
# Try to find a BomItem which matches the BuildItem
# Note: Before this migration, variant stock assignment was not allowed,
# so BomItem lookup should be pretty easy
count_total += 1
try:
bom_item = BomItem.objects.get(
part__id=build_item.build.part.pk,
sub_part__id=build_item.stock_item.part.pk,
)
build_item.bom_item = bom_item
build_item.save()
count_valid += 1
except BomItem.DoesNotExist:
pass
print(f"Assigned BomItem for {count_valid}/{count_total} entries")
def unassign_bom_items(apps, schema_editor):
"""
Reverse migration does not do anything.
Function here to preserve ability to reverse migration
"""
pass
class Migration(migrations.Migration):
dependencies = [
('build', '0028_builditem_bom_item'),
]
operations = [
migrations.RunPython(assign_bom_items, reverse_code=unassign_bom_items),
]

View File

@ -30,6 +30,7 @@ from InvenTree.models import InvenTreeAttachment
import common.models
import InvenTree.fields
import InvenTree.helpers
from stock import models as StockModels
from part import models as PartModels
@ -880,9 +881,12 @@ class Build(MPTTModel):
output - Build output (StockItem).
"""
# Remember, if 'variant' stock is allowed to be allocated, it becomes more complicated!
variants = part.get_descendants(include_self=True)
allocations = BuildItem.objects.filter(
build=self,
stock_item__part=part,
stock_item__part__pk__in=[p.pk for p in variants],
install_into=output,
)
@ -1036,7 +1040,19 @@ class Build(MPTTModel):
StockModels.StockItem.IN_STOCK_FILTER
)
items = items.filter(part=part)
# Check if variants are allowed for this part
try:
bom_item = PartModels.BomItem.objects.get(part=self.part, sub_part=part)
allow_part_variants = bom_item.allow_variants
except PartModels.BomItem.DoesNotExist:
allow_part_variants = False
if allow_part_variants:
parts = part.get_descendants(include_self=True)
items = items.filter(part__pk__in=[p.pk for p in parts])
else:
items = items.filter(part=part)
# Exclude any items which have already been allocated
allocated = BuildItem.objects.filter(
@ -1160,10 +1176,6 @@ class BuildItem(models.Model):
if self.stock_item.part and self.stock_item.part.trackable and not self.install_into:
raise ValidationError(_('Build item must specify a build output, as master part is marked as trackable'))
# Allocated part must be in the BOM for the master part
if self.stock_item.part not in self.build.part.getRequiredParts(recursive=False):
errors['stock_item'] = [_("Selected stock item not found in BOM for part '{p}'").format(p=self.build.part.full_name)]
# 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(
@ -1189,6 +1201,61 @@ class BuildItem(models.Model):
if len(errors) > 0:
raise ValidationError(errors)
"""
Attempt to find the "BomItem" which links this BuildItem to the build.
- If a BomItem is already set, and it is valid, then we are ok!
"""
bom_item_valid = False
if self.bom_item:
"""
A BomItem object has already been assigned. This is valid if:
a) It points to the same "part" as the referened 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
is a variant of the sub_part referenced by the BomItem
"""
if self.build and self.build.part == self.bom_item.part:
# Check that the sub_part points to the stock_item (either directly or via a variant)
if self.bom_item.sub_part == self.stock_item.part:
bom_item_valid = True
elif self.bom_item.allow_variants and self.stock_item.part in self.bom_item.sub_part.get_descendants(include_self=False):
bom_item_valid = True
# If the existing BomItem is *not* valid, try to find a match
if not bom_item_valid:
if self.build and self.stock_item:
ancestors = self.stock_item.part.get_ancestors(include_self=True, ascending=True)
for idx, ancestor in enumerate(ancestors):
try:
bom_item = PartModels.BomItem.objects.get(part=self.build.part, sub_part=ancestor)
except PartModels.BomItem.DoesNotExist:
continue
# A matching BOM item has been found!
if idx == 0 or bom_item.allow_variants:
bom_item_valid = True
self.bom_item = bom_item
break
# BomItem did not exist or could not be validated.
# Search for a new one
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)
})
@transaction.atomic
def complete_allocation(self, user):
"""
@ -1217,6 +1284,18 @@ class BuildItem(models.Model):
# Simply remove the items from stock
item.take_stock(self.quantity, user)
def getStockItemThumbnail(self):
"""
Return qualified URL for part thumbnail image
"""
if self.stock_item and self.stock_item.part:
return InvenTree.helpers.getMediaUrl(self.stock_item.part.image.thumbnail.url)
elif self.bom_item and self.stock_item.sub_part:
return InvenTree.helpers.getMediaUrl(self.bom_item.sub_part.image.thumbnail.url)
else:
return InvenTree.helpers.getBlankThumbnail()
build = models.ForeignKey(
Build,
on_delete=models.CASCADE,
@ -1225,6 +1304,15 @@ class BuildItem(models.Model):
help_text=_('Build to allocate parts')
)
# Internal model which links part <-> sub_part
# We need to track this separately, to allow for "variant' stock
bom_item = models.ForeignKey(
PartModels.BomItem,
on_delete=models.CASCADE,
related_name='allocate_build_items',
blank=True, null=True,
)
stock_item = models.ForeignKey(
'stock.StockItem',
on_delete=models.CASCADE,

View File

@ -97,9 +97,10 @@ class BuildSerializer(InvenTreeModelSerializer):
class BuildItemSerializer(InvenTreeModelSerializer):
""" Serializes a BuildItem object """
bom_part = serializers.IntegerField(source='bom_item.sub_part.pk', read_only=True)
part = serializers.IntegerField(source='stock_item.part.pk', read_only=True)
part_name = serializers.CharField(source='stock_item.part.full_name', read_only=True)
part_image = serializers.CharField(source='stock_item.part.image', read_only=True)
part_thumb = serializers.CharField(source='getStockItemThumbnail', read_only=True)
stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True)
quantity = serializers.FloatField()
@ -108,11 +109,12 @@ class BuildItemSerializer(InvenTreeModelSerializer):
model = BuildItem
fields = [
'pk',
'bom_part',
'build',
'install_into',
'part',
'part_name',
'part_image',
'part_thumb',
'stock_item',
'stock_item_detail',
'quantity'

View File

@ -821,6 +821,14 @@ class BomList(generics.ListCreateAPIView):
queryset = queryset.filter(inherited=inherited)
# Filter by "allow_variants"
variants = params.get("allow_variants", None)
if variants is not None:
variants = str2bool(variants)
queryset = queryset.filter(allow_variants=variants)
# Filter by part?
part = params.get('part', None)

View File

@ -352,6 +352,7 @@ class EditBomItemForm(HelperForm):
'reference',
'overage',
'note',
'allow_variants',
'inherited',
'optional',
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2 on 2021-06-01 03:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0065_auto_20210505_2144'),
]
operations = [
migrations.AddField(
model_name='bomitem',
name='allow_variants',
field=models.BooleanField(default=False, help_text='Stock items for variant parts can be used for this BOM item', verbose_name='Allow Variants'),
),
]

View File

@ -2240,6 +2240,7 @@ class BomItem(models.Model):
note: Note field for this BOM item
checksum: Validation checksum for the particular BOM line item
inherited: This BomItem can be inherited by the BOMs of variant parts
allow_variants: Stock for part variants can be substituted for this BomItem
"""
def save(self, *args, **kwargs):
@ -2288,6 +2289,12 @@ class BomItem(models.Model):
help_text=_('This BOM item is inherited by BOMs for variant parts'),
)
allow_variants = models.BooleanField(
default=False,
verbose_name=_('Allow Variants'),
help_text=_('Stock items for variant parts can be used for this BOM item')
)
def get_item_hash(self):
""" Calculate the checksum hash of this BOM line item:

View File

@ -453,6 +453,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
class Meta:
model = BomItem
fields = [
'allow_variants',
'inherited',
'note',
'optional',
@ -460,16 +461,16 @@ class BomItemSerializer(InvenTreeModelSerializer):
'pk',
'part',
'part_detail',
'purchase_price_avg',
'purchase_price_max',
'purchase_price_min',
'purchase_price_range',
'quantity',
'reference',
'sub_part',
'sub_part_detail',
# 'price_range',
'validated',
'purchase_price_min',
'purchase_price_max',
'purchase_price_avg',
'purchase_price_range',
]

View File

@ -285,11 +285,18 @@ function loadBomTable(table, options) {
title: '{% trans "Optional" %}',
searchable: false,
formatter: function(value) {
if (value == '1') return '{% trans "true" %}';
if (value == '0') return '{% trans "false" %}';
return yesNoLabel(value);
}
});
cols.push({
field: 'allow_variants',
title: '{% trans "Allow Variants" %}',
formatter: function(value) {
return yesNoLabel(value);
}
})
cols.push({
field: 'inherited',
title: '{% trans "Inherited" %}',
@ -297,7 +304,7 @@ function loadBomTable(table, options) {
formatter: function(value, row, index, field) {
// This BOM item *is* inheritable, but is defined for this BOM
if (!row.inherited) {
return "-";
return yesNoLabel(false);
} else if (row.part == options.parent_id) {
return '{% trans "Inherited" %}';
} else {

View File

@ -372,7 +372,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
data.forEach(function(item) {
// Group BuildItem objects by part
var part = item.part;
var part = item.bom_part || item.part;
var key = parseInt(part);
if (!(key in allocations)) {
@ -461,6 +461,16 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
data: row.allocations,
showHeader: true,
columns: [
{
field: 'part',
title: '{% trans "Part" %}',
formatter: function(value, row) {
var html = imageHoverIcon(row.part_thumb);
html += renderLink(row.part_name, `/part/${value}/`);
return html;
}
},
{
width: '50%',
field: 'quantity',

View File

@ -5,6 +5,14 @@
* Requires api.js to be loaded first
*/
function yesNoLabel(value) {
if (value) {
return `<span class='label label-green'>{% trans "YES" %}</span>`;
} else {
return `<span class='label label-yellow'>{% trans "NO" %}</span>`;
}
}
function toggleStar(options) {
/* Toggle the 'starred' status of a part.
* Performs AJAX queries and updates the display on the button.
@ -662,16 +670,6 @@ function loadPartCategoryTable(table, options) {
});
}
function yesNoLabel(value) {
if (value) {
return `<span class='label label-green'>{% trans "YES" %}</span>`;
} else {
return `<span class='label label-yellow'>{% trans "NO" %}</span>`;
}
}
function loadPartTestTemplateTable(table, options) {
/*
* Load PartTestTemplate table.

View File

@ -81,15 +81,17 @@ function loadStockTestResultsTable(table, options) {
return html;
}
var parent_node = "parent node";
table.inventreeTable({
url: "{% url 'api-part-test-template-list' %}",
method: 'get',
name: 'testresult',
treeEnable: true,
rootParentId: options.stock_item,
rootParentId: parent_node,
parentIdField: 'parent',
idField: 'pk',
uniqueId: 'pk',
uniqueId: 'key',
treeShowField: 'test_name',
formatNoMatches: function() {
return '{% trans "No test results found" %}';
@ -190,7 +192,7 @@ function loadStockTestResultsTable(table, options) {
match = true;
if (row.result == null) {
item.parent = options.stock_item;
item.parent = parent_node;
tableData[index] = item;
override = true;
} else {
@ -202,7 +204,7 @@ function loadStockTestResultsTable(table, options) {
// No match could be found
if (!match) {
item.test_name = item.test;
item.parent = options.stock_item;
item.parent = parent_node;
}
if (!override) {
@ -1302,33 +1304,6 @@ function createNewStockItem(options) {
function loadInstalledInTable(table, options) {
/*
* Display a table showing the stock items which are installed in this stock item.
* This is a multi-level tree table, where the "top level" items are Part objects,
* and the children of each top-level item are the associated installed stock items.
*
* The process for retrieving data and displaying the table is as follows:
*
* A) Get BOM data for the stock item
* - It is assumed that the stock item will be for an assembly
* (otherwise why are we installing stuff anyway?)
* - Request BOM items for stock_item.part (and only for trackable sub items)
*
* B) Add parts to table
* - Create rows for each trackable sub-part in the table
*
* C) Gather installed stock item data
* - Get the list of installed stock items via the API
* - If the Part reference is already in the table, add the sub-item as a child
* - If this is a stock item for a *new* part, request that part from the API,
* and add that part as a new row, then add the stock item as a child of that part
*
* D) Enjoy!
*
*
* And the options object contains the following things:
*
* - stock_item: The PK of the master stock_item object
* - part: The PK of the Part reference of the stock_item object
* - quantity: The quantity of the stock item
*/
function updateCallbacks() {
@ -1351,246 +1326,88 @@ function loadInstalledInTable(table, options) {
});
}
table.inventreeTable(
{
url: "{% url 'api-bom-list' %}",
queryParams: {
part: options.part,
sub_part_trackable: true,
sub_part_detail: true,
},
showColumns: false,
name: 'installed-in',
detailView: true,
detailViewByClick: true,
detailFilter: function(index, row) {
return row.installed_count && row.installed_count > 0;
},
detailFormatter: function(index, row, element) {
var subTableId = `installed-table-${row.sub_part}`;
table.inventreeTable({
url: "{% url 'api-stock-list' %}",
queryParams: {
installed_in: options.stock_item,
part_detail: true,
},
formatNoMatches: function() {
return '{% trans "No installed items" %}';
},
columns: [
{
field: 'part',
title: '{% trans "Part" %}',
formatter: function(value, row) {
var html = '';
var html = `<div class='sub-table'><table class='table table-condensed table-striped' id='${subTableId}'></table></div>`;
html += imageHoverIcon(row.part_detail.thumbnail);
html += renderLink(row.part_detail.full_name, `/stock/item/${row.pk}/`);
element.html(html);
var subTable = $(`#${subTableId}`);
// Display a "sub table" showing all the linked stock items
subTable.bootstrapTable({
data: row.installed_items,
showHeader: true,
columns: [
{
field: 'item',
title: '{% trans "Stock Item" %}',
formatter: function(value, subrow, index, field) {
var pk = subrow.pk;
var html = '';
if (subrow.serial && subrow.quantity == 1) {
html += `{% trans "Serial" %}: ${subrow.serial}`;
} else {
html += `{% trans "Quantity" %}: ${subrow.quantity}`;
}
return renderLink(html, `/stock/item/${subrow.pk}/`);
},
},
{
field: 'status',
title: '{% trans "Status" %}',
formatter: function(value, subrow, index, field) {
return stockStatusDisplay(value);
}
},
{
field: 'batch',
title: '{% trans "Batch" %}',
},
{
field: 'actions',
title: '',
formatter: function(value, subrow, index) {
var pk = subrow.pk;
var html = '';
// Add some buttons yo!
html += `<div class='btn-group float-right' role='group'>`;
html += makeIconButton('fa-unlink', 'button-uninstall', pk, "{% trans 'Uninstall stock item' %}");
html += `</div>`;
return html;
}
}
],
onPostBody: function() {
// Setup button callbacks
subTable.find('.button-uninstall').click(function() {
var pk = $(this).attr('pk');
launchModalForm(
"{% url 'stock-item-uninstall' %}",
{
data: {
'items[]': [pk],
},
success: function() {
// Refresh entire table!
table.bootstrapTable('refresh');
}
}
);
});
}
});
},
columns: [
{
checkbox: true,
title: '{% trans "Select" %}',
searchable: false,
switchable: false,
},
{
field: 'pk',
title: 'ID',
visible: false,
switchable: false,
},
{
field: 'part',
title: '{% trans "Part" %}',
sortable: true,
formatter: function(value, row, index, field) {
var url = `/part/${row.sub_part}/`;
var thumb = row.sub_part_detail.thumbnail;
var name = row.sub_part_detail.full_name;
html = imageHoverIcon(thumb) + renderLink(name, url);
if (row.not_in_bom) {
html = `<i>${html}</i>`
}
return html;
}
},
{
field: 'installed',
title: '{% trans "Installed" %}',
sortable: false,
formatter: function(value, row, index, field) {
// Construct a progress showing how many items have been installed
var installed = row.installed_count || 0;
var required = row.quantity || 0;
required *= options.quantity;
var progress = makeProgressBar(installed, required, {
id: row.sub_part.pk,
});
return progress;
}
},
{
field: 'actions',
switchable: false,
formatter: function(value, row) {
var pk = row.sub_part;
var html = `<div class='btn-group float-right' role='group'>`;
html += makeIconButton('fa-link', 'button-install', pk, '{% trans "Install item" %}');
html += `</div>`;
return html;
}
return html;
}
],
onLoadSuccess: function() {
// Grab a list of parts which are actually installed in this stock item
},
{
field: 'quantity',
title: '{% trans "Quantity" %}',
formatter: function(value, row) {
inventreeGet(
"{% url 'api-stock-list' %}",
var html = '';
if (row.serial && row.quantity == 1) {
html += `{% trans "Serial" %}: ${row.serial}`;
} else {
html += `${row.quantity}`;
}
return renderLink(html, `/stock/item/${row.pk}/`);
}
},
{
field: 'status',
title: '{% trans "Status" %}',
formatter: function(value, row) {
return stockStatusDisplay(value);
}
},
{
field: 'batch',
title: '{% trans "Batch" %}',
},
{
field: 'buttons',
title: '',
switchable: false,
formatter: function(value, row) {
var pk = row.pk;
var html = '';
html += `<div class='btn-group float-right' role='group'>`;
html += makeIconButton('fa-unlink', 'button-uninstall', pk, '{% trans "Uninstall Stock Item" %}');
html += `</div>`;
return html;
}
}
],
onPostBody: function() {
// Assign callbacks to the buttons
table.find('.button-uninstall').click(function() {
var pk = $(this).attr('pk');
launchModalForm(
'{% url "stock-item-uninstall" %}',
{
installed_in: options.stock_item,
part_detail: true,
},
{
success: function(stock_items) {
var table_data = table.bootstrapTable('getData');
stock_items.forEach(function(item) {
var match = false;
for (var idx = 0; idx < table_data.length; idx++) {
var row = table_data[idx];
// Check each row in the table to see if this stock item matches
table_data.forEach(function(row) {
// Match on "sub_part"
if (row.sub_part == item.part) {
// First time?
if (row.installed_count == null) {
row.installed_count = 0;
row.installed_items = [];
}
row.installed_count += item.quantity;
row.installed_items.push(item);
// Push the row back into the table
table.bootstrapTable('updateRow', idx, row, true);
match = true;
}
});
if (match) {
break;
}
}
if (!match) {
// The stock item did *not* match any items in the BOM!
// Add a new row to the table...
// Contruct a new "row" to add to the table
var new_row = {
sub_part: item.part,
sub_part_detail: item.part_detail,
not_in_bom: true,
installed_count: item.quantity,
installed_items: [item],
};
table.bootstrapTable('append', [new_row]);
}
});
// Update button callback links
updateCallbacks();
data: {
'items[]': pk,
},
success: function() {
table.bootstrapTable('refresh');
}
}
);
updateCallbacks();
},
)
});
}
);
});
}

View File

@ -49,6 +49,10 @@ function getAvailableTableFilters(tableKey) {
inherited: {
type: 'bool',
title: '{% trans "Inherited" %}',
},
allow_variants: {
type: 'bool',
title: '{% trans "Allow Variant Stock" %}',
}
};
}