mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
commit
1f6fc5eb1a
@ -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():
|
||||
|
20
InvenTree/build/migrations/0028_builditem_bom_item.py
Normal file
20
InvenTree/build/migrations/0028_builditem_bom_item.py
Normal 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'),
|
||||
),
|
||||
]
|
62
InvenTree/build/migrations/0029_auto_20210601_1525.py
Normal file
62
InvenTree/build/migrations/0029_auto_20210601_1525.py
Normal 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),
|
||||
]
|
@ -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,
|
||||
|
@ -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'
|
||||
|
@ -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)
|
||||
|
||||
|
@ -352,6 +352,7 @@ class EditBomItemForm(HelperForm):
|
||||
'reference',
|
||||
'overage',
|
||||
'note',
|
||||
'allow_variants',
|
||||
'inherited',
|
||||
'optional',
|
||||
]
|
||||
|
18
InvenTree/part/migrations/0066_bomitem_allow_variants.py
Normal file
18
InvenTree/part/migrations/0066_bomitem_allow_variants.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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:
|
||||
|
||||
|
@ -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',
|
||||
]
|
||||
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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',
|
||||
|
@ -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.
|
||||
|
@ -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();
|
||||
},
|
||||
)
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
@ -49,6 +49,10 @@ function getAvailableTableFilters(tableKey) {
|
||||
inherited: {
|
||||
type: 'bool',
|
||||
title: '{% trans "Inherited" %}',
|
||||
},
|
||||
allow_variants: {
|
||||
type: 'bool',
|
||||
title: '{% trans "Allow Variant Stock" %}',
|
||||
}
|
||||
};
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user