From f6baf5d2ae9f19794d72cc06c3fa9b825841590f Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 15 May 2019 00:16:34 +1000 Subject: [PATCH 01/20] Add 'overage' field to BOM item - Accepts absolute or percentage numbers - Default = blank - Now with custom validator! (for limited time only, limit one per customer) --- InvenTree/InvenTree/validators.py | 51 ++++++++++++++++++- InvenTree/part/forms.py | 1 + .../migrations/0025_auto_20190515_0012.py | 46 +++++++++++++++++ InvenTree/part/models.py | 5 ++ 4 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 InvenTree/part/migrations/0025_auto_20190515_0012.py diff --git a/InvenTree/InvenTree/validators.py b/InvenTree/InvenTree/validators.py index f3fd9ef306..0e1622a49a 100644 --- a/InvenTree/InvenTree/validators.py +++ b/InvenTree/InvenTree/validators.py @@ -7,9 +7,56 @@ from django.utils.translation import gettext_lazy as _ def validate_part_name(value): - # Prevent some illegal characters in part names - for c in ['|', '#', '$']: + """ Prevent some illegal characters in part names. + """ + + for c in ['|', '#', '$', '{', '}']: if c in str(value): raise ValidationError( _('Invalid character in part name') ) + + +def validate_overage(value): + """ Validate that a BOM overage string is properly formatted. + + An overage string can look like: + + - An integer number ('1' / 3 / 4) + - A percentage ('5%' / '10 %') + """ + + value = str(value).lower().strip() + + # First look for a simple integer value + try: + i = int(value) + + if i < 0: + raise ValidationError(_("Overage value must not be negative")) + + # Looks like an integer! + return True + except ValueError: + pass + + # Now look for a percentage value + if value.endswith('%'): + v = value[:-1].strip() + + # Does it look like a number? + try: + f = float(v) + + if f < 0: + raise ValidationError(_("Overage value must not be negative")) + elif f > 100: + raise ValidationError(_("Overage must not exceed 100%")) + + return True + except ValueError: + pass + + raise ValidationError( + _("Overage must be an integer value or a percentage") + ) diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py index 88c6c11385..d4e70ee47a 100644 --- a/InvenTree/part/forms.py +++ b/InvenTree/part/forms.py @@ -133,6 +133,7 @@ class EditBomItemForm(HelperForm): 'part', 'sub_part', 'quantity', + 'overage', 'note' ] diff --git a/InvenTree/part/migrations/0025_auto_20190515_0012.py b/InvenTree/part/migrations/0025_auto_20190515_0012.py new file mode 100644 index 0000000000..aaeb8ea1a3 --- /dev/null +++ b/InvenTree/part/migrations/0025_auto_20190515_0012.py @@ -0,0 +1,46 @@ +# Generated by Django 2.2 on 2019-05-14 14:12 + +import InvenTree.validators +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0024_partcategory_default_keywords'), + ] + + operations = [ + migrations.AddField( + model_name='bomitem', + name='overage', + field=models.CharField(blank=True, help_text='Estimated build wastage quantity (absolute or percentage)', max_length=24, validators=[InvenTree.validators.validate_overage]), + ), + migrations.AlterField( + model_name='bomitem', + name='note', + field=models.CharField(blank=True, help_text='BOM item notes', max_length=100), + ), + migrations.AlterField( + model_name='bomitem', + name='part', + field=models.ForeignKey(help_text='Select parent part', limit_choices_to={'active': True, 'buildable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='bom_items', to='part.Part'), + ), + migrations.AlterField( + model_name='bomitem', + name='quantity', + field=models.PositiveIntegerField(default=1, help_text='BOM quantity for this BOM item', validators=[django.core.validators.MinValueValidator(0)]), + ), + migrations.AlterField( + model_name='bomitem', + name='sub_part', + field=models.ForeignKey(help_text='Select part to be used in BOM', limit_choices_to={'active': True, 'consumable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='used_in', to='part.Part'), + ), + migrations.AlterField( + model_name='supplierpart', + name='URL', + field=models.URLField(blank=True, help_text='URL for external supplier part link'), + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index d255a319f3..b316da2813 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -661,6 +661,7 @@ class BomItem(models.Model): part: Link to the parent part (the part that will be produced) sub_part: Link to the child part (the part that will be consumed) quantity: Number of 'sub_parts' consumed to produce one 'part' + 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 """ @@ -688,6 +689,10 @@ class BomItem(models.Model): # Quantity required quantity = models.PositiveIntegerField(default=1, validators=[MinValueValidator(0)], help_text='BOM quantity for this BOM item') + overage = models.CharField(max_length=24, blank=True, validators=[validators.validate_overage], + help_text='Estimated build wastage quantity (absolute or percentage)' + ) + # Note attached to this BOM line item note = models.CharField(max_length=100, blank=True, help_text='BOM item notes') From a80c11f3ce83b64d9915a749c00684f8713772c3 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 15 May 2019 00:22:10 +1000 Subject: [PATCH 02/20] Add function to infer default_supplier for a Part --- InvenTree/part/models.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index b316da2813..7e83486f18 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -300,6 +300,23 @@ class Part(models.Model): # Default case - no default category found return None + def get_default_supplier(self): + """ Get the default supplier part for this part (may be None). + + - If the part specifies a default_supplier, return that + - If there is only one supplier part available, return that + - Else, return None + """ + + if self.default_supplier: + return self.default_suppliers + + if self.supplier_count == 1: + return self.supplier_parts.first() + + # Default to None if there are multiple suppliers to choose from + return None + default_supplier = models.ForeignKey('part.SupplierPart', on_delete=models.SET_NULL, blank=True, null=True, From 68ae38a7d7ca6abec8cb5c9069294ead55423308 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 15 May 2019 00:36:02 +1000 Subject: [PATCH 03/20] Calculate total quantity required for a build (including overages) --- InvenTree/part/models.py | 56 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 7e83486f18..1f2c1b7b41 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -743,6 +743,62 @@ class BomItem(models.Model): child=self.sub_part.full_name, n=self.quantity) + def get_overage_quantity(self, quantity): + """ Calculate overage quantity + """ + + # Most of the time overage string will be empty + if len(self.overage) == 0: + return 0 + + overage = str(self.overage).strip() + + # Is the overage an integer value? + try: + ovg = int(overage) + + if ovg < 0: + ovg = 0; + + return ovg + except ValueError: + pass + + # Is the overage a percentage? + if overage.endswith('%'): + overage = overage[:-1].strip() + + try: + percent = float(overage) / 100.0 + if percetage > 1: + percentage = 1 + if percentage < 0: + percentage = 0 + + return int(percentage * quantity) + + except ValueError: + pass + + # Default = No overage + return 0 + + def get_required_quantity(self, build_quantity): + """ Calculate the required part quantity, based on the supplier build_quantity. + Includes overage estimate in the returned value. + + Args: + build_quantity: Number of parts to build + + Returns: + Quantity required for this build (including overage) + """ + + # Base quantity requirement + base_quantity = self.quantity * build_quantity + + return base_quantity + self.get_overage_quantity(base_quantity) + class SupplierPart(models.Model): """ Represents a unique part as provided by a Supplier From 8c92c2c2a1f6bac3910d36421ba843f0079d642e Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 15 May 2019 07:23:02 +1000 Subject: [PATCH 04/20] Display overage values in BOM table --- InvenTree/build/models.py | 2 +- InvenTree/part/param_todo.py_todo | 89 ------------------------ InvenTree/part/serializers.py | 4 +- InvenTree/static/script/inventree/bom.js | 9 +++ 4 files changed, 11 insertions(+), 93 deletions(-) delete mode 100644 InvenTree/part/param_todo.py_todo diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 823a19ba61..1a0aba0470 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -261,7 +261,7 @@ class Build(models.Model): try: item = BomItem.objects.get(part=self.part.id, sub_part=part.id) - return item.quantity * self.quantity + return item.get_required_quantity(self.quantity) except BomItem.DoesNotExist: return 0 diff --git a/InvenTree/part/param_todo.py_todo b/InvenTree/part/param_todo.py_todo deleted file mode 100644 index e597bedf57..0000000000 --- a/InvenTree/part/param_todo.py_todo +++ /dev/null @@ -1,89 +0,0 @@ -""" -TODO - Implement part parameters, and templates - -See code below -""" - - - -class PartParameterTemplate(models.Model): - """ A PartParameterTemplate pre-defines a parameter field, - ready to be copied for use with a given Part. - A PartParameterTemplate can be optionally associated with a PartCategory - """ - name = models.CharField(max_length=20, unique=True) - units = models.CharField(max_length=10, blank=True) - - # Parameter format - PARAM_NUMERIC = 10 - PARAM_TEXT = 20 - PARAM_BOOL = 30 - - PARAM_TYPE_CODES = { - PARAM_NUMERIC: _("Numeric"), - PARAM_TEXT: _("Text"), - PARAM_BOOL: _("Bool") - } - - format = models.PositiveIntegerField( - default=PARAM_NUMERIC, - choices=PARAM_TYPE_CODES.items(), - validators=[MinValueValidator(0)]) - - def __str__(self): - return "{name} ({units})".format( - name=self.name, - units=self.units) - - class Meta: - verbose_name = "Parameter Template" - verbose_name_plural = "Parameter Templates" - - -class CategoryParameterLink(models.Model): - """ Links a PartParameterTemplate to a PartCategory - """ - category = models.ForeignKey(PartCategory, on_delete=models.CASCADE) - template = models.ForeignKey(PartParameterTemplate, on_delete=models.CASCADE) - - def __str__(self): - return "{name} - {cat}".format( - name=self.template.name, - cat=self.category) - - class Meta: - verbose_name = "Category Parameter" - verbose_name_plural = "Category Parameters" - unique_together = ('category', 'template') - - -class PartParameter(models.Model): - """ PartParameter is associated with a single part - """ - - part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='parameters') - template = models.ForeignKey(PartParameterTemplate) - - # Value data - value = models.CharField(max_length=50, blank=True) - min_value = models.CharField(max_length=50, blank=True) - max_value = models.CharField(max_length=50, blank=True) - - def __str__(self): - return "{name} : {val}{units}".format( - name=self.template.name, - val=self.value, - units=self.template.units) - - @property - def units(self): - return self.template.units - - @property - def name(self): - return self.template.name - - class Meta: - verbose_name = "Part Parameter" - verbose_name_plural = "Part Parameters" - unique_together = ('part', 'template') diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 37ccb639a0..87ca59c13b 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -104,8 +104,6 @@ class PartStarSerializer(InvenTreeModelSerializer): class BomItemSerializer(InvenTreeModelSerializer): """ Serializer for BomItem object """ - # url = serializers.CharField(source='get_absolute_url', read_only=True) - part_detail = PartBriefSerializer(source='part', many=False, read_only=True) sub_part_detail = PartBriefSerializer(source='sub_part', many=False, read_only=True) @@ -113,12 +111,12 @@ class BomItemSerializer(InvenTreeModelSerializer): model = BomItem fields = [ 'pk', - # 'url', 'part', 'part_detail', 'sub_part', 'sub_part_detail', 'quantity', + 'overage', 'note', ] diff --git a/InvenTree/static/script/inventree/bom.js b/InvenTree/static/script/inventree/bom.js index 46bdc249ef..6ff81de4fc 100644 --- a/InvenTree/static/script/inventree/bom.js +++ b/InvenTree/static/script/inventree/bom.js @@ -113,6 +113,15 @@ function loadBomTable(table, options) { title: 'Required', searchable: false, sortable: true, + formatter: function(value, row, index, field) { + var text = value; + + if (row.overage) { + text += " (+" + row.overage + ") "; + } + + return text; + } } ); From c6331255de7c043689b737787d6cd0df93021d73 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 15 May 2019 07:23:55 +1000 Subject: [PATCH 05/20] Fixes --- InvenTree/part/models.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 1f2c1b7b41..2d9b12e255 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -758,7 +758,7 @@ class BomItem(models.Model): ovg = int(overage) if ovg < 0: - ovg = 0; + ovg = 0 return ovg except ValueError: @@ -770,12 +770,12 @@ class BomItem(models.Model): try: percent = float(overage) / 100.0 - if percetage > 1: - percentage = 1 - if percentage < 0: - percentage = 0 + if percent > 1: + percent = 1 + if percent < 0: + percent = 0 - return int(percentage * quantity) + return int(percent * quantity) except ValueError: pass From a1d587b7f472789b19417dead5b68c6ed461f19e Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 15 May 2019 07:44:13 +1000 Subject: [PATCH 06/20] Rename 'Company' to 'Suppliers' in front-end --- InvenTree/company/templates/company/index.html | 8 ++++---- InvenTree/templates/InvenTree/search.html | 7 +++++++ InvenTree/templates/navbar.html | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/InvenTree/company/templates/company/index.html b/InvenTree/company/templates/company/index.html index 3f0dc7c489..d4a8659798 100644 --- a/InvenTree/company/templates/company/index.html +++ b/InvenTree/company/templates/company/index.html @@ -3,19 +3,19 @@ {% load static %} {% block page_title %} -InvenTree | Company List +InvenTree | Supplier List {% endblock %} {% block content %}
-

Company List

+

Supplier List

- +
@@ -54,7 +54,7 @@ InvenTree | Company List }, { field: 'name', - title: 'Company', + title: 'Supplier', sortable: true, formatter: function(value, row, index, field) { return imageHoverIcon(row.image) + renderLink(value, row.url); diff --git a/InvenTree/templates/InvenTree/search.html b/InvenTree/templates/InvenTree/search.html index 187cc1addb..77730f5ea5 100644 --- a/InvenTree/templates/InvenTree/search.html +++ b/InvenTree/templates/InvenTree/search.html @@ -1,5 +1,7 @@ {% extends "base.html" %} +{% load static %} + {% block page_title %} InvenTree | Search Results {% endblock %} @@ -19,6 +21,11 @@ InvenTree | Search Results {% endblock %} +{% block js_load %} +{{ block.super }} + +{% endblock %} + {% block js_ready %} {{ block.super }} diff --git a/InvenTree/templates/navbar.html b/InvenTree/templates/navbar.html index 5cb71d13ac..e7785abcd5 100644 --- a/InvenTree/templates/navbar.html +++ b/InvenTree/templates/navbar.html @@ -9,7 +9,7 @@
  • Parts
  • Stock
  • Build
  • -
  • Companies
  • +
  • Suppliers

  • -
    - {% if part.active %} - - {% endif %} - -
    - - -
    - +{% include "stock_table.html" %} {% endblock %} @@ -87,14 +69,14 @@ return false; }); - $("#multi-item-take").click(function() { + $("#multi-item-remove").click(function() { updateStockItems({ action: 'remove', }); return false; }); - $("#multi-item-give").click(function() { + $("#multi-item-add").click(function() { updateStockItems({ action: 'add', }); diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 9ea210a04e..311f0773cd 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -44,24 +44,7 @@
    -
    -
    - - -
    -
    - - -
    - +{% include "stock_table.html" %} {% include 'modals.html' %} diff --git a/InvenTree/templates/stock_table.html b/InvenTree/templates/stock_table.html new file mode 100644 index 0000000000..f5411fabb8 --- /dev/null +++ b/InvenTree/templates/stock_table.html @@ -0,0 +1,17 @@ +
    +
    + + +
    +
    + + +
    \ No newline at end of file From 117fd701cded3ab728dbc66a81f1bba5fbb8b38d Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 16 May 2019 22:19:49 +1000 Subject: [PATCH 18/20] Refactor some code --- InvenTree/part/templates/part/stock.html | 39 ------------------ InvenTree/static/script/inventree/stock.js | 36 ++++++++++++++++ InvenTree/stock/templates/stock/location.html | 41 +------------------ 3 files changed, 37 insertions(+), 79 deletions(-) diff --git a/InvenTree/part/templates/part/stock.html b/InvenTree/part/templates/part/stock.html index 60f283bea9..8b9561ea2e 100644 --- a/InvenTree/part/templates/part/stock.html +++ b/InvenTree/part/templates/part/stock.html @@ -44,43 +44,4 @@ url: "{% url 'api-stock-list' %}", }); - function selectedStock() { - return $("#stock-table").bootstrapTable('getSelections'); - } - - $("#multi-item-move").click(function() { - - var items = selectedStock(); - - moveStockItems(items, - { - success: function() { - $("#stock-table").bootstrapTable('refresh'); - } - }); - - return false; - }); - - $("#multi-item-stocktake").click(function() { - updateStockItems({ - action: 'stocktake' - }); - return false; - }); - - $("#multi-item-remove").click(function() { - updateStockItems({ - action: 'remove', - }); - return false; - }); - - $("#multi-item-add").click(function() { - updateStockItems({ - action: 'add', - }); - return false; - }) - {% endblock %} \ No newline at end of file diff --git a/InvenTree/static/script/inventree/stock.js b/InvenTree/static/script/inventree/stock.js index 8824965c41..b8d674556b 100644 --- a/InvenTree/static/script/inventree/stock.js +++ b/InvenTree/static/script/inventree/stock.js @@ -434,6 +434,42 @@ function loadStockTable(table, options) { if (options.buttons) { linkButtonsToSelection(table, options.buttons); } + + // Automatically link button callbacks + $('#multi-item-stocktake').click(function() { + updateStockItems({ + action: 'stocktake', + }); + return false; + }); + + $('#multi-item-remove').click(function() { + updateStockItems({ + action: 'remove', + }); + return false; + }); + + $('#multi-item-add').click(function() { + updateStockItems({ + action: 'add', + }); + return false; + }); + + $("#multi-item-move").click(function() { + + var items = $("#stock-table").bootstrapTable('getSelections'); + + moveStockItems(items, + { + success: function() { + $("#stock-table").bootstrapTable('refresh'); + } + }); + + return false; + }); } diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 311f0773cd..ae36078ece 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -132,46 +132,7 @@ return false; }); - - function selectedStock() { - return $("#stock-table").bootstrapTable('getSelections'); - } - - $("#multi-item-move").click(function() { - - var items = selectedStock(); - - moveStockItems(items, - { - success: function() { - $("#stock-table").bootstrapTable('refresh'); - } - }); - - return false; - }); - - $('#multi-item-stocktake').click(function() { - updateStockItems({ - action: 'stocktake', - }); - return false; - }); - - $('#multi-item-remove').click(function() { - updateStockItems({ - action: 'remove', - }); - return false; - }); - - $('#multi-item-add').click(function() { - updateStockItems({ - action: 'add', - }); - return false; - }); - + loadStockTable($("#stock-table"), { buttons: [ '#stock-options', From 5ebc7b040a119385e9fa4963f3e9c9037bf252dd Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 16 May 2019 22:23:31 +1000 Subject: [PATCH 19/20] Show current quantity in stocktake form --- InvenTree/static/script/inventree/stock.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/InvenTree/static/script/inventree/stock.js b/InvenTree/static/script/inventree/stock.js index b8d674556b..d34ed9beee 100644 --- a/InvenTree/static/script/inventree/stock.js +++ b/InvenTree/static/script/inventree/stock.js @@ -43,6 +43,7 @@ function updateStock(items, options={}) { html += 'Item'; html += 'Location'; html += 'Quantity'; + html += '' + options.action + ''; html += ''; @@ -71,6 +72,9 @@ function updateStock(items, options={}) { } else { html += 'No location set'; } + + html += '' + item.quantity + ''; + html += " Date: Thu, 16 May 2019 22:29:39 +1000 Subject: [PATCH 20/20] Display number of supplier parts in supplier list --- InvenTree/company/serializers.py | 20 ++++++++++++++++++- .../company/templates/company/index.html | 9 ++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/InvenTree/company/serializers.py b/InvenTree/company/serializers.py index 88792f7f64..2967dbebd5 100644 --- a/InvenTree/company/serializers.py +++ b/InvenTree/company/serializers.py @@ -25,7 +25,25 @@ class CompanySerializer(serializers.ModelSerializer): """ Serializer for Company object (full detail) """ url = serializers.CharField(source='get_absolute_url', read_only=True) + part_count = serializers.CharField(read_only=True) class Meta: model = Company - fields = '__all__' + fields = [ + 'id', + 'url', + 'name', + 'description', + 'website', + 'name', + 'phone', + 'address', + 'email', + 'contact', + 'URL', + 'image', + 'notes', + 'is_customer', + 'is_supplier', + 'part_count' + ] diff --git a/InvenTree/company/templates/company/index.html b/InvenTree/company/templates/company/index.html index d4a8659798..cea452fdc1 100644 --- a/InvenTree/company/templates/company/index.html +++ b/InvenTree/company/templates/company/index.html @@ -73,7 +73,14 @@ InvenTree | Supplier List } return ''; } - } + }, + { + field: 'part_count', + title: 'Parts', + formatter: function(value, row, index, field) { + return renderLink(value, row.url + 'parts/'); + } + }, ], url: "{% url 'api-company-list' %}" });