Major improvements to BOM upload

- Column headings now match the values provided in BOM import template
- Add a new column for part selection, while displaying all imported data
- Better code documentation
- Improve data validation
- Allow decimal quantity (not just integer!)
- Better matching logic
This commit is contained in:
Oliver Walters 2020-08-18 14:01:01 +10:00
parent 7349b396ca
commit 68fb599c73
4 changed files with 157 additions and 84 deletions

View File

@ -102,26 +102,23 @@ class BomUploadManager:
# Fields which are absolutely necessary for valid upload # Fields which are absolutely necessary for valid upload
REQUIRED_HEADERS = [ REQUIRED_HEADERS = [
'Part', 'Part_Name',
'Quantity' 'Quantity'
] ]
# Fields which would be helpful but are not required # Fields which would be helpful but are not required
OPTIONAL_HEADERS = [ OPTIONAL_HEADERS = [
'Part_IPN',
'Part_ID',
'Reference', 'Reference',
'Notes', 'Note',
'Overage', 'Overage',
'Description',
'Category',
'Supplier',
'Manufacturer',
'MPN',
'IPN',
] ]
EDITABLE_HEADERS = [ EDITABLE_HEADERS = [
'Reference', 'Reference',
'Notes' 'Note',
'Overage'
] ]
HEADERS = REQUIRED_HEADERS + OPTIONAL_HEADERS HEADERS = REQUIRED_HEADERS + OPTIONAL_HEADERS
@ -171,6 +168,11 @@ class BomUploadManager:
if h.lower() == header.lower(): if h.lower() == header.lower():
return h return h
# Try for a case-insensitive match with space replacement
for h in self.HEADERS:
if h.lower() == header.lower().replace(' ', '_'):
return h
# Finally, look for a close match using fuzzy matching # Finally, look for a close match using fuzzy matching
matches = [] matches = []

View File

@ -1,17 +1,18 @@
{% extends "part/part_base.html" %} {% extends "part/part_base.html" %}
{% load static %} {% load static %}
{% load i18n %}
{% load inventree_extras %} {% load inventree_extras %}
{% block details %} {% block details %}
{% include "part/tabs.html" with tab='bom' %} {% include "part/tabs.html" with tab='bom' %}
<h4>Upload Bill of Materials</h4> <h4>{% trans "Upload Bill of Materials" %}</h4>
<p>Step 2 - Select Fields</p> <p>{% trans "Step 2 - Select Fields" %}</p>
<hr> <hr>
{% if missing_columns and missing_columns|length > 0 %} {% if missing_columns and missing_columns|length > 0 %}
<div class='alert alert-danger alert-block' role='alert'> <div class='alert alert-danger alert-block' role='alert'>
Missing selections for the following required columns: {% trans "Missing selections for the following required columns" %}:
<br> <br>
<ul> <ul>
{% for col in missing_columns %} {% for col in missing_columns %}
@ -22,7 +23,7 @@
{% endif %} {% endif %}
<form method="post" action='' class='js-modal-form' enctype="multipart/form-data"> <form method="post" action='' class='js-modal-form' enctype="multipart/form-data">
<button type="submit" class="save btn btn-default">Submit Selections</button> <button type="submit" class="save btn btn-default">{% trans "Submit Selections" %}</button>
{% csrf_token %} {% csrf_token %}
<input type='hidden' name='form_step' value='select_fields'/> <input type='hidden' name='form_step' value='select_fields'/>
@ -31,7 +32,7 @@
<thead> <thead>
<tr> <tr>
<th></th> <th></th>
<th>Row</th> <th>{% trans "File Fields" %}</th>
{% for col in bom_columns %} {% for col in bom_columns %}
<th> <th>
<div> <div>
@ -48,7 +49,7 @@
<tbody> <tbody>
<tr> <tr>
<td></td> <td></td>
<td></td> <td>{% trans "Match Fields" %}</td>
{% for col in bom_columns %} {% for col in bom_columns %}
<td> <td>
<select class='select' id='id_col_{{ forloop.counter0 }}' name='col_guess_{{ forloop.counter0 }}'> <select class='select' id='id_col_{{ forloop.counter0 }}' name='col_guess_{{ forloop.counter0 }}'>

View File

@ -1,23 +1,24 @@
{% extends "part/part_base.html" %} {% extends "part/part_base.html" %}
{% load static %} {% load static %}
{% load i18n %}
{% load inventree_extras %} {% load inventree_extras %}
{% block details %} {% block details %}
{% include "part/tabs.html" with tab="bom" %} {% include "part/tabs.html" with tab="bom" %}
<h4>Upload Bill of Materials</h4> <h4>{% trans "Upload Bill of Materials" %}</h4>
<p>Step 3 - Select Parts</p> <p>{% trans "Step 3 - Select Parts" %}</p>
<hr> <hr>
{% if form_errors %} {% if form_errors %}
<div class='alert alert-danger alert-block' role='alert'> <div class='alert alert-danger alert-block' role='alert'>
Errors exist in the submitted data. {% trans "Errors exist in the submitted data" %}
</div> </div>
{% endif %} {% endif %}
<form method="post" action='' class='js-modal-form' enctype="multipart/form-data"> <form method="post" action='' class='js-modal-form' enctype="multipart/form-data">
<button type="submit" class="save btn btn-default">Submit BOM</button> <button type="submit" class="save btn btn-default">{% trans "Submit BOM" %}</button>
{% csrf_token %} {% csrf_token %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
@ -29,7 +30,8 @@
<tr> <tr>
<th></th> <th></th>
<th></th> <th></th>
<th>Row</th> <th>{% trans "Row" %}</th>
<th>{% trans "Select Part" %}</th>
{% for col in bom_columns %} {% for col in bom_columns %}
<th> <th>
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/> <input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
@ -51,39 +53,39 @@
<span row_id='{{ forloop.counter }}' class='fas fa-trash-alt icon-red'></span> <span row_id='{{ forloop.counter }}' class='fas fa-trash-alt icon-red'></span>
</button> </button>
</td> </td>
<td> <td></td>
</td>
<td>{% add row.index 1 %}</td> <td>{% add row.index 1 %}</td>
<td>
<button class='btn btn-default btn-create' onClick='newPartFromBomWizard()' id='new_part_row_{{ row.index }}' title='Create new part' type='button'>
<span row_id='{{ row.index }}' class='fas fa-plus icon-green'/>
</button>
<select class='select bomselect' id='select_part_{{ row.index }}' name='part_{{ row.index }}'>
<option value=''>--- {% trans "Select Part" %} ---</option>
{% for part in row.part_options %}
<option value='{{ part.id }}' {% if part.id == row.part.id %} selected='selected' {% elif part.id == row.part_match.id %} selected='selected' {% endif %}>
{{ part.full_name }} - {{ part.description }}
</option>
{% endfor %}
</select>
{% if row.errors.part %}
<p class='help-inline'>{{ row.errors.part }}</p>
{% endif %}
</td>
{% for item in row.data %} {% for item in row.data %}
<td> <td>
{% if item.column.guess == 'Part' %} {% if item.column.guess == 'Part' %}
<button class='btn btn-default btn-create' onClick='newPartFromBomWizard()' id='new_part_row_{{ row.index }}' title='Create new part' type='button'>
<span row_id='{{ row.index }}' class='fas fa-trash-alt icon-red'/>
</button>
<select class='select bomselect' id='select_part_{{ row.index }}' name='part_{{ row.index }}'>
{% if row.part_match %}
{% with row.part_match as part %}
<option value='{{ part.id }}'{% if part.id == row.part.id %} selected='selected'{% endif %}>{{ part.full_name }} - {{ part.description }}</option>
{% endwith %}
{% endif %}
<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> <i>{{ item.cell }}</i>
{% if row.errors.part %} {% if row.errors.part %}
<p class='help-inline'>{{ row.errors.part }}</p> <p class='help-inline'>{{ row.errors.part }}</p>
{% endif %} {% endif %}
{% elif item.column.guess == 'Quantity' %} {% elif item.column.guess == 'Quantity' %}
<input name='quantity_{{ row.index }}' class='numberinput' type='number' min='1' value='{{ row.quantity }}'/> <input name='quantity_{{ row.index }}' class='numberinput' type='number' min='1' step='any' value='{% decimal row.quantity %}'/>
{% if row.errors.quantity %} {% if row.errors.quantity %}
<p class='help-inline'>{{ row.errors.quantity }}</p> <p class='help-inline'>{{ row.errors.quantity }}</p>
{% endif %} {% endif %}
{% elif item.column.guess == 'Reference' %} {% elif item.column.guess == 'Reference' %}
<input name='reference_{{ row.index }}' value='{{ row.reference }}'/> <input name='reference_{{ row.index }}' value='{{ row.reference }}'/>
{% elif item.column.guess == 'Notes' %} {% elif item.column.guess == 'Note' %}
<input name='notes_{{ row.index }}' value='{{ row.notes }}'/> <input name='notes_{{ row.index }}' value='{{ row.notes }}'/>
{% elif item.column.guess == 'Overage' %} {% elif item.column.guess == 'Overage' %}
<input name='overage_{{ row.index }}' value='{{ row.overage }}'/> <input name='overage_{{ row.index }}' value='{{ row.overage }}'/>

View File

@ -19,7 +19,7 @@ from django.conf import settings
import os import os
from rapidfuzz import fuzz from rapidfuzz import fuzz
from decimal import Decimal from decimal import Decimal, InvalidOperation
from .models import PartCategory, Part, PartAttachment from .models import PartCategory, Part, PartAttachment
from .models import PartParameterTemplate, PartParameter from .models import PartParameterTemplate, PartParameter
@ -948,81 +948,133 @@ class BomUpload(FormView):
The pre-fill data are then passed through to the part selection form. The pre-fill data are then passed through to the part selection form.
""" """
# Fields prefixed with "Part_" can be used to do "smart matching" against Part objects in the database
k_idx = self.getColumnIndex('Part_ID')
p_idx = self.getColumnIndex('Part_Name')
i_idx = self.getColumnIndex('Part_IPN')
q_idx = self.getColumnIndex('Quantity') q_idx = self.getColumnIndex('Quantity')
p_idx = self.getColumnIndex('Part')
i_idx = self.getColumnIndex('IPN')
d_idx = self.getColumnIndex('Description')
r_idx = self.getColumnIndex('Reference') r_idx = self.getColumnIndex('Reference')
n_idx = self.getColumnIndex('Notes') o_idx = self.getColumnIndex('Overage')
n_idx = self.getColumnIndex('Note')
for row in self.bom_rows: for row in self.bom_rows:
"""
quantity = 0 Iterate through each row in the uploaded data,
part = None and see if we can match the row to a "Part" object in the database.
There are three potential ways to match, based on the uploaded data:
a) Use the PK (primary key) field for the part, uploaded in the "Part_ID" field
b) Use the IPN (internal part number) field for the part, uploaded in the "Part_IPN" field
c) Use the name of the part, uploaded in the "Part_Name" field
Notes:
- If using the Part_ID field, we can do an exact match against the PK field
- If using the Part_IPN field, we can do an exact match against the IPN field
- If using the Part_Name field, we can use fuzzy string matching to match "close" values
We also extract other information from the row, for the other non-matched fields:
- Quantity
- Reference
- Overage
- Note
"""
# Initially use a quantity of zero
quantity = Decimal(0)
# Initially we do not have a part to reference
exact_match_part = None
# A list of potential Part matches
part_options = self.allowed_parts
# Check if there is a column corresponding to "quantity"
if q_idx >= 0: if q_idx >= 0:
q_val = row['data'][q_idx] q_val = row['data'][q_idx]
if q_val:
try: try:
quantity = int(q_val) # Attempt to extract a valid quantity from the field
except ValueError: quantity = Decimal(q_val)
except (ValueError, InvalidOperation):
pass pass
# Store the 'quantity' value
row['quantity'] = quantity
# Check if there is a column corresponding to "PK"
if k_idx >= 0:
pk = row['data'][k_idx]
if pk:
try:
# Attempt Part lookup based on PK value
exact_match_part = Part.objects.get(pk=pk)
except (ValueError, Part.DoesNotExist):
exact_match_part = None
# Check if there is a column corresponding to "Part Name"
if p_idx >= 0: if p_idx >= 0:
part_name = row['data'][p_idx] part_name = row['data'][p_idx]
row['part_name'] = part_name row['part_name'] = part_name
# Fuzzy match the values and see what happens
matches = [] matches = []
for part in self.allowed_parts: for part in self.allowed_parts:
ratio = fuzz.partial_ratio(part.name + part.description, part_name) ratio = fuzz.partial_ratio(part.name + part.description, part_name)
matches.append({'part': part, 'match': ratio}) matches.append({'part': part, 'match': ratio})
# Sort matches by the 'strength' of the match ratio
if len(matches) > 0: if len(matches) > 0:
matches = sorted(matches, key=lambda item: item['match'], reverse=True) matches = sorted(matches, key=lambda item: item['match'], reverse=True)
part_options = [m['part'] for m in matches]
# Check if there is a column corresponding to "Part IPN"
if i_idx >= 0: if i_idx >= 0:
row['part_ipn'] = row['data'][i_idx] row['part_ipn'] = row['data'][i_idx]
if d_idx >= 0: # Check if there is a column corresponding to "Overage" field
row['description'] = row['data'][d_idx] if o_idx >= 0:
row['overage'] = row['data'][o_idx]
# Check if there is a column corresponding to "Reference" field
if r_idx >= 0: if r_idx >= 0:
row['reference'] = row['data'][r_idx] row['reference'] = row['data'][r_idx]
# Check if there is a column corresponding to "Note" field
if n_idx >= 0: if n_idx >= 0:
row['notes'] = row['data'][n_idx] row['note'] = row['data'][n_idx]
row['quantity'] = quantity # Supply list of part options for each row, sorted by how closely they match the part name
row['part_options'] = part_options
# Part selection using IPN # Unless found, the 'part_match' is blank
row['part_match'] = None
if exact_match_part:
# If there is an exact match based on PK, use that
row['part_match'] = exact_match_part
else:
# Otherwise, check to see if there is a matching IPN
try: try:
if row['part_ipn']: if row['part_ipn']:
part_matches = [part for part in self.allowed_parts if row['part_ipn'] == part.IPN] part_matches = [part for part in self.allowed_parts if row['part_ipn'].lower() == part.IPN.lower()]
part_options = [part for part in self.allowed_parts if part not in part_matches]
# Check for single match # Check for single match
if len(part_matches) == 1: if len(part_matches) == 1:
row['part_match'] = part_matches[0] row['part_match'] = part_matches[0]
row['part_options'] = part_options
continue continue
except KeyError: except KeyError:
pass pass
# Part selection using Part Name print(row, row['part_match'], len(row['part_options']))
match_limit = 100
part_matches = [m['part'] for m in matches if m['match'] >= match_limit]
# Check for single match
if len(part_matches) == 1:
row['part_match'] = part_matches[0]
row['part_options'] = [m['part'] for m in matches if m['match'] < match_limit]
else:
row['part_options'] = [m['part'] for m in matches]
def extractDataFromFile(self, bom): def extractDataFromFile(self, bom):
""" Read data from the BOM file """ """ Read data from the BOM file """
@ -1190,13 +1242,20 @@ class BomUpload(FormView):
if row is None: if row is None:
continue continue
q = 1 q = Decimal(1)
try: try:
q = int(value) q = Decimal(value)
if q <= 0: if q < 0:
row['errors']['quantity'] = _('Quantity must be greater than zero') row['errors']['quantity'] = _('Quantity must be greater than zero')
except ValueError:
if 'part' in row.keys():
if row['part'].trackable:
# Trackable parts must use integer quantities
if not q == int(q):
row['errors']['quantity'] = _('Quantity must be integer value for trackable parts')
except (ValueError, InvalidOperation):
row['errors']['quantity'] = _('Enter a valid quantity') row['errors']['quantity'] = _('Enter a valid quantity')
row['quantity'] = q row['quantity'] = q
@ -1206,6 +1265,7 @@ class BomUpload(FormView):
# Extract part from each row # Extract part from each row
if key.startswith('part_'): if key.startswith('part_'):
try: try:
row_id = int(key.replace('part_', '')) row_id = int(key.replace('part_', ''))
@ -1239,6 +1299,14 @@ class BomUpload(FormView):
row['part'] = part row['part'] = part
if part.trackable:
# For trackable parts, ensure the quantity is an integer value!
if 'quantity' in row.keys():
q = row['quantity']
if not q == int(q):
row['errors']['quantity'] = _('Quantity must be integer value for trackable parts')
# Extract other fields which do not require further validation # Extract other fields which do not require further validation
for field in ['reference', 'notes']: for field in ['reference', 'notes']:
if key.startswith(field + '_'): if key.startswith(field + '_'):