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:
|
v3 -> 2021-05-22:
|
||||||
- The updated StockItem "history tracking" now uses a different interface
|
- 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():
|
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 common.models
|
||||||
|
|
||||||
import InvenTree.fields
|
import InvenTree.fields
|
||||||
|
import InvenTree.helpers
|
||||||
|
|
||||||
from stock import models as StockModels
|
from stock import models as StockModels
|
||||||
from part import models as PartModels
|
from part import models as PartModels
|
||||||
@ -880,9 +881,12 @@ class Build(MPTTModel):
|
|||||||
output - Build output (StockItem).
|
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(
|
allocations = BuildItem.objects.filter(
|
||||||
build=self,
|
build=self,
|
||||||
stock_item__part=part,
|
stock_item__part__pk__in=[p.pk for p in variants],
|
||||||
install_into=output,
|
install_into=output,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1036,7 +1040,19 @@ class Build(MPTTModel):
|
|||||||
StockModels.StockItem.IN_STOCK_FILTER
|
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
|
# Exclude any items which have already been allocated
|
||||||
allocated = BuildItem.objects.filter(
|
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:
|
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'))
|
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
|
# Allocated quantity cannot exceed available stock quantity
|
||||||
if self.quantity > self.stock_item.quantity:
|
if self.quantity > self.stock_item.quantity:
|
||||||
errors['quantity'] = [_("Allocated quantity ({n}) must not exceed available quantity ({q})").format(
|
errors['quantity'] = [_("Allocated quantity ({n}) must not exceed available quantity ({q})").format(
|
||||||
@ -1189,6 +1201,61 @@ class BuildItem(models.Model):
|
|||||||
if len(errors) > 0:
|
if len(errors) > 0:
|
||||||
raise ValidationError(errors)
|
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
|
@transaction.atomic
|
||||||
def complete_allocation(self, user):
|
def complete_allocation(self, user):
|
||||||
"""
|
"""
|
||||||
@ -1217,6 +1284,18 @@ class BuildItem(models.Model):
|
|||||||
# Simply remove the items from stock
|
# Simply remove the items from stock
|
||||||
item.take_stock(self.quantity, user)
|
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 = models.ForeignKey(
|
||||||
Build,
|
Build,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
@ -1225,6 +1304,15 @@ class BuildItem(models.Model):
|
|||||||
help_text=_('Build to allocate parts')
|
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_item = models.ForeignKey(
|
||||||
'stock.StockItem',
|
'stock.StockItem',
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
|
@ -97,9 +97,10 @@ class BuildSerializer(InvenTreeModelSerializer):
|
|||||||
class BuildItemSerializer(InvenTreeModelSerializer):
|
class BuildItemSerializer(InvenTreeModelSerializer):
|
||||||
""" Serializes a BuildItem object """
|
""" 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 = serializers.IntegerField(source='stock_item.part.pk', read_only=True)
|
||||||
part_name = serializers.CharField(source='stock_item.part.full_name', 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)
|
stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True)
|
||||||
|
|
||||||
quantity = serializers.FloatField()
|
quantity = serializers.FloatField()
|
||||||
@ -108,11 +109,12 @@ class BuildItemSerializer(InvenTreeModelSerializer):
|
|||||||
model = BuildItem
|
model = BuildItem
|
||||||
fields = [
|
fields = [
|
||||||
'pk',
|
'pk',
|
||||||
|
'bom_part',
|
||||||
'build',
|
'build',
|
||||||
'install_into',
|
'install_into',
|
||||||
'part',
|
'part',
|
||||||
'part_name',
|
'part_name',
|
||||||
'part_image',
|
'part_thumb',
|
||||||
'stock_item',
|
'stock_item',
|
||||||
'stock_item_detail',
|
'stock_item_detail',
|
||||||
'quantity'
|
'quantity'
|
||||||
|
@ -821,6 +821,14 @@ class BomList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
queryset = queryset.filter(inherited=inherited)
|
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?
|
# Filter by part?
|
||||||
part = params.get('part', None)
|
part = params.get('part', None)
|
||||||
|
|
||||||
|
@ -352,6 +352,7 @@ class EditBomItemForm(HelperForm):
|
|||||||
'reference',
|
'reference',
|
||||||
'overage',
|
'overage',
|
||||||
'note',
|
'note',
|
||||||
|
'allow_variants',
|
||||||
'inherited',
|
'inherited',
|
||||||
'optional',
|
'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
|
note: Note field for this BOM item
|
||||||
checksum: Validation checksum for the particular BOM line item
|
checksum: Validation checksum for the particular BOM line item
|
||||||
inherited: This BomItem can be inherited by the BOMs of variant parts
|
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):
|
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'),
|
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):
|
def get_item_hash(self):
|
||||||
""" Calculate the checksum hash of this BOM line item:
|
""" Calculate the checksum hash of this BOM line item:
|
||||||
|
|
||||||
|
@ -453,6 +453,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = BomItem
|
model = BomItem
|
||||||
fields = [
|
fields = [
|
||||||
|
'allow_variants',
|
||||||
'inherited',
|
'inherited',
|
||||||
'note',
|
'note',
|
||||||
'optional',
|
'optional',
|
||||||
@ -460,16 +461,16 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
|||||||
'pk',
|
'pk',
|
||||||
'part',
|
'part',
|
||||||
'part_detail',
|
'part_detail',
|
||||||
|
'purchase_price_avg',
|
||||||
|
'purchase_price_max',
|
||||||
|
'purchase_price_min',
|
||||||
|
'purchase_price_range',
|
||||||
'quantity',
|
'quantity',
|
||||||
'reference',
|
'reference',
|
||||||
'sub_part',
|
'sub_part',
|
||||||
'sub_part_detail',
|
'sub_part_detail',
|
||||||
# 'price_range',
|
# 'price_range',
|
||||||
'validated',
|
'validated',
|
||||||
'purchase_price_min',
|
|
||||||
'purchase_price_max',
|
|
||||||
'purchase_price_avg',
|
|
||||||
'purchase_price_range',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -285,11 +285,18 @@ function loadBomTable(table, options) {
|
|||||||
title: '{% trans "Optional" %}',
|
title: '{% trans "Optional" %}',
|
||||||
searchable: false,
|
searchable: false,
|
||||||
formatter: function(value) {
|
formatter: function(value) {
|
||||||
if (value == '1') return '{% trans "true" %}';
|
return yesNoLabel(value);
|
||||||
if (value == '0') return '{% trans "false" %}';
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
cols.push({
|
||||||
|
field: 'allow_variants',
|
||||||
|
title: '{% trans "Allow Variants" %}',
|
||||||
|
formatter: function(value) {
|
||||||
|
return yesNoLabel(value);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
cols.push({
|
cols.push({
|
||||||
field: 'inherited',
|
field: 'inherited',
|
||||||
title: '{% trans "Inherited" %}',
|
title: '{% trans "Inherited" %}',
|
||||||
@ -297,7 +304,7 @@ function loadBomTable(table, options) {
|
|||||||
formatter: function(value, row, index, field) {
|
formatter: function(value, row, index, field) {
|
||||||
// This BOM item *is* inheritable, but is defined for this BOM
|
// This BOM item *is* inheritable, but is defined for this BOM
|
||||||
if (!row.inherited) {
|
if (!row.inherited) {
|
||||||
return "-";
|
return yesNoLabel(false);
|
||||||
} else if (row.part == options.parent_id) {
|
} else if (row.part == options.parent_id) {
|
||||||
return '{% trans "Inherited" %}';
|
return '{% trans "Inherited" %}';
|
||||||
} else {
|
} else {
|
||||||
|
@ -372,7 +372,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
|||||||
data.forEach(function(item) {
|
data.forEach(function(item) {
|
||||||
|
|
||||||
// Group BuildItem objects by part
|
// Group BuildItem objects by part
|
||||||
var part = item.part;
|
var part = item.bom_part || item.part;
|
||||||
var key = parseInt(part);
|
var key = parseInt(part);
|
||||||
|
|
||||||
if (!(key in allocations)) {
|
if (!(key in allocations)) {
|
||||||
@ -461,6 +461,16 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
|||||||
data: row.allocations,
|
data: row.allocations,
|
||||||
showHeader: true,
|
showHeader: true,
|
||||||
columns: [
|
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%',
|
width: '50%',
|
||||||
field: 'quantity',
|
field: 'quantity',
|
||||||
|
@ -5,6 +5,14 @@
|
|||||||
* Requires api.js to be loaded first
|
* 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) {
|
function toggleStar(options) {
|
||||||
/* Toggle the 'starred' status of a part.
|
/* Toggle the 'starred' status of a part.
|
||||||
* Performs AJAX queries and updates the display on the button.
|
* 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) {
|
function loadPartTestTemplateTable(table, options) {
|
||||||
/*
|
/*
|
||||||
* Load PartTestTemplate table.
|
* Load PartTestTemplate table.
|
||||||
|
@ -81,15 +81,17 @@ function loadStockTestResultsTable(table, options) {
|
|||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var parent_node = "parent node";
|
||||||
|
|
||||||
table.inventreeTable({
|
table.inventreeTable({
|
||||||
url: "{% url 'api-part-test-template-list' %}",
|
url: "{% url 'api-part-test-template-list' %}",
|
||||||
method: 'get',
|
method: 'get',
|
||||||
name: 'testresult',
|
name: 'testresult',
|
||||||
treeEnable: true,
|
treeEnable: true,
|
||||||
rootParentId: options.stock_item,
|
rootParentId: parent_node,
|
||||||
parentIdField: 'parent',
|
parentIdField: 'parent',
|
||||||
idField: 'pk',
|
idField: 'pk',
|
||||||
uniqueId: 'pk',
|
uniqueId: 'key',
|
||||||
treeShowField: 'test_name',
|
treeShowField: 'test_name',
|
||||||
formatNoMatches: function() {
|
formatNoMatches: function() {
|
||||||
return '{% trans "No test results found" %}';
|
return '{% trans "No test results found" %}';
|
||||||
@ -190,7 +192,7 @@ function loadStockTestResultsTable(table, options) {
|
|||||||
match = true;
|
match = true;
|
||||||
|
|
||||||
if (row.result == null) {
|
if (row.result == null) {
|
||||||
item.parent = options.stock_item;
|
item.parent = parent_node;
|
||||||
tableData[index] = item;
|
tableData[index] = item;
|
||||||
override = true;
|
override = true;
|
||||||
} else {
|
} else {
|
||||||
@ -202,7 +204,7 @@ function loadStockTestResultsTable(table, options) {
|
|||||||
// No match could be found
|
// No match could be found
|
||||||
if (!match) {
|
if (!match) {
|
||||||
item.test_name = item.test;
|
item.test_name = item.test;
|
||||||
item.parent = options.stock_item;
|
item.parent = parent_node;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!override) {
|
if (!override) {
|
||||||
@ -1302,33 +1304,6 @@ function createNewStockItem(options) {
|
|||||||
function loadInstalledInTable(table, options) {
|
function loadInstalledInTable(table, options) {
|
||||||
/*
|
/*
|
||||||
* Display a table showing the stock items which are installed in this stock item.
|
* 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() {
|
function updateCallbacks() {
|
||||||
@ -1351,246 +1326,88 @@ function loadInstalledInTable(table, options) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
table.inventreeTable(
|
table.inventreeTable({
|
||||||
{
|
url: "{% url 'api-stock-list' %}",
|
||||||
url: "{% url 'api-bom-list' %}",
|
queryParams: {
|
||||||
queryParams: {
|
installed_in: options.stock_item,
|
||||||
part: options.part,
|
part_detail: true,
|
||||||
sub_part_trackable: true,
|
},
|
||||||
sub_part_detail: true,
|
formatNoMatches: function() {
|
||||||
},
|
return '{% trans "No installed items" %}';
|
||||||
showColumns: false,
|
},
|
||||||
name: 'installed-in',
|
columns: [
|
||||||
detailView: true,
|
{
|
||||||
detailViewByClick: true,
|
field: 'part',
|
||||||
detailFilter: function(index, row) {
|
title: '{% trans "Part" %}',
|
||||||
return row.installed_count && row.installed_count > 0;
|
formatter: function(value, row) {
|
||||||
},
|
var html = '';
|
||||||
detailFormatter: function(index, row, element) {
|
|
||||||
var subTableId = `installed-table-${row.sub_part}`;
|
|
||||||
|
|
||||||
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);
|
return 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
},
|
||||||
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(
|
var html = '';
|
||||||
"{% url 'api-stock-list' %}",
|
|
||||||
|
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,
|
data: {
|
||||||
part_detail: true,
|
'items[]': pk,
|
||||||
},
|
},
|
||||||
{
|
success: function() {
|
||||||
success: function(stock_items) {
|
table.bootstrapTable('refresh');
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
|
});
|
||||||
updateCallbacks();
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
});
|
||||||
}
|
}
|
@ -49,6 +49,10 @@ function getAvailableTableFilters(tableKey) {
|
|||||||
inherited: {
|
inherited: {
|
||||||
type: 'bool',
|
type: 'bool',
|
||||||
title: '{% trans "Inherited" %}',
|
title: '{% trans "Inherited" %}',
|
||||||
|
},
|
||||||
|
allow_variants: {
|
||||||
|
type: 'bool',
|
||||||
|
title: '{% trans "Allow Variant Stock" %}',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user