diff --git a/.gitignore b/.gitignore index bfbaf7c285..25ae56db0a 100644 --- a/.gitignore +++ b/.gitignore @@ -37,8 +37,9 @@ InvenTree/media # Key file secret_key.txt -# Ignore python IDE project configuration +# IDE / development files .idea/ +*.code-workspace # Coverage reports .coverage diff --git a/InvenTree/part/bom.py b/InvenTree/part/bom.py new file mode 100644 index 0000000000..a96c331a4b --- /dev/null +++ b/InvenTree/part/bom.py @@ -0,0 +1,210 @@ +""" +Functionality for Bill of Material (BOM) management. +Primarily BOM upload tools. +""" + +from fuzzywuzzy import fuzz +import tablib +import os + +from django.utils.translation import gettext_lazy as _ +from django.core.exceptions import ValidationError + +from InvenTree.helpers import DownloadFile + + +def IsValidBOMFormat(fmt): + """ Test if a file format specifier is in the valid list of BOM file formats """ + + return fmt.strip().lower() in ['csv', 'xls', 'xlsx', 'tsv'] + + +def MakeBomTemplate(fmt): + """ Generate a Bill of Materials upload template file (for user download) """ + + fmt = fmt.strip().lower() + + if not IsValidBOMFormat(fmt): + fmt = 'csv' + + fields = [ + 'Part', + 'Quantity', + 'Overage', + 'Reference', + 'Notes' + ] + + data = tablib.Dataset(headers=fields).export(fmt) + + filename = 'InvenTree_BOM_Template.' + fmt + + return DownloadFile(data, filename) + + +class BomUploadManager: + """ Class for managing an uploaded BOM file """ + + # Fields which are absolutely necessary for valid upload + REQUIRED_HEADERS = [ + 'Part', + 'Quantity' + ] + + # Fields which would be helpful but are not required + OPTIONAL_HEADERS = [ + 'Reference', + 'Notes', + 'Overage', + 'Description', + 'Category', + 'Supplier', + 'Manufacturer', + 'MPN', + 'IPN', + ] + + EDITABLE_HEADERS = [ + 'Reference', + 'Notes' + ] + + HEADERS = REQUIRED_HEADERS + OPTIONAL_HEADERS + + def __init__(self, bom_file): + """ Initialize the BomUpload class with a user-uploaded file object """ + + self.process(bom_file) + + def process(self, bom_file): + """ Process a BOM file """ + + self.data = None + + ext = os.path.splitext(bom_file.name)[-1].lower() + + if ext in ['.csv', '.tsv', ]: + # These file formats need string decoding + raw_data = bom_file.read().decode('utf-8') + elif ext in ['.xls', '.xlsx']: + raw_data = bom_file.read() + else: + raise ValidationError({'bom_file': _('Unsupported file format: {f}'.format(f=ext))}) + + try: + self.data = tablib.Dataset().load(raw_data) + except tablib.UnsupportedFormat: + raise ValidationError({'bom_file': _('Error reading BOM file (invalid data)')}) + except tablib.core.InvalidDimensions: + raise ValidationError({'bom_file': _('Error reading BOM file (incorrect row size)')}) + + def guess_header(self, header, threshold=80): + """ Try to match a header (from the file) to a list of known headers + + Args: + header - Header name to look for + threshold - Match threshold for fuzzy search + """ + + # Try for an exact match + for h in self.HEADERS: + if h == header: + return h + + # Try for a case-insensitive match + for h in self.HEADERS: + if h.lower() == header.lower(): + return h + + # Finally, look for a close match using fuzzy matching + matches = [] + + for h in self.HEADERS: + ratio = fuzz.partial_ratio(header, h) + if ratio > threshold: + matches.append({'header': h, 'match': ratio}) + + if len(matches) > 0: + matches = sorted(matches, key=lambda item: item['match'], reverse=True) + return matches[0]['header'] + + return None + + def columns(self): + """ Return a list of headers for the thingy """ + headers = [] + + for header in self.data.headers: + headers.append({ + 'name': header, + 'guess': self.guess_header(header) + }) + + return headers + + def col_count(self): + if self.data is None: + return 0 + + return len(self.data.headers) + + def row_count(self): + """ Return the number of rows in the file. + Ignored the top rows as indicated by 'starting row' + """ + + if self.data is None: + return 0 + + return len(self.data) + + def rows(self): + """ Return a list of all rows """ + rows = [] + + for i in range(self.row_count()): + + data = [item for item in self.get_row_data(i)] + + # Is the row completely empty? Skip! + empty = True + + for idx, item in enumerate(data): + if len(str(item).strip()) > 0: + empty = False + + try: + # Excel import casts number-looking-items into floats, which is annoying + if item == int(item) and not str(item) == str(int(item)): + print("converting", item, "to", int(item)) + data[idx] = int(item) + except ValueError: + pass + + if empty: + print("Empty - continuing") + continue + + row = { + 'data': data, + 'index': i + } + + rows.append(row) + + return rows + + def get_row_data(self, index): + """ Retrieve row data at a particular index """ + if self.data is None or index >= len(self.data): + return None + + return self.data[index] + + def get_row_dict(self, index): + """ Retrieve a dict object representing the data row at a particular offset """ + + if self.data is None or index >= len(self.data): + return None + + return self.data.dict[index] diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py index 1564c16316..38280bbc1c 100644 --- a/InvenTree/part/forms.py +++ b/InvenTree/part/forms.py @@ -8,6 +8,7 @@ from __future__ import unicode_literals from InvenTree.forms import HelperForm from django import forms +from django.core.validators import MinValueValidator from .models import Part, PartCategory, PartAttachment from .models import BomItem @@ -38,24 +39,27 @@ class BomValidateForm(HelperForm): ] -class BomExportForm(HelperForm): +class BomUploadSelectFile(HelperForm): + """ Form for importing a BOM. Provides a file input box for upload """ - # TODO - Define these choices somewhere else, and import them here - format_choices = ( - ('csv', 'CSV'), - ('pdf', 'PDF'), - ('xml', 'XML'), - ('xlsx', 'XLSX'), - ('html', 'HTML') - ) - - # Select export type - format = forms.CharField(label='Format', widget=forms.Select(choices=format_choices), required='true', help_text='Select export format') + bom_file = forms.FileField(label='BOM file', required=True, help_text="Select BOM file to upload") class Meta: model = Part fields = [ - 'format', + 'bom_file', + ] + + +class BomUploadSelectFields(HelperForm): + """ Form for selecting BOM fields """ + + starting_row = forms.IntegerField(required=True, initial=2, help_text='Index of starting row', validators=[MinValueValidator(1)]) + + class Meta: + model = Part + fields = [ + 'starting_row', ] @@ -130,6 +134,7 @@ class EditBomItemForm(HelperForm): 'part', 'sub_part', 'quantity', + 'reference', 'overage', 'note' ] diff --git a/InvenTree/part/migrations/0012_auto_20190627_2144.py b/InvenTree/part/migrations/0012_auto_20190627_2144.py new file mode 100644 index 0000000000..ffd574b61d --- /dev/null +++ b/InvenTree/part/migrations/0012_auto_20190627_2144.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.2 on 2019-06-27 11:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0011_part_revision'), + ] + + operations = [ + migrations.AddField( + model_name='bomitem', + name='reference', + field=models.CharField(blank=True, help_text='BOM item reference', max_length=500), + ), + migrations.AlterField( + model_name='bomitem', + name='note', + field=models.CharField(blank=True, help_text='BOM item notes', max_length=500), + ), + ] diff --git a/InvenTree/part/migrations/0013_auto_20190628_0951.py b/InvenTree/part/migrations/0013_auto_20190628_0951.py new file mode 100644 index 0000000000..df9f8fdb14 --- /dev/null +++ b/InvenTree/part/migrations/0013_auto_20190628_0951.py @@ -0,0 +1,24 @@ +# Generated by Django 2.2.2 on 2019-06-27 23:51 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0012_auto_20190627_2144'), + ] + + operations = [ + migrations.AlterField( + model_name='bomitem', + name='part', + field=models.ForeignKey(help_text='Select parent part', limit_choices_to={'assembly': True}, on_delete=django.db.models.deletion.CASCADE, related_name='bom_items', to='part.Part'), + ), + migrations.AlterField( + model_name='bomitem', + name='sub_part', + field=models.ForeignKey(help_text='Select part to be used in BOM', limit_choices_to={'component': True}, on_delete=django.db.models.deletion.CASCADE, related_name='used_in', to='part.Part'), + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 48e8dc7906..e56466c832 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -671,6 +671,13 @@ class Part(models.Model): self.save() + @transaction.atomic + def clear_bom(self): + """ Clear the BOM items for the part (delete all BOM lines). + """ + + self.bom_items.all().delete() + def required_parts(self): """ Return a list of parts required to make this part (list of BOM items) """ parts = [] @@ -678,6 +685,18 @@ class Part(models.Model): parts.append(bom.sub_part) return parts + def get_allowed_bom_items(self): + """ Return a list of parts which can be added to a BOM for this part. + + - Exclude parts which are not 'component' parts + - Exclude parts which this part is in the BOM for + """ + + parts = Part.objects.filter(component=True).exclude(id=self.id) + parts = parts.exclude(id__in=[part.id for part in self.used_in.all()]) + + return parts + @property def supplier_count(self): """ Return the number of supplier parts available for this part """ @@ -843,15 +862,19 @@ class Part(models.Model): 'Part', 'Description', 'Quantity', + 'Overage', + 'Reference', 'Note', ]) - for it in self.bom_items.all(): + for it in self.bom_items.all().order_by('id'): line = [] line.append(it.sub_part.full_name) line.append(it.sub_part.description) line.append(it.quantity) + line.append(it.overage) + line.append(it.reference) line.append(it.note) data.append(line) @@ -969,6 +992,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' + 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 """ @@ -982,7 +1006,6 @@ class BomItem(models.Model): help_text='Select parent part', limit_choices_to={ 'assembly': True, - 'active': True, }) # A link to the child item (sub-part) @@ -991,7 +1014,6 @@ class BomItem(models.Model): help_text='Select part to be used in BOM', limit_choices_to={ 'component': True, - 'active': True }) # Quantity required @@ -1001,8 +1023,10 @@ class BomItem(models.Model): help_text='Estimated build wastage quantity (absolute or percentage)' ) + reference = models.CharField(max_length=500, blank=True, help_text='BOM item reference') + # Note attached to this BOM line item - note = models.CharField(max_length=100, blank=True, help_text='BOM item notes') + note = models.CharField(max_length=500, blank=True, help_text='BOM item notes') def clean(self): """ Check validity of the BomItem model. diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index eaea7ecebc..47b34b292f 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -53,6 +53,7 @@ class PartBriefSerializer(InvenTreeModelSerializer): 'total_stock', 'available_stock', 'image_url', + 'active', ] @@ -166,6 +167,7 @@ class BomItemSerializer(InvenTreeModelSerializer): 'sub_part', 'sub_part_detail', 'quantity', + 'reference', 'price_range', 'overage', 'note', diff --git a/InvenTree/part/templates/part/bom.html b/InvenTree/part/templates/part/bom.html index dee1b0f140..3d61e24e2a 100644 --- a/InvenTree/part/templates/part/bom.html +++ b/InvenTree/part/templates/part/bom.html @@ -31,25 +31,28 @@ {% endif %} -