mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
commit
1f9e6f4a68
3
.gitignore
vendored
3
.gitignore
vendored
@ -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
|
||||
|
210
InvenTree/part/bom.py
Normal file
210
InvenTree/part/bom.py
Normal file
@ -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]
|
@ -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'
|
||||
]
|
||||
|
23
InvenTree/part/migrations/0012_auto_20190627_2144.py
Normal file
23
InvenTree/part/migrations/0012_auto_20190627_2144.py
Normal file
@ -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),
|
||||
),
|
||||
]
|
24
InvenTree/part/migrations/0013_auto_20190628_0951.py
Normal file
24
InvenTree/part/migrations/0013_auto_20190628_0951.py
Normal file
@ -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'),
|
||||
),
|
||||
]
|
@ -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.
|
||||
|
@ -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',
|
||||
|
@ -31,26 +31,29 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div id='button-toolbar'>
|
||||
<div id='button-toolbar' class="btn-group" role="group" aria-label="...">
|
||||
{% if editing_enabled %}
|
||||
<div class='btn-group' style='float: right;'>
|
||||
<button class='btn btn-info' type='button' id='bom-item-new'>New BOM Item</button>
|
||||
<button class='btn btn-success' type='button' id='editing-finished'>Finish Editing</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class='dropdown' style="float: right;">
|
||||
<button class='btn btn-primary dropdown-toggle' type='button' data-toggle='dropdown'>
|
||||
Options
|
||||
<span class='caret'></span>
|
||||
</button>
|
||||
<ul class='dropdown-menu'>
|
||||
<button class='btn btn-default' type='button' title='Remove selected BOM items' id='bom-item-delete'><span class='glyphicon glyphicon-trash'></span></button>
|
||||
<a href="{% url 'upload-bom' part.id %}">
|
||||
<button class='btn btn-default' type='button' title='Import BOM data' id='bom-upload'><span class='glyphicon glyphicon-open-file'></span></button>
|
||||
</a>
|
||||
<button class='btn btn-default' type='button' title='New BOM Item' id='bom-item-new'><span class='glyphicon glyphicon-plus'></span></button>
|
||||
<button class='btn btn-default' type='button' title='Finish Editing' id='editing-finished'><span class='glyphicon glyphicon-ok'></span></button>
|
||||
{% elif part.active %}
|
||||
<button class='btn btn-default' type='button' title='Edit BOM' id='edit-bom'><span class='glyphicon glyphicon-edit'></span></button>
|
||||
{% if part.is_bom_valid == False %}
|
||||
<li><a href='#' id='validate-bom' title='Validate BOM'>Validate BOM</a></li>
|
||||
<button class='btn btn-default' id='validate-bom' type='button'><span class='glyphicon glyphicon-check'></span></button>
|
||||
{% endif %}
|
||||
<li><a href='#' id='edit-bom' title='Edit BOM'>Edit BOM</a></li>
|
||||
<li><a href='#' id='export-bom' title='Export BOM'>Export BOM</a></li>
|
||||
<div class='btn-group' role='group'>
|
||||
<div class='dropdown'>
|
||||
<button title='Export BOM' class='btn btn-default dropdown-toggle' data-toggle='dropdown' type='button'><span class='glyphicon glyphicon-download-alt'></span></button>
|
||||
<ul class='dropdown-menu'>
|
||||
<li><a href='#' class='download-bom' format='csv'>CSV</a></li>
|
||||
<li><a href='#' class='download-bom' format='xlsx'>XLSX</a></li>
|
||||
<li><a href='#' class='download-bom' format='json'>JSON</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@ -59,11 +62,6 @@
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_load %}
|
||||
{{ block.super }}
|
||||
<script type='text/javascript' src="{% static 'script/inventree/bom.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
@ -76,6 +74,12 @@
|
||||
sub_part_detail: true,
|
||||
});
|
||||
|
||||
linkButtonsToSelection($("#bom-table"),
|
||||
[
|
||||
"#bom-item-delete",
|
||||
]
|
||||
);
|
||||
|
||||
{% if editing_enabled %}
|
||||
$("#editing-finished").click(function() {
|
||||
location.href = "{% url 'part-bom' part.id %}";
|
||||
@ -112,15 +116,11 @@
|
||||
});
|
||||
|
||||
$("#edit-bom").click(function () {
|
||||
location.href = "{% url 'part-bom' part.id %}?edit=True";
|
||||
});
|
||||
|
||||
$("#export-bom").click(function () {
|
||||
downloadBom({
|
||||
modal: '#modal-form',
|
||||
url: "{% url 'bom-export' part.id %}"
|
||||
location.href = "{% url 'part-bom' part.id %}?edit=1";
|
||||
});
|
||||
|
||||
$(".download-bom").click(function () {
|
||||
location.href = "{% url 'bom-export' part.id %}?format=" + $(this).attr('format');
|
||||
});
|
||||
|
||||
{% endif %}
|
||||
|
90
InvenTree/part/templates/part/bom_upload/select_fields.html
Normal file
90
InvenTree/part/templates/part/bom_upload/select_fields.html
Normal file
@ -0,0 +1,90 @@
|
||||
{% extends "part/part_base.html" %}
|
||||
{% load static %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block details %}
|
||||
{% include "part/tabs.html" with tab='bom' %}
|
||||
<h4>Upload Bill of Materials</h4>
|
||||
|
||||
<p>Step 2 - Select Fields</p>
|
||||
<hr>
|
||||
|
||||
{% if missing_columns and missing_columns|length > 0 %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
Missing selections for the following required columns:
|
||||
<br>
|
||||
<ul>
|
||||
{% for col in missing_columns %}
|
||||
<li>{{ col }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action='' class='js-modal-form' enctype="multipart/form-data">
|
||||
<button type="submit" class="save btn btn-default">Submit Selections</button>
|
||||
{% csrf_token %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% crispy form %}
|
||||
|
||||
<input type='hidden' name='form_step' value='select_fields'/>
|
||||
|
||||
<table class='table table-striped'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Row</th>
|
||||
{% for col in bom_columns %}
|
||||
<th>
|
||||
<div>
|
||||
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
|
||||
{{ col.name }}
|
||||
<button class='btn btn-default btn-remove' id='del_col_{{ forloop.counter0 }}' style='display: inline; float: right;' title='Remove column'>
|
||||
<span col_id='{{ forloop.counter0 }}' onClick='removeColFromBomWizard()' class='glyphicon glyphicon-small glyphicon-remove'></span>
|
||||
</button>
|
||||
</div>
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
{% for col in bom_columns %}
|
||||
<td>
|
||||
<select class='select' id='id_col_{{ forloop.counter0 }}' name='col_guess_{{ forloop.counter0 }}'>
|
||||
<option value=''>---------</option>
|
||||
{% for req in bom_headers %}
|
||||
<option value='{{ req }}'{% if req == col.guess %}selected='selected'{% endif %}>{{ req }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if col.duplicate %}
|
||||
<p class='help-inline'>Duplicate column selection</p>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% for row in bom_rows %}
|
||||
<tr>
|
||||
<td>
|
||||
<button class='btn btn-default btn-remove' id='del_row_{{ forloop.counter }}' style='display: inline; float: right;' title='Remove row'>
|
||||
<span row_id='{{ forloop.counter }}' onClick='removeRowFromBomWizard()' class='glyphicon glyphicon-small glyphicon-remove'></span>
|
||||
</button>
|
||||
</td>
|
||||
<td>{{ forloop.counter }}</td>
|
||||
{% for item in row.data %}
|
||||
<td>
|
||||
<input type='hidden' name='row_{{ row.index }}_col_{{ forloop.counter0 }}' value='{{ item.cell }}'/>
|
||||
{{ item.cell }}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
108
InvenTree/part/templates/part/bom_upload/select_parts.html
Normal file
108
InvenTree/part/templates/part/bom_upload/select_parts.html
Normal file
@ -0,0 +1,108 @@
|
||||
{% extends "part/part_base.html" %}
|
||||
{% load static %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block details %}
|
||||
{% include "part/tabs.html" with tab="bom" %}
|
||||
<h4>Upload Bill of Materials</h4>
|
||||
|
||||
<p>Step 3 - Select Parts</p>
|
||||
<hr>
|
||||
|
||||
{% if form_errors %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
Errors exist in the submitted data.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action='' class='js-modal-form' enctype="multipart/form-data">
|
||||
|
||||
<button type="submit" class="save btn btn-default">Submit BOM</button>
|
||||
|
||||
{% csrf_token %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
<input type='hidden' name='form_step' value='select_parts'/>
|
||||
|
||||
<table class='table table-striped'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th>Row</th>
|
||||
{% for col in bom_columns %}
|
||||
<th>
|
||||
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
|
||||
<input type='hidden' name='col_guess_{{ forloop.counter0 }}' value='{{ col.guess }}'/>
|
||||
{% if col.guess %}
|
||||
{{ col.guess }}
|
||||
{% else %}
|
||||
{{ col.name }}
|
||||
{% endif %}
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in bom_rows %}
|
||||
<tr {% if row.errors %} style='background: #ffeaea;'{% endif %} part-name='{{ row.part_name }}' part-description='{{ row.description }}' part-select='#select_part_{{ row.index }}'>
|
||||
<td>
|
||||
<button class='btn btn-default btn-remove' id='del_row_{{ forloop.counter }}' style='display: inline; float: right;' title='Remove row'>
|
||||
<span row_id='{{ forloop.counter }}' onClick='removeRowFromBomWizard()' class='glyphicon glyphicon-small glyphicon-remove'></span>
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
</td>
|
||||
<td>{% add row.index 1 %}</td>
|
||||
{% for item in row.data %}
|
||||
<td>
|
||||
{% if item.column.guess == 'Part' %}
|
||||
<button class='btn btn-default btn-create' id='new_part_row_{{ row.index }}' title='Create new part' type='button'>
|
||||
<span row_id='{{ row.index }}' class='glyphicon glyphicon-small glyphicon-plus' onClick='newPartFromBomWizard()'/>
|
||||
</button>
|
||||
|
||||
<select class='select bomselect' id='select_part_{{ row.index }}' name='part_{{ row.index }}'>
|
||||
<option value=''>---------</option>
|
||||
{% for part in row.part_options %}
|
||||
<option value='{{ part.id }}'{% if part.id == row.part.id %} selected='selected'{% endif %}>{{ part.full_name }} - {{ part.description }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<i>{{ item.cell }}</i>
|
||||
{% if row.errors.part %}
|
||||
<p class='help-inline'>{{ row.errors.part }}</p>
|
||||
{% endif %}
|
||||
{% elif item.column.guess == 'Quantity' %}
|
||||
<input name='quantity_{{ row.index }}' class='numberinput' type='number' min='1' value='{{ row.quantity }}'/>
|
||||
{% if row.errors.quantity %}
|
||||
<p class='help-inline'>{{ row.errors.quantity }}</p>
|
||||
{% endif %}
|
||||
{% elif item.column.guess == 'Reference' %}
|
||||
<input name='reference_{{ row.index }}' value='{{ row.reference }}'/>
|
||||
{% elif item.column.guess == 'Notes' %}
|
||||
<input name='notes_{{ row.index }}' value='{{ row.notes }}'/>
|
||||
{% elif item.column.guess == 'Overage' %}
|
||||
<input name='overage_{{ row.index }}' value='{{ row.overage }}'/>
|
||||
{% else %}
|
||||
{{ item.cell }}
|
||||
{% endif %}
|
||||
<input type='hidden' name='row_{{ row.index }}_col_{{ forloop.counter0 }}' value='{{ item.cell }}'/>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
$('.bomselect').select2({
|
||||
dropdownAutoWidth: true,
|
||||
matcher: partialMatcher,
|
||||
});
|
||||
|
||||
{% endblock %}
|
29
InvenTree/part/templates/part/bom_upload/upload_file.html
Normal file
29
InvenTree/part/templates/part/bom_upload/upload_file.html
Normal file
@ -0,0 +1,29 @@
|
||||
{% extends "part/part_base.html" %}
|
||||
{% load static %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block details %}
|
||||
|
||||
{% include "part/tabs.html" with tab='bom' %}
|
||||
|
||||
<h4>Upload Bill of Materials</h4>
|
||||
<hr>
|
||||
|
||||
<p>Step 1 - Select BOM File</p>
|
||||
|
||||
<div class='alert alert-info alert-block'>
|
||||
<p>The BOM file must contain the required named columns as provided in the <a href="/part/bom_template/">BOM Upload Template</a></a></p>
|
||||
</div>
|
||||
|
||||
<form method="post" action='' class='js-modal-form' enctype="multipart/form-data">
|
||||
<button type="submit" class="save btn btn-default">Upload File</button>
|
||||
{% csrf_token %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
<input type='hidden' name='form_step' value='select_file'/>
|
||||
|
||||
{% crispy form %}
|
||||
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
@ -99,14 +99,16 @@
|
||||
<div class="col-sm-6">
|
||||
<table class="table table-striped">
|
||||
<tr>
|
||||
<td colspan='2'>
|
||||
<h4>Stock Status</h4>
|
||||
<td>
|
||||
<h4>Available Stock</h4>
|
||||
</td>
|
||||
<td><h4>{{ part.net_stock }} {{ part.units }}</h4></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>In Stock</td>
|
||||
<td>{{ part.total_stock }}</td>
|
||||
</tr>
|
||||
{% if not part.is_template %}
|
||||
{% if part.allocation_count > 0 %}
|
||||
<tr>
|
||||
<td>Allocated</td>
|
||||
@ -119,14 +121,12 @@
|
||||
<td>{{ part.on_order }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td><b>Total Available</b></td>
|
||||
<td><b>{{ part.net_stock }}</b></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if not part.is_template %}
|
||||
{% if part.assembly %}
|
||||
<tr>
|
||||
<td colspan='2'>
|
||||
<h4>Build Status</h4>
|
||||
<b>Build Status</b>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@ -140,6 +140,7 @@
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -35,15 +35,24 @@
|
||||
{
|
||||
field: 'part_detail',
|
||||
title: 'Part',
|
||||
sortable: true,
|
||||
formatter: function(value, row, index, field) {
|
||||
return imageHoverIcon(row.part_detail.image_url) + renderLink(value.full_name, value.url + 'bom/');
|
||||
var html = imageHoverIcon(row.part_detail.image_url) + renderLink(value.full_name, value.url + 'bom/');
|
||||
|
||||
if (!row.part_detail.active) {
|
||||
html += "<span class='label label-warning' style='float: right;'>INACTIVE</span>";
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'part_detail.description',
|
||||
title: 'Description',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
field: 'quantity',
|
||||
title: 'Uses',
|
||||
}
|
||||
|
@ -24,9 +24,9 @@
|
||||
<table class='table table-striped table-condensed' id='variant-table' data-toolbar='#button-toolbar'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Variant</th>
|
||||
<th>Description</th>
|
||||
<th>Stock</th>
|
||||
<th data-sortable='true'>Variant</th>
|
||||
<th data-sortable='true'>Description</th>
|
||||
<th data-sortable='true'>Stock</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -35,6 +35,9 @@
|
||||
<td>
|
||||
{% include "hover_image.html" with image=variant.image hover=True %}
|
||||
<a href="{% url 'part-detail' variant.id %}">{{ variant.full_name }}</a>
|
||||
{% if not variant.active %}
|
||||
<span class='label label-warning' style='float: right;'>INACTIVE</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ variant.description }}</td>
|
||||
<td>{{ variant.total_stock }}</td>
|
||||
|
@ -8,12 +8,24 @@ from InvenTree import version
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inrange(n, *args, **kwargs):
|
||||
""" Return range(n) for iterating through a numeric quantity """
|
||||
return range(n)
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def multiply(x, y, *args, **kwargs):
|
||||
""" Multiply two numbers together """
|
||||
return x * y
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def add(x, y, *args, **kwargs):
|
||||
""" Add two numbers together """
|
||||
return x + y
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def part_allocation_count(build, part, *args, **kwargs):
|
||||
""" Return the total number of <part> allocated to <build> """
|
||||
|
@ -27,6 +27,8 @@ part_detail_urls = [
|
||||
url(r'^make-variant/', views.MakePartVariant.as_view(), name='make-part-variant'),
|
||||
url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'),
|
||||
|
||||
url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'),
|
||||
|
||||
url(r'^variants/?', views.PartDetail.as_view(template_name='part/variants.html'), name='part-variants'),
|
||||
url(r'^stock/?', views.PartDetail.as_view(template_name='part/stock.html'), name='part-stock'),
|
||||
url(r'^allocation/?', views.PartDetail.as_view(template_name='part/allocation.html'), name='part-allocation'),
|
||||
@ -73,6 +75,9 @@ part_urls = [
|
||||
# Create a new BOM item
|
||||
url(r'^bom/new/?', views.BomItemCreate.as_view(), name='bom-item-create'),
|
||||
|
||||
# Download a BOM upload template
|
||||
url(r'^bom_template/?', views.BomUploadTemplate.as_view(), name='bom-upload-template'),
|
||||
|
||||
# Individual part
|
||||
url(r'^(?P<pk>\d+)/', include(part_detail_urls)),
|
||||
|
||||
|
@ -5,13 +5,17 @@ Django views for interacting with Part app
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.shortcuts import HttpResponseRedirect
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.urls import reverse_lazy
|
||||
from django.views.generic import DetailView, ListView
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.views.generic import DetailView, ListView, FormView
|
||||
from django.forms.models import model_to_dict
|
||||
from django.forms import HiddenInput, CheckboxInput
|
||||
|
||||
from fuzzywuzzy import fuzz
|
||||
|
||||
from .models import PartCategory, Part, PartAttachment
|
||||
from .models import BomItem
|
||||
from .models import match_part_names
|
||||
@ -19,6 +23,7 @@ from .models import match_part_names
|
||||
from company.models import SupplierPart
|
||||
|
||||
from . import forms as part_forms
|
||||
from .bom import MakeBomTemplate, BomUploadManager
|
||||
|
||||
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
|
||||
from InvenTree.views import QRCodeView
|
||||
@ -490,6 +495,11 @@ class PartCreate(AjaxCreateView):
|
||||
except PartCategory.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Allow initial data to be passed through as arguments
|
||||
for label in ['name', 'IPN', 'description', 'revision', 'keywords']:
|
||||
if label in self.request.GET:
|
||||
initials[label] = self.request.GET.get(label)
|
||||
|
||||
return initials
|
||||
|
||||
|
||||
@ -509,13 +519,14 @@ class PartDetail(DetailView):
|
||||
"""
|
||||
context = super(PartDetail, self).get_context_data(**kwargs)
|
||||
|
||||
part = self.get_object()
|
||||
|
||||
if str2bool(self.request.GET.get('edit', '')):
|
||||
context['editing_enabled'] = 1
|
||||
# Allow BOM editing if the part is active
|
||||
context['editing_enabled'] = 1 if part.active else 0
|
||||
else:
|
||||
context['editing_enabled'] = 0
|
||||
|
||||
part = self.get_object()
|
||||
|
||||
context['starred'] = part.isStarredBy(self.request.user)
|
||||
context['disabled'] = not part.active
|
||||
|
||||
@ -616,36 +627,549 @@ class BomValidate(AjaxUpdateView):
|
||||
return self.renderJsonResponse(request, form, data, context=self.get_context())
|
||||
|
||||
|
||||
class BomExport(AjaxView):
|
||||
class BomUpload(FormView):
|
||||
""" View for uploading a BOM file, and handling BOM data importing.
|
||||
|
||||
model = Part
|
||||
ajax_form_title = 'Export BOM'
|
||||
ajax_template_name = 'part/bom_export.html'
|
||||
form_class = part_forms.BomExportForm
|
||||
The BOM upload process is as follows:
|
||||
|
||||
def get_object(self):
|
||||
return get_object_or_404(Part, pk=self.kwargs['pk'])
|
||||
1. (Client) Select and upload BOM file
|
||||
2. (Server) Verify that supplied file is a file compatible with tablib library
|
||||
3. (Server) Introspect data file, try to find sensible columns / values / etc
|
||||
4. (Server) Send suggestions back to the client
|
||||
5. (Client) Makes choices based on suggestions:
|
||||
- Accept automatic matching to parts found in database
|
||||
- Accept suggestions for 'partial' or 'fuzzy' matches
|
||||
- Create new parts in case of parts not being available
|
||||
6. (Client) Sends updated dataset back to server
|
||||
7. (Server) Check POST data for validity, sanity checking, etc.
|
||||
8. (Server) Respond to POST request
|
||||
- If data are valid, proceed to 9.
|
||||
- If data not valid, return to 4.
|
||||
9. (Server) Send confirmation form to user
|
||||
- Display the actions which will occur
|
||||
- Provide final "CONFIRM" button
|
||||
10. (Client) Confirm final changes
|
||||
11. (Server) Apply changes to database, update BOM items.
|
||||
|
||||
During these steps, data are passed between the server/client as JSON objects.
|
||||
"""
|
||||
|
||||
template_name = 'part/bom_upload/upload_file.html'
|
||||
|
||||
# Context data passed to the forms (initially empty, extracted from uploaded file)
|
||||
bom_headers = []
|
||||
bom_columns = []
|
||||
bom_rows = []
|
||||
missing_columns = []
|
||||
allowed_parts = []
|
||||
|
||||
def get_success_url(self):
|
||||
part = self.get_object()
|
||||
return reverse('upload-bom', kwargs={'pk': part.id})
|
||||
|
||||
def get_form_class(self):
|
||||
|
||||
form_step = self.request.POST.get('form_step', None)
|
||||
|
||||
if form_step == 'select_fields':
|
||||
return part_forms.BomUploadSelectFields
|
||||
else:
|
||||
# Default form is the starting point
|
||||
return part_forms.BomUploadSelectFile
|
||||
|
||||
def get_context_data(self, *args, **kwargs):
|
||||
|
||||
ctx = super().get_context_data(*args, **kwargs)
|
||||
|
||||
# Give each row item access to the column it is in
|
||||
# This provides for much simpler template rendering
|
||||
|
||||
rows = []
|
||||
for row in self.bom_rows:
|
||||
row_data = row['data']
|
||||
|
||||
data = []
|
||||
|
||||
for idx, item in enumerate(row_data):
|
||||
|
||||
data.append({
|
||||
'cell': item,
|
||||
'idx': idx,
|
||||
'column': self.bom_columns[idx]
|
||||
})
|
||||
|
||||
rows.append({
|
||||
'index': row.get('index', -1),
|
||||
'data': data,
|
||||
'part_options': row.get('part_options', self.allowed_parts),
|
||||
|
||||
# User-input (passed between client and server)
|
||||
'quantity': row.get('quantity', None),
|
||||
'description': row.get('description', ''),
|
||||
'part_name': row.get('part_name', ''),
|
||||
'part': row.get('part', None),
|
||||
'reference': row.get('reference', ''),
|
||||
'notes': row.get('notes', ''),
|
||||
'errors': row.get('errors', ''),
|
||||
})
|
||||
|
||||
ctx['part'] = self.part
|
||||
ctx['bom_headers'] = BomUploadManager.HEADERS
|
||||
ctx['bom_columns'] = self.bom_columns
|
||||
ctx['bom_rows'] = rows
|
||||
ctx['missing_columns'] = self.missing_columns
|
||||
ctx['allowed_parts_list'] = self.allowed_parts
|
||||
|
||||
return ctx
|
||||
|
||||
def getAllowedParts(self):
|
||||
""" Return a queryset of parts which are allowed to be added to this BOM.
|
||||
"""
|
||||
|
||||
return self.part.get_allowed_bom_items()
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
form = self.form_class()
|
||||
""" Perform the initial 'GET' request.
|
||||
|
||||
return self.renderJsonResponse(request, form)
|
||||
Initially returns a form for file upload """
|
||||
|
||||
self.request = request
|
||||
|
||||
# A valid Part object must be supplied. This is the 'parent' part for the BOM
|
||||
self.part = get_object_or_404(Part, pk=self.kwargs['pk'])
|
||||
|
||||
self.form = self.get_form()
|
||||
|
||||
form_class = self.get_form_class()
|
||||
form = self.get_form(form_class)
|
||||
return self.render_to_response(self.get_context_data(form=form))
|
||||
|
||||
def handleBomFileUpload(self):
|
||||
""" Process a BOM file upload form.
|
||||
|
||||
This function validates that the uploaded file was valid,
|
||||
and contains tabulated data that can be extracted.
|
||||
If the file does not satisfy these requirements,
|
||||
the "upload file" form is again shown to the user.
|
||||
"""
|
||||
|
||||
bom_file = self.request.FILES.get('bom_file', None)
|
||||
|
||||
manager = None
|
||||
bom_file_valid = False
|
||||
|
||||
if bom_file is None:
|
||||
self.form.errors['bom_file'] = [_('No BOM file provided')]
|
||||
else:
|
||||
# Create a BomUploadManager object - will perform initial data validation
|
||||
# (and raise a ValidationError if there is something wrong with the file)
|
||||
try:
|
||||
manager = BomUploadManager(bom_file)
|
||||
bom_file_valid = True
|
||||
except ValidationError as e:
|
||||
errors = e.error_dict
|
||||
|
||||
for k, v in errors.items():
|
||||
self.form.errors[k] = v
|
||||
|
||||
if bom_file_valid:
|
||||
# BOM file is valid? Proceed to the next step!
|
||||
form = part_forms.BomUploadSelectFields
|
||||
self.template_name = 'part/bom_upload/select_fields.html'
|
||||
|
||||
self.extractDataFromFile(manager)
|
||||
else:
|
||||
form = self.form
|
||||
|
||||
return self.render_to_response(self.get_context_data(form=form))
|
||||
|
||||
def getColumnIndex(self, name):
|
||||
""" Return the index of the column with the given name.
|
||||
It named column is not found, return -1
|
||||
"""
|
||||
|
||||
try:
|
||||
idx = list(self.column_selections.values()).index(name)
|
||||
except ValueError:
|
||||
idx = -1
|
||||
|
||||
return idx
|
||||
|
||||
def preFillSelections(self):
|
||||
""" Once data columns have been selected, attempt to pre-select the proper data from the database.
|
||||
This function is called once the field selection has been validated.
|
||||
The pre-fill data are then passed through to the part selection form.
|
||||
"""
|
||||
|
||||
q_idx = self.getColumnIndex('Quantity')
|
||||
p_idx = self.getColumnIndex('Part')
|
||||
d_idx = self.getColumnIndex('Description')
|
||||
r_idx = self.getColumnIndex('Reference')
|
||||
n_idx = self.getColumnIndex('Notes')
|
||||
|
||||
for row in self.bom_rows:
|
||||
|
||||
quantity = 0
|
||||
part = None
|
||||
|
||||
if q_idx >= 0:
|
||||
q_val = row['data'][q_idx]
|
||||
|
||||
try:
|
||||
quantity = int(q_val)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if p_idx >= 0:
|
||||
part_name = row['data'][p_idx]
|
||||
|
||||
row['part_name'] = part_name
|
||||
|
||||
# Fuzzy match the values and see what happends
|
||||
matches = []
|
||||
|
||||
for part in self.allowed_parts:
|
||||
ratio = fuzz.partial_ratio(part.name + part.description, part_name)
|
||||
matches.append({'part': part, 'match': ratio})
|
||||
|
||||
if len(matches) > 0:
|
||||
matches = sorted(matches, key=lambda item: item['match'], reverse=True)
|
||||
|
||||
if d_idx >= 0:
|
||||
row['description'] = row['data'][d_idx]
|
||||
|
||||
if r_idx >= 0:
|
||||
row['reference'] = row['data'][r_idx]
|
||||
|
||||
if n_idx >= 0:
|
||||
row['notes'] = row['data'][n_idx]
|
||||
|
||||
row['quantity'] = quantity
|
||||
row['part_options'] = [m['part'] for m in matches]
|
||||
|
||||
def extractDataFromFile(self, bom):
|
||||
""" Read data from the BOM file """
|
||||
|
||||
self.bom_columns = bom.columns()
|
||||
self.bom_rows = bom.rows()
|
||||
|
||||
def getTableDataFromPost(self):
|
||||
""" Extract table cell data from POST request.
|
||||
These data are used to maintain state between sessions.
|
||||
|
||||
Table data keys are as follows:
|
||||
|
||||
col_name_<idx> - Column name at idx as provided in the uploaded file
|
||||
col_guess_<idx> - Column guess at idx as selected in the BOM
|
||||
row_<x>_col<y> - Cell data as provided in the uploaded file
|
||||
|
||||
"""
|
||||
|
||||
# Map the columns
|
||||
self.column_names = {}
|
||||
self.column_selections = {}
|
||||
|
||||
self.row_data = {}
|
||||
|
||||
for item in self.request.POST:
|
||||
value = self.request.POST[item]
|
||||
|
||||
# Column names as passed as col_name_<idx> where idx is an integer
|
||||
|
||||
# Extract the column names
|
||||
if item.startswith('col_name_'):
|
||||
try:
|
||||
col_id = int(item.replace('col_name_', ''))
|
||||
except ValueError:
|
||||
continue
|
||||
col_name = value
|
||||
|
||||
self.column_names[col_id] = col_name
|
||||
|
||||
# Extract the column selections (in the 'select fields' view)
|
||||
if item.startswith('col_guess_'):
|
||||
|
||||
try:
|
||||
col_id = int(item.replace('col_guess_', ''))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
col_name = value
|
||||
|
||||
self.column_selections[col_id] = value
|
||||
|
||||
# Extract the row data
|
||||
if item.startswith('row_'):
|
||||
# Item should be of the format row_<r>_col_<c>
|
||||
s = item.split('_')
|
||||
|
||||
if len(s) < 4:
|
||||
continue
|
||||
|
||||
# Ignore row/col IDs which are not correct numeric values
|
||||
try:
|
||||
row_id = int(s[1])
|
||||
col_id = int(s[3])
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if row_id not in self.row_data:
|
||||
self.row_data[row_id] = {}
|
||||
|
||||
self.row_data[row_id][col_id] = value
|
||||
|
||||
self.col_ids = sorted(self.column_names.keys())
|
||||
|
||||
# Re-construct the data table
|
||||
self.bom_rows = []
|
||||
|
||||
for row_idx in sorted(self.row_data.keys()):
|
||||
row = self.row_data[row_idx]
|
||||
items = []
|
||||
|
||||
for col_idx in sorted(row.keys()):
|
||||
|
||||
value = row[col_idx]
|
||||
items.append(value)
|
||||
|
||||
self.bom_rows.append({
|
||||
'index': row_idx,
|
||||
'data': items,
|
||||
'errors': {},
|
||||
})
|
||||
|
||||
# Construct the column data
|
||||
self.bom_columns = []
|
||||
|
||||
# Track any duplicate column selections
|
||||
self.duplicates = False
|
||||
|
||||
for col in self.col_ids:
|
||||
|
||||
if col in self.column_selections:
|
||||
guess = self.column_selections[col]
|
||||
else:
|
||||
guess = None
|
||||
|
||||
header = ({
|
||||
'name': self.column_names[col],
|
||||
'guess': guess
|
||||
})
|
||||
|
||||
if guess:
|
||||
n = list(self.column_selections.values()).count(self.column_selections[col])
|
||||
if n > 1:
|
||||
header['duplicate'] = True
|
||||
self.duplicates = True
|
||||
|
||||
self.bom_columns.append(header)
|
||||
|
||||
# Are there any missing columns?
|
||||
self.missing_columns = []
|
||||
|
||||
for col in BomUploadManager.REQUIRED_HEADERS:
|
||||
if col not in self.column_selections.values():
|
||||
self.missing_columns.append(col)
|
||||
|
||||
def handleFieldSelection(self):
|
||||
""" Handle the output of the field selection form.
|
||||
Here the user is presented with the raw data and must select the
|
||||
column names and which rows to process.
|
||||
"""
|
||||
|
||||
# Extract POST data
|
||||
self.getTableDataFromPost()
|
||||
|
||||
valid = len(self.missing_columns) == 0 and not self.duplicates
|
||||
|
||||
form = part_forms.BomUploadSelectFields
|
||||
|
||||
if valid:
|
||||
# Try to extract meaningful data
|
||||
self.preFillSelections()
|
||||
form = None
|
||||
self.template_name = 'part/bom_upload/select_parts.html'
|
||||
else:
|
||||
self.template_name = 'part/bom_upload/select_fields.html'
|
||||
|
||||
return self.render_to_response(self.get_context_data(form=form))
|
||||
|
||||
def handlePartSelection(self):
|
||||
|
||||
# Extract basic table data from POST request
|
||||
self.getTableDataFromPost()
|
||||
|
||||
# Keep track of the parts that have been selected
|
||||
parts = {}
|
||||
|
||||
# Extract other data (part selections, etc)
|
||||
for key in self.request.POST:
|
||||
value = self.request.POST[key]
|
||||
|
||||
# Extract quantity from each row
|
||||
if key.startswith('quantity_'):
|
||||
try:
|
||||
row_id = int(key.replace('quantity_', ''))
|
||||
|
||||
row = self.getRowByIndex(row_id)
|
||||
|
||||
if row is None:
|
||||
continue
|
||||
|
||||
q = 1
|
||||
|
||||
try:
|
||||
q = int(value)
|
||||
if q <= 0:
|
||||
row['errors']['quantity'] = _('Quantity must be greater than zero')
|
||||
except ValueError:
|
||||
row['errors']['quantity'] = _('Enter a valid quantity')
|
||||
|
||||
row['quantity'] = q
|
||||
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Extract part from each row
|
||||
if key.startswith('part_'):
|
||||
try:
|
||||
row_id = int(key.replace('part_', ''))
|
||||
|
||||
row = self.getRowByIndex(row_id)
|
||||
|
||||
if row is None:
|
||||
continue
|
||||
except ValueError:
|
||||
# Row ID non integer value
|
||||
continue
|
||||
|
||||
try:
|
||||
part_id = int(value)
|
||||
part = Part.objects.get(id=part_id)
|
||||
except ValueError:
|
||||
row['errors']['part'] = _('Select valid part')
|
||||
continue
|
||||
except Part.DoesNotExist:
|
||||
row['errors']['part'] = _('Select valid part')
|
||||
continue
|
||||
|
||||
# Keep track of how many of each part we have seen
|
||||
if part_id in parts:
|
||||
parts[part_id]['quantity'] += 1
|
||||
row['errors']['part'] = _('Duplicate part selected')
|
||||
else:
|
||||
parts[part_id] = {
|
||||
'part': part,
|
||||
'quantity': 1,
|
||||
}
|
||||
|
||||
row['part'] = part
|
||||
|
||||
# Extract other fields which do not require further validation
|
||||
for field in ['reference', 'notes']:
|
||||
if key.startswith(field + '_'):
|
||||
try:
|
||||
row_id = int(key.replace(field + '_', ''))
|
||||
|
||||
row = self.getRowByIndex(row_id)
|
||||
|
||||
if row:
|
||||
row[field] = value
|
||||
except:
|
||||
continue
|
||||
|
||||
# Are there any errors after form handling?
|
||||
valid = True
|
||||
|
||||
for row in self.bom_rows:
|
||||
# Has a part been selected for the given row?
|
||||
if row.get('part', None) is None:
|
||||
row['errors']['part'] = _('Select a part')
|
||||
|
||||
# Has a quantity been specified?
|
||||
if row.get('quantity', None) is None:
|
||||
row['errors']['quantity'] = _('Specify quantity')
|
||||
|
||||
errors = row.get('errors', [])
|
||||
|
||||
if len(errors) > 0:
|
||||
valid = False
|
||||
|
||||
self.template_name = 'part/bom_upload/select_parts.html'
|
||||
|
||||
ctx = self.get_context_data(form=None)
|
||||
|
||||
if valid:
|
||||
self.part.clear_bom()
|
||||
|
||||
# Generate new BOM items
|
||||
for row in self.bom_rows:
|
||||
part = row.get('part')
|
||||
quantity = row.get('quantity')
|
||||
reference = row.get('reference', '')
|
||||
notes = row.get('notes', '')
|
||||
|
||||
# Create a new BOM item!
|
||||
item = BomItem(
|
||||
part=self.part,
|
||||
sub_part=part,
|
||||
quantity=quantity,
|
||||
reference=reference,
|
||||
note=notes
|
||||
)
|
||||
|
||||
item.save()
|
||||
|
||||
# Redirect to the BOM view
|
||||
return HttpResponseRedirect(reverse('part-bom', kwargs={'pk': self.part.id}))
|
||||
else:
|
||||
ctx['form_errors'] = True
|
||||
|
||||
return self.render_to_response(ctx)
|
||||
|
||||
def getRowByIndex(self, idx):
|
||||
|
||||
for row in self.bom_rows:
|
||||
if row['index'] == idx:
|
||||
return row
|
||||
|
||||
return None
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""
|
||||
User has now submitted the BOM export data
|
||||
""" Perform the various 'POST' requests required.
|
||||
"""
|
||||
|
||||
# part = self.get_object()
|
||||
self.request = request
|
||||
|
||||
return super(AjaxView, self).post(request, *args, **kwargs)
|
||||
self.part = get_object_or_404(Part, pk=self.kwargs['pk'])
|
||||
self.allowed_parts = self.getAllowedParts()
|
||||
self.form = self.get_form(self.get_form_class())
|
||||
|
||||
def get_data(self):
|
||||
return {
|
||||
# 'form_valid': True,
|
||||
# 'redirect': '/'
|
||||
# 'redirect': reverse('bom-download', kwargs={'pk': self.request.GET.get('pk')})
|
||||
}
|
||||
# Did the user POST a file named bom_file?
|
||||
|
||||
form_step = request.POST.get('form_step', None)
|
||||
|
||||
if form_step == 'select_file':
|
||||
return self.handleBomFileUpload()
|
||||
elif form_step == 'select_fields':
|
||||
return self.handleFieldSelection()
|
||||
elif form_step == 'select_parts':
|
||||
return self.handlePartSelection()
|
||||
|
||||
return self.render_to_response(self.get_context_data(form=self.form))
|
||||
|
||||
|
||||
class BomUploadTemplate(AjaxView):
|
||||
"""
|
||||
Provide a BOM upload template file for download.
|
||||
- Generates a template file in the provided format e.g. ?format=csv
|
||||
"""
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
|
||||
export_format = request.GET.get('format', 'csv')
|
||||
|
||||
return MakeBomTemplate(export_format)
|
||||
|
||||
|
||||
class BomDownload(AjaxView):
|
||||
@ -654,8 +1178,6 @@ class BomDownload(AjaxView):
|
||||
- File format should be passed as a query param e.g. ?format=csv
|
||||
"""
|
||||
|
||||
# TODO - This should no longer extend an AjaxView!
|
||||
|
||||
model = Part
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
@ -908,16 +1430,24 @@ class BomItemCreate(AjaxCreateView):
|
||||
try:
|
||||
part = Part.objects.get(id=part_id)
|
||||
|
||||
# Only allow active parts to be selected
|
||||
query = form.fields['part'].queryset.filter(active=True)
|
||||
form.fields['part'].queryset = query
|
||||
|
||||
# Don't allow selection of sub_part objects which are already added to the Bom!
|
||||
query = form.fields['sub_part'].queryset
|
||||
|
||||
# Don't allow a part to be added to its own BOM
|
||||
query = query.exclude(id=part.id)
|
||||
query = query.filter(active=True)
|
||||
|
||||
# Eliminate any options that are already in the BOM!
|
||||
query = query.exclude(id__in=[item.id for item in part.required_parts()])
|
||||
|
||||
form.fields['sub_part'].queryset = query
|
||||
|
||||
form.fields['part'].widget = HiddenInput()
|
||||
|
||||
except Part.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
@ -77,6 +77,10 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bomselect {
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
/* Part image icons with full-display on mouse hover */
|
||||
|
||||
.hover-img-thumb {
|
||||
|
@ -12,47 +12,79 @@ function reloadBomTable(table, options) {
|
||||
}
|
||||
|
||||
|
||||
function downloadBom(options = {}) {
|
||||
function removeRowFromBomWizard(e) {
|
||||
/* Remove a row from BOM upload wizard
|
||||
*/
|
||||
|
||||
var modal = options.modal || "#modal-form";
|
||||
e = e || window.event;
|
||||
|
||||
var content = `
|
||||
<b>Select file format</b><br>
|
||||
<div class='controls'>
|
||||
<select id='bom-format' class='select'>
|
||||
<option value='csv'>CSV</option>
|
||||
<option value='tsv'>TSV</option>
|
||||
<option value='xls'>XLS</option>
|
||||
<option value='xlsx'>XLSX</option>
|
||||
<option value='ods'>ODS</option>
|
||||
<option value='yaml'>YAML</option>
|
||||
<option value='json'>JSON</option>
|
||||
<option value='xml'>XML</option>
|
||||
<option value='html'>HTML</option>
|
||||
</select>
|
||||
</div>
|
||||
`;
|
||||
var src = e.target || e.srcElement;
|
||||
|
||||
openModal({
|
||||
modal: modal,
|
||||
title: "Export Bill of Materials",
|
||||
submit_text: "Download",
|
||||
close_text: "Cancel",
|
||||
var table = $(src).closest('table');
|
||||
|
||||
// Which column was clicked?
|
||||
var row = $(src).closest('tr');
|
||||
|
||||
row.remove();
|
||||
|
||||
var rowNum = 1;
|
||||
var colNum = 0;
|
||||
|
||||
table.find('tr').each(function() {
|
||||
|
||||
colNum++;
|
||||
|
||||
if (colNum >= 3) {
|
||||
var cell = $(this).find('td:eq(1)');
|
||||
cell.text(rowNum++);
|
||||
console.log("Row: " + rowNum);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
modalSetContent(modal, content);
|
||||
|
||||
modalEnable(modal, true);
|
||||
function removeColFromBomWizard(e) {
|
||||
/* Remove a column from BOM upload wizard
|
||||
*/
|
||||
|
||||
$(modal).on('click', '#modal-form-submit', function() {
|
||||
$(modal).modal('hide');
|
||||
e = e || window.event;
|
||||
|
||||
var format = $(modal).find('#bom-format :selected').val();
|
||||
var src = e.target || e.srcElement;
|
||||
|
||||
if (options.url) {
|
||||
var url = options.url + "?format=" + format;
|
||||
// Which column was clicked?
|
||||
var col = $(src).closest('th').index();
|
||||
|
||||
location.href = url;
|
||||
var table = $(src).closest('table');
|
||||
|
||||
table.find('tr').each(function() {
|
||||
this.removeChild(this.cells[col]);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function newPartFromBomWizard(e) {
|
||||
/* Create a new part directly from the BOM wizard.
|
||||
*/
|
||||
|
||||
e = e || window.event;
|
||||
|
||||
var src = e.target || e.srcElement;
|
||||
|
||||
var row = $(src).closest('tr');
|
||||
|
||||
launchModalForm('/part/new/', {
|
||||
data: {
|
||||
'description': row.attr('part-description'),
|
||||
'name': row.attr('part-name'),
|
||||
},
|
||||
success: function(response) {
|
||||
/* A new part has been created! Push it as an option.
|
||||
*/
|
||||
|
||||
var select = row.attr('part-select');
|
||||
|
||||
var option = new Option(response.text, response.pk, true, true);
|
||||
$(select).append(option).trigger('change');
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -78,13 +110,16 @@ function loadBomTable(table, options) {
|
||||
title: 'ID',
|
||||
visible: false,
|
||||
},
|
||||
{
|
||||
];
|
||||
|
||||
if (options.editable) {
|
||||
cols.push({
|
||||
checkbox: true,
|
||||
title: 'Select',
|
||||
searchable: false,
|
||||
sortable: false,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
// Part column
|
||||
cols.push(
|
||||
@ -106,11 +141,18 @@ function loadBomTable(table, options) {
|
||||
}
|
||||
);
|
||||
|
||||
// Part reference
|
||||
cols.push({
|
||||
field: 'reference',
|
||||
title: 'Reference',
|
||||
searchable: true,
|
||||
sortable: true,
|
||||
});
|
||||
|
||||
// Part quantity
|
||||
cols.push(
|
||||
{
|
||||
cols.push({
|
||||
field: 'quantity',
|
||||
title: 'Required',
|
||||
title: 'Quantity',
|
||||
searchable: false,
|
||||
sortable: true,
|
||||
formatter: function(value, row, index, field) {
|
||||
@ -131,8 +173,7 @@ function loadBomTable(table, options) {
|
||||
|
||||
return quantity;
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
if (!options.editable) {
|
||||
cols.push(
|
||||
@ -192,7 +233,7 @@ function loadBomTable(table, options) {
|
||||
var bEdit = "<button title='Edit BOM Item' class='bom-edit-button btn btn-default btn-glyph' type='button' url='/part/bom/" + row.pk + "/edit'><span class='glyphicon glyphicon-edit'/></button>";
|
||||
var bDelt = "<button title='Delete BOM Item' class='bom-delete-button btn btn-default btn-glyph' type='button' url='/part/bom/" + row.pk + "/delete'><span class='glyphicon glyphicon-trash'/></button>";
|
||||
|
||||
return "<div class='btn-group'>" + bEdit + bDelt + "</div>";
|
||||
return "<div class='btn-group' role='group'>" + bEdit + bDelt + "</div>";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -130,6 +130,11 @@ function loadStockTable(table, options) {
|
||||
|
||||
name += row.part__name;
|
||||
|
||||
if (row.part__revision) {
|
||||
name += " | ";
|
||||
name += row.part__revision;
|
||||
}
|
||||
|
||||
return imageHoverIcon(row.part__image) + renderLink(name, '/part/' + row.part + '/stock/');
|
||||
}
|
||||
},
|
||||
|
@ -295,6 +295,7 @@ class StockList(generics.ListCreateAPIView):
|
||||
'part',
|
||||
'part__IPN',
|
||||
'part__name',
|
||||
'part__revision',
|
||||
'part__description',
|
||||
'part__image',
|
||||
'part__category',
|
||||
|
@ -99,6 +99,7 @@ InvenTree
|
||||
|
||||
<script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script>
|
||||
<script type='text/javascript' src="{% static 'script/inventree/api.js' %}"></script>
|
||||
<script type='text/javascript' src="{% static 'script/inventree/bom.js' %}"></script>
|
||||
<script type='text/javascript' src="{% static 'script/inventree/tables.js' %}"></script>
|
||||
<script type='text/javascript' src="{% static 'script/inventree/modals.js' %}"></script>
|
||||
<script type='text/javascript' src="{% static 'script/inventree/order.js' %}"></script>
|
||||
|
Loading…
Reference in New Issue
Block a user