mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-15 11:35:41 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -18,7 +18,7 @@ body:
|
||||
attributes:
|
||||
label: "Problem statement"
|
||||
description: "A clear and concise description of what the solved problem or feature request is."
|
||||
placeholder: "I am always struggeling with ..."
|
||||
placeholder: "I am always struggling with ..."
|
||||
- type: textarea
|
||||
id: solution
|
||||
validations:
|
||||
|
@ -4,6 +4,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
@ -24,6 +25,7 @@ import InvenTree.format
|
||||
import InvenTree.helpers
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree.fields import InvenTreeURLField
|
||||
from InvenTree.sanitizer import sanitize_svg
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
@ -383,8 +385,16 @@ class InvenTreeAttachment(models.Model):
|
||||
'link': _('Missing external link'),
|
||||
})
|
||||
|
||||
if self.attachment.name.lower().endswith('.svg'):
|
||||
self.attachment.file.file = self.clean_svg(self.attachment)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def clean_svg(self, field):
|
||||
"""Sanitize SVG file before saving."""
|
||||
cleaned = sanitize_svg(field.file.read())
|
||||
return BytesIO(bytes(cleaned, 'utf8'))
|
||||
|
||||
def __str__(self):
|
||||
"""Human name for attachment."""
|
||||
if self.attachment is not None:
|
||||
|
67
InvenTree/InvenTree/sanitizer.py
Normal file
67
InvenTree/InvenTree/sanitizer.py
Normal file
@ -0,0 +1,67 @@
|
||||
"""Functions to sanitize user input files."""
|
||||
from bleach import clean
|
||||
from bleach.css_sanitizer import CSSSanitizer
|
||||
|
||||
ALLOWED_ELEMENTS_SVG = [
|
||||
'a', 'animate', 'animateColor', 'animateMotion',
|
||||
'animateTransform', 'circle', 'defs', 'desc', 'ellipse', 'font-face',
|
||||
'font-face-name', 'font-face-src', 'g', 'glyph', 'hkern',
|
||||
'linearGradient', 'line', 'marker', 'metadata', 'missing-glyph',
|
||||
'mpath', 'path', 'polygon', 'polyline', 'radialGradient', 'rect',
|
||||
'set', 'stop', 'svg', 'switch', 'text', 'title', 'tspan', 'use'
|
||||
]
|
||||
|
||||
ALLOWED_ATTRIBUTES_SVG = [
|
||||
'accent-height', 'accumulate', 'additive', 'alphabetic',
|
||||
'arabic-form', 'ascent', 'attributeName', 'attributeType',
|
||||
'baseProfile', 'bbox', 'begin', 'by', 'calcMode', 'cap-height',
|
||||
'class', 'color', 'color-rendering', 'content', 'cx', 'cy', 'd', 'dx',
|
||||
'dy', 'descent', 'display', 'dur', 'end', 'fill', 'fill-opacity',
|
||||
'fill-rule', 'font-family', 'font-size', 'font-stretch', 'font-style',
|
||||
'font-variant', 'font-weight', 'from', 'fx', 'fy', 'g1', 'g2',
|
||||
'glyph-name', 'gradientUnits', 'hanging', 'height', 'horiz-adv-x',
|
||||
'horiz-origin-x', 'id', 'ideographic', 'k', 'keyPoints',
|
||||
'keySplines', 'keyTimes', 'lang', 'marker-end', 'marker-mid',
|
||||
'marker-start', 'markerHeight', 'markerUnits', 'markerWidth',
|
||||
'mathematical', 'max', 'min', 'name', 'offset', 'opacity', 'orient',
|
||||
'origin', 'overline-position', 'overline-thickness', 'panose-1',
|
||||
'path', 'pathLength', 'points', 'preserveAspectRatio', 'r', 'refX',
|
||||
'refY', 'repeatCount', 'repeatDur', 'requiredExtensions',
|
||||
'requiredFeatures', 'restart', 'rotate', 'rx', 'ry', 'slope',
|
||||
'stemh', 'stemv', 'stop-color', 'stop-opacity',
|
||||
'strikethrough-position', 'strikethrough-thickness', 'stroke',
|
||||
'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap',
|
||||
'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity',
|
||||
'stroke-width', 'systemLanguage', 'target', 'text-anchor', 'to',
|
||||
'transform', 'type', 'u1', 'u2', 'underline-position',
|
||||
'underline-thickness', 'unicode', 'unicode-range', 'units-per-em',
|
||||
'values', 'version', 'viewBox', 'visibility', 'width', 'widths', 'x',
|
||||
'x-height', 'x1', 'x2', 'xlink:actuate', 'xlink:arcrole',
|
||||
'xlink:href', 'xlink:role', 'xlink:show', 'xlink:title',
|
||||
'xlink:type', 'xml:base', 'xml:lang', 'xml:space', 'xmlns',
|
||||
'xmlns:xlink', 'y', 'y1', 'y2', 'zoomAndPan', 'style'
|
||||
]
|
||||
|
||||
|
||||
def sanitize_svg(file_data: str, strip: bool = True, elements: str = ALLOWED_ELEMENTS_SVG, attributes: str = ALLOWED_ATTRIBUTES_SVG) -> str:
|
||||
"""Sanatize a SVG file.
|
||||
|
||||
Args:
|
||||
file_data (str): SVG as string.
|
||||
strip (bool, optional): Should invalid elements get removed. Defaults to True.
|
||||
elements (str, optional): Allowed elements. Defaults to ALLOWED_ELEMENTS_SVG.
|
||||
attributes (str, optional): Allowed attributes. Defaults to ALLOWED_ATTRIBUTES_SVG.
|
||||
|
||||
Returns:
|
||||
str: Sanitzied SVG file.
|
||||
"""
|
||||
|
||||
cleaned = clean(
|
||||
file_data,
|
||||
tags=elements,
|
||||
attributes=attributes,
|
||||
strip=strip,
|
||||
strip_comments=strip,
|
||||
css_sanitizer=CSSSanitizer()
|
||||
)
|
||||
return cleaned
|
@ -207,13 +207,12 @@ function enableDragAndDrop(element, url, options) {
|
||||
formData,
|
||||
{
|
||||
success: function(data, status, xhr) {
|
||||
console.log('Uploaded file via drag-and-drop');
|
||||
if (options.success) {
|
||||
options.success(data, status, xhr);
|
||||
}
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
console.log('File upload failed');
|
||||
console.error('File upload failed');
|
||||
if (options.error) {
|
||||
options.error(xhr, status, error);
|
||||
}
|
||||
@ -222,7 +221,7 @@ function enableDragAndDrop(element, url, options) {
|
||||
}
|
||||
);
|
||||
} else {
|
||||
console.log('Ignoring drag-and-drop event (not a file)');
|
||||
console.warn('Ignoring drag-and-drop event (not a file)');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -23,6 +23,7 @@ import InvenTree.helpers
|
||||
import InvenTree.tasks
|
||||
from common.models import InvenTreeSetting
|
||||
from common.settings import currency_codes
|
||||
from InvenTree.sanitizer import sanitize_svg
|
||||
from part.models import Part, PartCategory
|
||||
from stock.models import StockItem, StockLocation
|
||||
|
||||
@ -878,3 +879,20 @@ class BarcodeMixinTest(helpers.InvenTreeTestCase):
|
||||
|
||||
for barcode, hash in hashing_tests.items():
|
||||
self.assertEqual(InvenTree.helpers.hash_barcode(barcode), hash)
|
||||
|
||||
|
||||
class SanitizerTest(TestCase):
|
||||
"""Simple tests for sanitizer functions."""
|
||||
|
||||
def test_svg_sanitizer(self):
|
||||
"""Test that SVGs are sanitized acordingly."""
|
||||
valid_string = """<svg xmlns="http://www.w3.org/2000/svg" version="1.1" id="svg2" height="400" width="400">{0}
|
||||
<path id="path1" d="m -151.78571,359.62883 v 112.76373 l 97.068507,-56.04253 V 303.14815 Z" style="fill:#ddbc91;"></path>
|
||||
</svg>"""
|
||||
dangerous_string = valid_string.format('<script>alert();</script>')
|
||||
|
||||
# Test that valid string
|
||||
self.assertEqual(valid_string, sanitize_svg(valid_string))
|
||||
|
||||
# Test that invalid string is cleanded
|
||||
self.assertNotEqual(dangerous_string, sanitize_svg(dangerous_string))
|
||||
|
@ -839,6 +839,10 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
# Get a list of all 'untracked' BOM items
|
||||
for bom_item in self.untracked_bom_items:
|
||||
|
||||
if bom_item.consumable:
|
||||
# Do not auto-allocate stock to consumable BOM items
|
||||
continue
|
||||
|
||||
variant_parts = bom_item.sub_part.get_descendants(include_self=False)
|
||||
|
||||
unallocated_quantity = self.unallocated_quantity(bom_item)
|
||||
@ -972,7 +976,12 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
return max(required - allocated, 0)
|
||||
|
||||
def is_bom_item_allocated(self, bom_item, output=None):
|
||||
"""Test if the supplied BomItem has been fully allocated!"""
|
||||
"""Test if the supplied BomItem has been fully allocated"""
|
||||
|
||||
if bom_item.consumable:
|
||||
# Consumable BOM items do not need to be allocated
|
||||
return True
|
||||
|
||||
return self.unallocated_quantity(bom_item, output) == 0
|
||||
|
||||
def is_fully_allocated(self, output):
|
||||
|
@ -781,6 +781,10 @@ class BuildAllocationSerializer(serializers.Serializer):
|
||||
quantity = item['quantity']
|
||||
output = item.get('output', None)
|
||||
|
||||
# Ignore allocation for consumable BOM items
|
||||
if bom_item.consumable:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Create a new BuildItem to allocate stock
|
||||
BuildItem.objects.create(
|
||||
|
@ -1642,6 +1642,16 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'TABLE_STRING_MAX_LENGTH': {
|
||||
'name': _('Table String Length'),
|
||||
'description': _('Maximimum length limit for strings displayed in table views'),
|
||||
'validator': [
|
||||
int,
|
||||
MinValueValidator(0),
|
||||
],
|
||||
'default': 100,
|
||||
}
|
||||
}
|
||||
|
||||
typ = 'user'
|
||||
|
@ -1640,8 +1640,9 @@ class BomFilter(rest_filters.FilterSet):
|
||||
"""Custom filters for the BOM list."""
|
||||
|
||||
# Boolean filters for BOM item
|
||||
optional = rest_filters.BooleanFilter(label='BOM line is optional')
|
||||
inherited = rest_filters.BooleanFilter(label='BOM line is inherited')
|
||||
optional = rest_filters.BooleanFilter(label='BOM item is optional')
|
||||
consumable = rest_filters.BooleanFilter(label='BOM item is consumable')
|
||||
inherited = rest_filters.BooleanFilter(label='BOM item is inherited')
|
||||
allow_variants = rest_filters.BooleanFilter(label='Variants are allowed')
|
||||
|
||||
# Filters for linked 'part'
|
||||
|
18
InvenTree/part/migrations/0087_bomitem_consumable.py
Normal file
18
InvenTree/part/migrations/0087_bomitem_consumable.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.13 on 2022-04-28 00:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('part', '0086_auto_20220912_0007'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='bomitem',
|
||||
name='consumable',
|
||||
field=models.BooleanField(default=False, help_text='This BOM item is consumable (it is not tracked in build orders)', verbose_name='Consumable'),
|
||||
),
|
||||
]
|
@ -1134,7 +1134,12 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
||||
total = None
|
||||
|
||||
# Prefetch related tables, to reduce query expense
|
||||
queryset = self.get_bom_items().prefetch_related(
|
||||
queryset = self.get_bom_items()
|
||||
|
||||
# Ignore 'consumable' BOM items for this calculation
|
||||
queryset = queryset.filter(consumable=False)
|
||||
|
||||
queryset = queryset.prefetch_related(
|
||||
'sub_part__stock_items',
|
||||
'sub_part__stock_items__allocations',
|
||||
'sub_part__stock_items__sales_order_allocations',
|
||||
@ -2526,6 +2531,7 @@ class BomItem(DataImportMixin, models.Model):
|
||||
sub_part: Link to the child part (the part that will be consumed)
|
||||
quantity: Number of 'sub_parts' consumed to produce one 'part'
|
||||
optional: Boolean field describing if this BomItem is optional
|
||||
consumable: Boolean field describing if this BomItem is considered a 'consumable'
|
||||
reference: BOM reference field (e.g. part designators)
|
||||
overage: Estimated losses for a Build. Can be expressed as absolute value (e.g. '7') or a percentage (e.g. '2%')
|
||||
note: Note field for this BOM item
|
||||
@ -2544,6 +2550,7 @@ class BomItem(DataImportMixin, models.Model):
|
||||
'allow_variants': {},
|
||||
'inherited': {},
|
||||
'optional': {},
|
||||
'consumable': {},
|
||||
'note': {},
|
||||
'part': {
|
||||
'label': _('Part'),
|
||||
@ -2649,7 +2656,17 @@ class BomItem(DataImportMixin, models.Model):
|
||||
# Quantity required
|
||||
quantity = models.DecimalField(default=1.0, max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], verbose_name=_('Quantity'), help_text=_('BOM quantity for this BOM item'))
|
||||
|
||||
optional = models.BooleanField(default=False, verbose_name=_('Optional'), help_text=_("This BOM item is optional"))
|
||||
optional = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('Optional'),
|
||||
help_text=_("This BOM item is optional")
|
||||
)
|
||||
|
||||
consumable = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('Consumable'),
|
||||
help_text=_("This BOM item is consumable (it is not tracked in build orders)")
|
||||
)
|
||||
|
||||
overage = models.CharField(max_length=24, blank=True, validators=[validators.validate_overage],
|
||||
verbose_name=_('Overage'),
|
||||
@ -2698,6 +2715,14 @@ class BomItem(DataImportMixin, models.Model):
|
||||
result_hash.update(str(self.optional).encode())
|
||||
result_hash.update(str(self.inherited).encode())
|
||||
|
||||
# Optionally encoded for backwards compatibility
|
||||
if self.consumable:
|
||||
result_hash.update(str(self.consumable).encode())
|
||||
|
||||
# Optionally encoded for backwards compatibility
|
||||
if self.allow_variants:
|
||||
result_hash.update(str(self.allow_variants).encode())
|
||||
|
||||
return str(result_hash.digest())
|
||||
|
||||
def validate_hash(self, valid=True):
|
||||
|
@ -760,6 +760,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
'inherited',
|
||||
'note',
|
||||
'optional',
|
||||
'consumable',
|
||||
'overage',
|
||||
'pk',
|
||||
'part',
|
||||
|
@ -118,6 +118,7 @@ class BomExportTest(InvenTreeTestCase):
|
||||
'sub_assembly',
|
||||
'quantity',
|
||||
'optional',
|
||||
'consumable',
|
||||
'overage',
|
||||
'reference',
|
||||
'note',
|
||||
|
@ -7,6 +7,8 @@ import django.core.exceptions as django_exceptions
|
||||
from django.db import transaction
|
||||
from django.test import TestCase
|
||||
|
||||
import stock.models
|
||||
|
||||
from .models import BomItem, BomItemSubstitute, Part
|
||||
|
||||
|
||||
@ -197,3 +199,49 @@ class BomItemTest(TestCase):
|
||||
|
||||
# The substitution links should have been automatically removed
|
||||
self.assertEqual(bom_item.substitutes.count(), 0)
|
||||
|
||||
def test_consumable(self):
|
||||
"""Tests for the 'consumable' BomItem field"""
|
||||
|
||||
# Create an assembly part
|
||||
assembly = Part.objects.create(name="An assembly", description="Made with parts", assembly=True)
|
||||
|
||||
# No BOM information initially
|
||||
self.assertEqual(assembly.can_build, 0)
|
||||
|
||||
# Create some component items
|
||||
c1 = Part.objects.create(name="C1", description="C1")
|
||||
c2 = Part.objects.create(name="C2", description="C2")
|
||||
c3 = Part.objects.create(name="C3", description="C3")
|
||||
c4 = Part.objects.create(name="C4", description="C4")
|
||||
|
||||
for p in [c1, c2, c3, c4]:
|
||||
# Ensure we have stock
|
||||
stock.models.StockItem.objects.create(part=p, quantity=1000)
|
||||
|
||||
# Create some BOM items
|
||||
BomItem.objects.create(
|
||||
part=assembly,
|
||||
sub_part=c1,
|
||||
quantity=10
|
||||
)
|
||||
|
||||
self.assertEqual(assembly.can_build, 100)
|
||||
|
||||
BomItem.objects.create(
|
||||
part=assembly,
|
||||
sub_part=c2,
|
||||
quantity=50,
|
||||
consumable=True
|
||||
)
|
||||
|
||||
# A 'consumable' BomItem does not alter the can_build calculation
|
||||
self.assertEqual(assembly.can_build, 100)
|
||||
|
||||
BomItem.objects.create(
|
||||
part=assembly,
|
||||
sub_part=c3,
|
||||
quantity=50,
|
||||
)
|
||||
|
||||
self.assertEqual(assembly.can_build, 20)
|
||||
|
17
InvenTree/stock/migrations/0088_remove_stockitem_infinite.py
Normal file
17
InvenTree/stock/migrations/0088_remove_stockitem_infinite.py
Normal file
@ -0,0 +1,17 @@
|
||||
# Generated by Django 3.2.15 on 2022-09-22 02:23
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stock', '0087_auto_20220912_2341'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='stockitem',
|
||||
name='infinite',
|
||||
),
|
||||
]
|
@ -224,7 +224,6 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
||||
build: Link to a Build (if this stock item was created from a build)
|
||||
is_building: Boolean field indicating if this stock item is currently being built (or is "in production")
|
||||
purchase_order: Link to a PurchaseOrder (if this stock item was created from a PurchaseOrder)
|
||||
infinite: If True this StockItem can never be exhausted
|
||||
sales_order: Link to a SalesOrder object (if the StockItem has been assigned to a SalesOrder)
|
||||
purchase_price: The unit purchase price for this StockItem - this is the unit price at time of purchase (if this item was purchased from an external supplier)
|
||||
packaging: Description of how the StockItem is packaged (e.g. "reel", "loose", "tape" etc)
|
||||
@ -882,11 +881,6 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
||||
|
||||
self.save()
|
||||
|
||||
# If stock item is incoming, an (optional) ETA field
|
||||
# expected_arrival = models.DateField(null=True, blank=True)
|
||||
|
||||
infinite = models.BooleanField(default=False)
|
||||
|
||||
def is_allocated(self):
|
||||
"""Return True if this StockItem is allocated to a SalesOrder or a Build."""
|
||||
# TODO - For now this only checks if the StockItem is allocated to a SalesOrder
|
||||
@ -1565,7 +1559,7 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
||||
except InvalidOperation:
|
||||
return False
|
||||
|
||||
if count < 0 or self.infinite:
|
||||
if count < 0:
|
||||
return False
|
||||
|
||||
self.stocktake_date = datetime.now().date()
|
||||
@ -1601,7 +1595,7 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
||||
return False
|
||||
|
||||
# Ignore amounts that do not make sense
|
||||
if quantity <= 0 or self.infinite:
|
||||
if quantity <= 0:
|
||||
return False
|
||||
|
||||
if self.updateQuantity(self.quantity + quantity):
|
||||
@ -1630,7 +1624,7 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
||||
except InvalidOperation:
|
||||
return False
|
||||
|
||||
if quantity <= 0 or self.infinite:
|
||||
if quantity <= 0:
|
||||
return False
|
||||
|
||||
if self.updateQuantity(self.quantity - quantity):
|
||||
|
@ -19,6 +19,7 @@
|
||||
{% include "InvenTree/settings/setting.html" with key="FORMS_CLOSE_USING_ESCAPE" icon="fa-window-close" user_setting=True %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_QUANTITY_IN_FORMS" icon="fa-hashtag" user_setting=True %}
|
||||
{% include "InvenTree/settings/setting.html" with key="DISPLAY_SCHEDULE_TAB" icon="fa-calendar-alt" user_setting=True %}
|
||||
{% include "InvenTree/settings/setting.html" with key="TABLE_STRING_MAX_LENGTH" icon="fa-table" user_setting=True %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
@ -154,7 +154,7 @@ function enableBreadcrumbTree(options) {
|
||||
var label = options.label;
|
||||
|
||||
if (!label) {
|
||||
console.log('ERROR: enableBreadcrumbTree called without supplying label');
|
||||
console.error('enableBreadcrumbTree called without supplying label');
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -382,6 +382,7 @@ function bomItemFields() {
|
||||
note: {},
|
||||
allow_variants: {},
|
||||
inherited: {},
|
||||
consumable: {},
|
||||
optional: {},
|
||||
};
|
||||
|
||||
@ -761,7 +762,22 @@ function loadBomTable(table, options={}) {
|
||||
}
|
||||
|
||||
return available;
|
||||
}
|
||||
|
||||
function canBuildQuantity(row) {
|
||||
// Calculate how many of each row we can make, given current stock
|
||||
|
||||
if (row.consumable) {
|
||||
// If the row is "consumable" we do not 'track' the quantity
|
||||
return Infinity;
|
||||
}
|
||||
|
||||
// Prevent div-by-zero or negative errors
|
||||
if ((row.quantity || 0) <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return availableQuantity(row) / row.quantity;
|
||||
}
|
||||
|
||||
// Construct the table columns
|
||||
@ -844,6 +860,9 @@ function loadBomTable(table, options={}) {
|
||||
{
|
||||
field: 'sub_part_detail.description',
|
||||
title: '{% trans "Description" %}',
|
||||
formatter: function(value) {
|
||||
return withTitle(shortenString(value), value);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@ -872,8 +891,12 @@ function loadBomTable(table, options={}) {
|
||||
text += ` <small>${row.sub_part_detail.units}</small>`;
|
||||
}
|
||||
|
||||
if (row.consumable) {
|
||||
text += ` <small>({% trans "Consumable" %})</small>`;
|
||||
}
|
||||
|
||||
if (row.optional) {
|
||||
text += ' ({% trans "Optional" %})';
|
||||
text += ' <small>({% trans "Optional" %})</small>';
|
||||
}
|
||||
|
||||
if (row.overage) {
|
||||
@ -966,40 +989,11 @@ function loadBomTable(table, options={}) {
|
||||
if (row.substitutes && row.substitutes.length > 0) {
|
||||
return row.substitutes.length;
|
||||
} else {
|
||||
return `-`;
|
||||
return yesNoLabel(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (show_pricing) {
|
||||
cols.push({
|
||||
field: 'purchase_price_range',
|
||||
title: '{% trans "Purchase Price Range" %}',
|
||||
searchable: false,
|
||||
sortable: true,
|
||||
});
|
||||
|
||||
cols.push({
|
||||
field: 'purchase_price_avg',
|
||||
title: '{% trans "Purchase Price Average" %}',
|
||||
searchable: false,
|
||||
sortable: true,
|
||||
});
|
||||
|
||||
cols.push({
|
||||
field: 'price_range',
|
||||
title: '{% trans "Supplier Cost" %}',
|
||||
sortable: true,
|
||||
formatter: function(value) {
|
||||
if (value) {
|
||||
return value;
|
||||
} else {
|
||||
return `<span class='warning-msg'>{% trans 'No supplier pricing available' %}</span>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cols.push({
|
||||
field: 'optional',
|
||||
title: '{% trans "Optional" %}',
|
||||
@ -1009,6 +1003,15 @@ function loadBomTable(table, options={}) {
|
||||
}
|
||||
});
|
||||
|
||||
cols.push({
|
||||
field: 'consumable',
|
||||
title: '{% trans "Consumable" %}',
|
||||
searchable: false,
|
||||
formatter: function(value) {
|
||||
return yesNoLabel(value);
|
||||
}
|
||||
});
|
||||
|
||||
cols.push({
|
||||
field: 'allow_variants',
|
||||
title: '{% trans "Allow Variants" %}',
|
||||
@ -1037,36 +1040,63 @@ function loadBomTable(table, options={}) {
|
||||
}
|
||||
});
|
||||
|
||||
if (show_pricing) {
|
||||
cols.push({
|
||||
field: 'purchase_price_range',
|
||||
title: '{% trans "Purchase Price Range" %}',
|
||||
searchable: false,
|
||||
sortable: true,
|
||||
});
|
||||
|
||||
cols.push({
|
||||
field: 'purchase_price_avg',
|
||||
title: '{% trans "Purchase Price Average" %}',
|
||||
searchable: false,
|
||||
sortable: true,
|
||||
});
|
||||
|
||||
cols.push({
|
||||
field: 'price_range',
|
||||
title: '{% trans "Supplier Cost" %}',
|
||||
sortable: true,
|
||||
formatter: function(value) {
|
||||
if (value) {
|
||||
return value;
|
||||
} else {
|
||||
return `<span class='warning-msg'>{% trans 'No supplier pricing available' %}</span>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cols.push(
|
||||
{
|
||||
field: 'can_build',
|
||||
title: '{% trans "Can Build" %}',
|
||||
sortable: true,
|
||||
formatter: function(value, row) {
|
||||
var can_build = 0;
|
||||
|
||||
var available = availableQuantity(row);
|
||||
|
||||
if (row.quantity > 0) {
|
||||
can_build = available / row.quantity;
|
||||
// "Consumable" parts are not tracked in the build
|
||||
if (row.consumable) {
|
||||
return `<em>{% trans "Consumable item" %}</em>`;
|
||||
}
|
||||
|
||||
var text = formatDecimal(can_build, 2);
|
||||
var can_build = canBuildQuantity(row);
|
||||
|
||||
// Take "on order" quantity into account
|
||||
if (row.on_order && row.on_order > 0 && row.quantity > 0) {
|
||||
available += row.on_order;
|
||||
can_build = available / row.quantity;
|
||||
return +can_build.toFixed(2);
|
||||
},
|
||||
sorter: function(valA, valB, rowA, rowB) {
|
||||
// Function to sort the "can build" quantity
|
||||
var cb_a = canBuildQuantity(rowA);
|
||||
var cb_b = canBuildQuantity(rowB);
|
||||
|
||||
text += `<span class='fas fa-info-circle icon-blue float-right' title='{% trans "Including On Order" %}: ${formatDecimal(can_build, 2)}'></span>`;
|
||||
}
|
||||
|
||||
return text;
|
||||
return (cb_a > cb_b) ? 1 : -1;
|
||||
},
|
||||
footerFormatter: function(data) {
|
||||
var can_build = null;
|
||||
|
||||
data.forEach(function(row) {
|
||||
if (row.part == options.parent_id && row.quantity > 0) {
|
||||
if (row.quantity > 0 && !row.consumable) {
|
||||
var cb = availableQuantity(row) / row.quantity;
|
||||
|
||||
if (can_build == null || cb < can_build) {
|
||||
@ -1080,23 +1110,7 @@ function loadBomTable(table, options={}) {
|
||||
} else {
|
||||
return formatDecimal(can_build, 2);
|
||||
}
|
||||
},
|
||||
sorter: function(valA, valB, rowA, rowB) {
|
||||
// Function to sort the "can build" quantity
|
||||
var cb_a = 0;
|
||||
var cb_b = 0;
|
||||
|
||||
if (rowA.quantity > 0) {
|
||||
cb_a = availableQuantity(rowA) / rowA.quantity;
|
||||
}
|
||||
|
||||
if (rowB.quantity > 0) {
|
||||
cb_b = availableQuantity(rowB) / rowB.quantity;
|
||||
}
|
||||
|
||||
return (cb_a > cb_b) ? 1 : -1;
|
||||
},
|
||||
sortable: true,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@ -1107,6 +1121,9 @@ function loadBomTable(table, options={}) {
|
||||
title: '{% trans "Notes" %}',
|
||||
searchable: true,
|
||||
sortable: true,
|
||||
formatter: function(value) {
|
||||
return withTitle(shortenString(value), value);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -1859,33 +1859,37 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
|
||||
var icons = '';
|
||||
|
||||
if (available_stock < (required - allocated)) {
|
||||
icons += `<span class='fas fa-times-circle icon-red float-right' title='{% trans "Insufficient stock available" %}'></span>`;
|
||||
if (row.consumable) {
|
||||
icons += `<span class='fas fa-info-circle icon-blue float-right' title='{% trans "Consumable item" %}'></span>`;
|
||||
} else {
|
||||
icons += `<span class='fas fa-check-circle icon-green float-right' title='{% trans "Sufficient stock available" %}'></span>`;
|
||||
if (available_stock < (required - allocated)) {
|
||||
icons += `<span class='fas fa-times-circle icon-red float-right' title='{% trans "Insufficient stock available" %}'></span>`;
|
||||
} else {
|
||||
icons += `<span class='fas fa-check-circle icon-green float-right' title='{% trans "Sufficient stock available" %}'></span>`;
|
||||
}
|
||||
|
||||
if (available_stock <= 0) {
|
||||
icons += `<span class='badge rounded-pill bg-danger'>{% trans "No Stock Available" %}</span>`;
|
||||
} else {
|
||||
var extra = '';
|
||||
if ((substitute_stock > 0) && (variant_stock > 0)) {
|
||||
extra = '{% trans "Includes variant and substitute stock" %}';
|
||||
} else if (variant_stock > 0) {
|
||||
extra = '{% trans "Includes variant stock" %}';
|
||||
} else if (substitute_stock > 0) {
|
||||
extra = '{% trans "Includes substitute stock" %}';
|
||||
}
|
||||
|
||||
if (extra) {
|
||||
icons += `<span title='${extra}' class='fas fa-info-circle float-right icon-blue'></span>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (row.on_order && row.on_order > 0) {
|
||||
icons += `<span class='fas fa-shopping-cart float-right' title='{% trans "On Order" %}: ${row.on_order}'></span>`;
|
||||
}
|
||||
|
||||
if (available_stock <= 0) {
|
||||
icons += `<span class='badge rounded-pill bg-danger'>{% trans "No Stock Available" %}</span>`;
|
||||
} else {
|
||||
var extra = '';
|
||||
if ((substitute_stock > 0) && (variant_stock > 0)) {
|
||||
extra = '{% trans "Includes variant and substitute stock" %}';
|
||||
} else if (variant_stock > 0) {
|
||||
extra = '{% trans "Includes variant stock" %}';
|
||||
} else if (substitute_stock > 0) {
|
||||
extra = '{% trans "Includes substitute stock" %}';
|
||||
}
|
||||
|
||||
if (extra) {
|
||||
icons += `<span title='${extra}' class='fas fa-info-circle float-right icon-blue'></span>`;
|
||||
}
|
||||
}
|
||||
|
||||
return renderLink(text, url) + icons;
|
||||
},
|
||||
sorter: function(valA, valB, rowA, rowB) {
|
||||
@ -1898,8 +1902,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
title: '{% trans "Allocated" %}',
|
||||
sortable: true,
|
||||
formatter: function(value, row) {
|
||||
var allocated = allocatedQuantity(row);
|
||||
var required = requiredQuantity(row);
|
||||
var allocated = row.consumable ? required : allocatedQuantity(row);
|
||||
return makeProgressBar(allocated, required);
|
||||
},
|
||||
sorter: function(valA, valB, rowA, rowB) {
|
||||
@ -1938,6 +1942,11 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
field: 'actions',
|
||||
title: '{% trans "Actions" %}',
|
||||
formatter: function(value, row) {
|
||||
|
||||
if (row.consumable) {
|
||||
return `<em>{% trans "Consumable item" %}</em>`;
|
||||
}
|
||||
|
||||
// Generate action buttons for this build output
|
||||
var html = `<div class='btn-group float-right' role='group'>`;
|
||||
|
||||
@ -2093,6 +2102,11 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
|
||||
for (var idx = 0; idx < bom_items.length; idx++) {
|
||||
var bom_item = bom_items[idx];
|
||||
|
||||
// Ignore "consumable" BOM items
|
||||
if (bom_item.consumable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
var required = bom_item.required || 0;
|
||||
var allocated = bom_item.allocated || 0;
|
||||
var remaining = required - allocated;
|
||||
|
@ -45,10 +45,11 @@ function deleteButton(url, text='{% trans "Delete" %}') {
|
||||
*/
|
||||
function shortenString(input_string, options={}) {
|
||||
|
||||
var max_length = options.max_length || 100;
|
||||
// Maximum length can be provided via options argument, or via a user-configurable setting
|
||||
var max_length = options.max_length || user_settings.TABLE_STRING_MAX_LENGTH;
|
||||
|
||||
if (input_string == null) {
|
||||
return null;
|
||||
if (!max_length || !input_string) {
|
||||
return input_string;
|
||||
}
|
||||
|
||||
input_string = input_string.toString();
|
||||
|
@ -79,6 +79,14 @@ function getAvailableTableFilters(tableKey) {
|
||||
type: 'bool',
|
||||
title: '{% trans "Allow Variant Stock" %}',
|
||||
},
|
||||
optional: {
|
||||
type: 'bool',
|
||||
title: '{% trans "Optional" %}',
|
||||
},
|
||||
consumable: {
|
||||
type: 'bool',
|
||||
title: '{% trans "Consumable" %}',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user