2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-19 05:25:42 +00:00

Merge branch 'master' of https://github.com/inventree/InvenTree into template-reduce-duplication

This commit is contained in:
Matthias
2022-02-12 00:55:39 +01:00
94 changed files with 27157 additions and 23686 deletions

View File

@ -446,10 +446,10 @@ class PartSerialNumberDetail(generics.RetrieveAPIView):
}
if latest is not None:
next = increment(latest)
next_serial = increment(latest)
if next != increment:
data['next'] = next
if next_serial != increment:
data['next'] = next_serial
return Response(data)
@ -1533,6 +1533,40 @@ class BomList(generics.ListCreateAPIView):
]
class BomExtract(generics.CreateAPIView):
"""
API endpoint for extracting BOM data from a BOM file.
"""
queryset = Part.objects.none()
serializer_class = part_serializers.BomExtractSerializer
def create(self, request, *args, **kwargs):
"""
Custom create function to return the extracted data
"""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
headers = self.get_success_headers(serializer.data)
data = serializer.extract_data()
return Response(data, status=status.HTTP_201_CREATED, headers=headers)
class BomUpload(generics.CreateAPIView):
"""
API endpoint for uploading a complete Bill of Materials.
It is assumed that the BOM has been extracted from a file using the BomExtract endpoint.
"""
queryset = Part.objects.all()
serializer_class = part_serializers.BomUploadSerializer
class BomDetail(generics.RetrieveUpdateDestroyAPIView):
""" API endpoint for detail view of a single BomItem object """
@ -1685,6 +1719,10 @@ bom_api_urls = [
url(r'^.*$', BomDetail.as_view(), name='api-bom-item-detail'),
])),
url(r'^extract/', BomExtract.as_view(), name='api-bom-extract'),
url(r'^upload/', BomUpload.as_view(), name='api-bom-upload'),
# Catch-all
url(r'^.*$', BomList.as_view(), name='api-bom-list'),
]

View File

@ -123,16 +123,22 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
stock_headers = [
_('Default Location'),
_('Total Stock'),
_('Available Stock'),
_('On Order'),
]
stock_cols = {}
for b_idx, bom_item in enumerate(bom_items):
stock_data = []
sub_part = bom_item.sub_part
# Get part default location
try:
loc = bom_item.sub_part.get_default_location()
loc = sub_part.get_default_location()
if loc is not None:
stock_data.append(str(loc.name))
@ -141,8 +147,20 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
except AttributeError:
stock_data.append('')
# Get part current stock
stock_data.append(str(normalize(bom_item.sub_part.available_stock)))
# Total "in stock" quantity for this part
stock_data.append(
str(normalize(sub_part.total_stock))
)
# Total "available stock" quantity for this part
stock_data.append(
str(normalize(sub_part.available_stock))
)
# Total "on order" quantity for this part
stock_data.append(
str(normalize(sub_part.on_order))
)
for s_idx, header in enumerate(stock_headers):
try:
@ -205,7 +223,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
supplier_parts_used.add(sp_part)
if sp_part.supplier and sp_part.supplier:
if sp_part.supplier:
supplier_name = sp_part.supplier.name
else:
supplier_name = ''

View File

@ -75,7 +75,6 @@ class BomMatchItemForm(MatchItemForm):
})
)
# return default
return super().get_special_field(col_guess, row, file_manager)

View File

@ -1530,15 +1530,15 @@ class Part(MPTTModel):
returns a string representation of a hash object which can be compared with a stored value
"""
hash = hashlib.md5(str(self.id).encode())
result_hash = hashlib.md5(str(self.id).encode())
# List *all* BOM items (including inherited ones!)
bom_items = self.get_bom_items().all().prefetch_related('sub_part')
for item in bom_items:
hash.update(str(item.get_item_hash()).encode())
result_hash.update(str(item.get_item_hash()).encode())
return str(hash.digest())
return str(result_hash.digest())
def is_bom_valid(self):
""" Check if the BOM is 'valid' - if the calculated checksum matches the stored value
@ -2188,9 +2188,7 @@ def after_save_part(sender, instance: Part, created, **kwargs):
Function to be executed after a Part is saved
"""
if created:
pass
else:
if not created:
# Check part stock only if we are *updating* the part (not creating it)
# Run this check in the background
@ -2678,18 +2676,18 @@ class BomItem(models.Model):
"""
# Seed the hash with the ID of this BOM item
hash = hashlib.md5(str(self.id).encode())
result_hash = hashlib.md5(str(self.id).encode())
# Update the hash based on line information
hash.update(str(self.sub_part.id).encode())
hash.update(str(self.sub_part.full_name).encode())
hash.update(str(self.quantity).encode())
hash.update(str(self.note).encode())
hash.update(str(self.reference).encode())
hash.update(str(self.optional).encode())
hash.update(str(self.inherited).encode())
result_hash.update(str(self.sub_part.id).encode())
result_hash.update(str(self.sub_part.full_name).encode())
result_hash.update(str(self.quantity).encode())
result_hash.update(str(self.note).encode())
result_hash.update(str(self.reference).encode())
result_hash.update(str(self.optional).encode())
result_hash.update(str(self.inherited).encode())
return str(hash.digest())
return str(result_hash.digest())
def validate_hash(self, valid=True):
""" Mark this item as 'valid' (store the checksum hash).

View File

@ -4,9 +4,11 @@ JSON serializers for Part app
import imghdr
from decimal import Decimal
import os
import tablib
from django.urls import reverse_lazy
from django.db import models
from django.db import models, transaction
from django.db.models import Q
from django.db.models.functions import Coalesce
from django.utils.translation import ugettext_lazy as _
@ -462,7 +464,13 @@ class BomItemSerializer(InvenTreeModelSerializer):
price_range = serializers.CharField(read_only=True)
quantity = InvenTreeDecimalField()
quantity = InvenTreeDecimalField(required=True)
def validate_quantity(self, quantity):
if quantity <= 0:
raise serializers.ValidationError(_("Quantity must be greater than zero"))
return quantity
part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(assembly=True))
@ -699,3 +707,345 @@ class PartCopyBOMSerializer(serializers.Serializer):
skip_invalid=data.get('skip_invalid', False),
include_inherited=data.get('include_inherited', False),
)
class BomExtractSerializer(serializers.Serializer):
"""
Serializer for uploading a file and extracting data from it.
Note: 2022-02-04 - This needs a *serious* refactor in future, probably
When parsing the file, the following things happen:
a) Check file format and validity
b) Look for "required" fields
c) Look for "part" fields - used to "infer" part
Once the file itself has been validated, we iterate through each data row:
- If the "level" column is provided, ignore anything below level 1
- Try to "guess" the part based on part_id / part_name / part_ipn
- Extract other fields as required
"""
class Meta:
fields = [
'bom_file',
'part',
'clear_existing',
]
# These columns must be present
REQUIRED_COLUMNS = [
'quantity',
]
# We need at least one column to specify a "part"
PART_COLUMNS = [
'part',
'part_id',
'part_name',
'part_ipn',
]
# These columns are "optional"
OPTIONAL_COLUMNS = [
'allow_variants',
'inherited',
'optional',
'overage',
'note',
'reference',
]
def find_matching_column(self, col_name, columns):
# Direct match
if col_name in columns:
return col_name
col_name = col_name.lower().strip()
for col in columns:
if col.lower().strip() == col_name:
return col
# No match
return None
def find_matching_data(self, row, col_name, columns):
"""
Extract data from the row, based on the "expected" column name
"""
col_name = self.find_matching_column(col_name, columns)
return row.get(col_name, None)
bom_file = serializers.FileField(
label=_("BOM File"),
help_text=_("Select Bill of Materials file"),
required=True,
allow_empty_file=False,
)
def validate_bom_file(self, bom_file):
"""
Perform validation checks on the uploaded BOM file
"""
self.filename = bom_file.name
name, ext = os.path.splitext(bom_file.name)
# Remove the leading . from the extension
ext = ext[1:]
accepted_file_types = [
'xls', 'xlsx',
'csv', 'tsv',
'xml',
]
if ext not in accepted_file_types:
raise serializers.ValidationError(_("Unsupported file type"))
# Impose a 50MB limit on uploaded BOM files
max_upload_file_size = 50 * 1024 * 1024
if bom_file.size > max_upload_file_size:
raise serializers.ValidationError(_("File is too large"))
# Read file data into memory (bytes object)
try:
data = bom_file.read()
except Exception as e:
raise serializers.ValidationError(str(e))
if ext in ['csv', 'tsv', 'xml']:
try:
data = data.decode()
except Exception as e:
raise serializers.ValidationError(str(e))
# Convert to a tablib dataset (we expect headers)
try:
self.dataset = tablib.Dataset().load(data, ext, headers=True)
except Exception as e:
raise serializers.ValidationError(str(e))
for header in self.REQUIRED_COLUMNS:
match = self.find_matching_column(header, self.dataset.headers)
if match is None:
raise serializers.ValidationError(_("Missing required column") + f": '{header}'")
part_column_matches = {}
part_match = False
for col in self.PART_COLUMNS:
col_match = self.find_matching_column(col, self.dataset.headers)
part_column_matches[col] = col_match
if col_match is not None:
part_match = True
if not part_match:
raise serializers.ValidationError(_("No part column found"))
if len(self.dataset) == 0:
raise serializers.ValidationError(_("No data rows found"))
return bom_file
def extract_data(self):
"""
Read individual rows out of the BOM file
"""
rows = []
errors = []
found_parts = set()
headers = self.dataset.headers
level_column = self.find_matching_column('level', headers)
for row in self.dataset.dict:
row_error = {}
"""
If the "level" column is specified, and this is not a top-level BOM item, ignore the row!
"""
if level_column is not None:
level = row.get('level', None)
if level is not None:
try:
level = int(level)
if level != 1:
continue
except:
pass
"""
Next, we try to "guess" the part, based on the provided data.
A) If the part_id is supplied, use that!
B) If the part name and/or part_ipn are supplied, maybe we can use those?
"""
part_id = self.find_matching_data(row, 'part_id', headers)
part_name = self.find_matching_data(row, 'part_name', headers)
part_ipn = self.find_matching_data(row, 'part_ipn', headers)
part = None
if part_id is not None:
try:
part = Part.objects.get(pk=part_id)
except (ValueError, Part.DoesNotExist):
pass
# Optionally, specify using field "part"
if part is None:
pk = self.find_matching_data(row, 'part', headers)
if pk is not None:
try:
part = Part.objects.get(pk=pk)
except (ValueError, Part.DoesNotExist):
pass
if part is None:
if part_name or part_ipn:
queryset = Part.objects.all()
if part_name:
queryset = queryset.filter(name=part_name)
if part_ipn:
queryset = queryset.filter(IPN=part_ipn)
# Only if we have a single direct match
if queryset.exists():
if queryset.count() == 1:
part = queryset.first()
else:
# Multiple matches!
row_error['part'] = _('Multiple matching parts found')
if part is None:
if 'part' not in row_error:
row_error['part'] = _('No matching part found')
else:
if part.pk in found_parts:
row_error['part'] = _("Duplicate part selected")
elif not part.component:
row_error['part'] = _('Part is not designated as a component')
found_parts.add(part.pk)
row['part'] = part.pk if part is not None else None
"""
Read out the 'quantity' column - check that it is valid
"""
quantity = self.find_matching_data(row, 'quantity', self.dataset.headers)
if quantity is None:
row_error['quantity'] = _('Quantity not provided')
else:
try:
quantity = Decimal(quantity)
if quantity <= 0:
row_error['quantity'] = _('Quantity must be greater than zero')
except:
row_error['quantity'] = _('Invalid quantity')
# For each "optional" column, ensure the column names are allocated correctly
for field_name in self.OPTIONAL_COLUMNS:
if field_name not in row:
row[field_name] = self.find_matching_data(row, field_name, self.dataset.headers)
rows.append(row)
errors.append(row_error)
return {
'rows': rows,
'errors': errors,
'headers': headers,
'filename': self.filename,
}
part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(assembly=True), required=True)
clear_existing = serializers.BooleanField(
label=_("Clear Existing BOM"),
help_text=_("Delete existing BOM data first"),
)
def save(self):
data = self.validated_data
master_part = data['part']
clear_existing = data['clear_existing']
if clear_existing:
# Remove all existing BOM items
master_part.bom_items.all().delete()
class BomUploadSerializer(serializers.Serializer):
"""
Serializer for uploading a BOM against a specified part.
A "BOM" is a set of BomItem objects which are to be validated together as a set
"""
items = BomItemSerializer(many=True, required=True)
def validate(self, data):
items = data['items']
if len(items) == 0:
raise serializers.ValidationError(_("At least one BOM item is required"))
data = super().validate(data)
return data
def save(self):
data = self.validated_data
items = data['items']
try:
with transaction.atomic():
for item in items:
part = item['part']
sub_part = item['sub_part']
# Ignore duplicate BOM items
if BomItem.objects.filter(part=part, sub_part=sub_part).exists():
continue
# Create a new BomItem object
BomItem.objects.create(**item)
except Exception as e:
raise serializers.ValidationError(detail=serializers.as_serializer_error(e))

View File

@ -13,6 +13,7 @@ from common.models import NotificationEntry
import InvenTree.helpers
import InvenTree.tasks
from InvenTree.ready import isImportingData
import part.models
@ -24,6 +25,10 @@ def notify_low_stock(part: part.models.Part):
Notify users who have starred a part when its stock quantity falls below the minimum threshold
"""
# Do not notify if we are importing data
if isImportingData():
return
# Check if we have notified recently...
delta = timedelta(days=1)

View File

@ -1,2 +0,0 @@
{% extends "part/bom_upload/upload_file.html" %}
{% include "patterns/wizard/match_fields.html" %}

View File

@ -1,127 +0,0 @@
{% extends "part/bom_upload/upload_file.html" %}
{% load inventree_extras %}
{% load i18n %}
{% load static %}
{% load crispy_forms_tags %}
{% block form_alert %}
{% if form.errors %}
{% endif %}
{% if form_errors %}
<div class='alert alert-danger alert-block' role='alert'>
{% trans "Errors exist in the submitted data" %}
</div>
{% endif %}
{% endblock form_alert %}
{% block form_buttons_top %}
{% if wizard.steps.prev %}
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-outline-secondary">{% trans "Previous Step" %}</button>
{% endif %}
<button type="submit" class="save btn btn-outline-secondary">{% trans "Submit Selections" %}</button>
{% endblock form_buttons_top %}
{% block form_content %}
<thead>
<tr>
<th></th>
<th>{% trans "Row" %}</th>
<th>{% trans "Select Part" %}</th>
<th>{% trans "Reference" %}</th>
<th>{% trans "Quantity" %}</th>
{% for col in columns %}
{% if col.guess != 'Quantity' %}
<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>
{% endif %}
{% endfor %}
</tr>
</thead>
<tbody>
<tr></tr> {% comment %} Dummy row for javascript del_row method {% endcomment %}
{% for row in rows %}
<tr {% if row.errors %} style='background: #ffeaea;'{% endif %} part-select='#select_part_{{ row.index }}'>
<td>
<button class='btn btn-outline-secondary btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ row.index }}' style='display: inline; float: right;' title='{% trans "Remove row" %}'>
<span row_id='{{ row.index }}' class='fas fa-trash-alt icon-red'></span>
</button>
</td>
<td>
{% add row.index 1 %}
</td>
<td>
{% for field in form.visible_fields %}
{% if field.name == row.item_select %}
{{ field }}
{% endif %}
{% endfor %}
{% if row.errors.part %}
<p class='help-inline'>{{ row.errors.part }}</p>
{% endif %}
</td>
<td>
{% for field in form.visible_fields %}
{% if field.name == row.reference %}
{{ field|as_crispy_field }}
{% endif %}
{% endfor %}
{% if row.errors.reference %}
<p class='help-inline'>{{ row.errors.reference }}</p>
{% endif %}
</td>
<td>
{% for field in form.visible_fields %}
{% if field.name == row.quantity %}
{{ field|as_crispy_field }}
{% endif %}
{% endfor %}
{% if row.errors.quantity %}
<p class='help-inline'>{{ row.errors.quantity }}</p>
{% endif %}
</td>
{% for item in row.data %}
{% if item.column.guess != 'Quantity' %}
<td>
{% if item.column.guess == 'Overage' %}
{% for field in form.visible_fields %}
{% if field.name == row.overage %}
{{ field|as_crispy_field }}
{% endif %}
{% endfor %}
{% elif item.column.guess == 'Note' %}
{% for field in form.visible_fields %}
{% if field.name == row.note %}
{{ field|as_crispy_field }}
{% endif %}
{% endfor %}
{% else %}
{{ item.cell }}
{% endif %}
<input type='hidden' name='row_{{ row.index }}_col_{{ forloop.counter0 }}' value='{{ item.cell }}'/>
</td>
{% endif %}
{% endfor %}
</tr>
{% endfor %}
</tbody>
{% endblock form_content %}
{% block form_buttons_bottom %}
{% endblock form_buttons_bottom %}
{% block js_ready %}
{{ block.super }}
$('.bomselect').select2({
dropdownAutoWidth: true,
matcher: partialMatcher,
});
{% endblock %}

View File

@ -1,67 +0,0 @@
{% extends "part/part_base.html" %}
{% load inventree_extras %}
{% load i18n %}
{% load static %}
{% block sidebar %}
{% url "part-detail" part.id as url %}
{% trans "Return to BOM" as text %}
{% include "sidebar_link.html" with url=url text=text icon="fa-undo" %}
{% endblock %}
{% block heading %}
{% trans "Upload Bill of Materials" %}
{% endblock %}
{% block actions %}
{% endblock %}
{% block page_info %}
<div class='panel-content'>
<p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
{% if description %}- {{ description }}{% endif %}</p>
<form action="" method="post" class='js-modal-form' enctype="multipart/form-data">
{% csrf_token %}
{% load crispy_forms_tags %}
{% block form_buttons_top %}
{% endblock form_buttons_top %}
{% block form_alert %}
<div class='alert alert-info alert-block'>
<strong>{% trans "Requirements for BOM upload" %}:</strong>
<ul>
<li>{% trans "The BOM file must contain the required named columns as provided in the " %} <strong><a href='#' id='bom-template-download'>{% trans "BOM Upload Template" %}</a></strong></li>
<li>{% trans "Each part must already exist in the database" %}</li>
</ul>
</div>
{% endblock %}
<table class='table table-striped' style='margin-top: 12px; margin-bottom: 0px'>
{{ wizard.management_form }}
{% block form_content %}
{% crispy wizard.form %}
{% endblock form_content %}
</table>
{% block form_buttons_bottom %}
{% if wizard.steps.prev %}
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-outline-secondary">{% trans "Previous Step" %}</button>
{% endif %}
<button type="submit" class="save btn btn-outline-secondary">{% trans "Upload File" %}</button>
</form>
{% endblock form_buttons_bottom %}
</div>
{% endblock page_info %}
{% block js_ready %}
{{ block.super }}
enableSidebar('bom-upload');
$('#bom-template-download').click(function() {
downloadBomTemplate();
});
{% endblock js_ready %}

View File

@ -37,6 +37,23 @@
</div>
</div>
<div class='panel panel-hidden' id='panel-allocations'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Part Stock Allocations" %}</h4>
{% include "spacer.html" %}
</div>
</div>
<div class='panel-content'>
<div id='allocations-button-toolbar'>
<div class='btn-group' role='group'>
{% include "filter_list.html" with id="allocations" %}
</div>
</div>
<table class='table table-striped table-condensed' data-toolbar='#allocations-button-toolbar' id='part-allocation-table'></table>
</div>
</div>
<div class='panel panel-hidden' id='panel-test-templates'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
@ -109,9 +126,12 @@
</div>
</div>
{% settings_value "PART_SHOW_PRICE_HISTORY" as show_price_history %}
{% if show_price_history %}
<div class='panel panel-hidden' id='panel-pricing'>
{% include "part/prices.html" %}
</div>
{% endif %}
<div class='panel panel-hidden' id='panel-part-notes'>
<div class='panel-heading'>
@ -631,6 +651,19 @@
{% endif %}
});
// Load the "allocations" tab
onPanelLoad('allocations', function() {
loadStockAllocationTable(
$("#part-allocation-table"),
{
params: {
part: {{ part.pk }},
},
}
);
});
// Load the "related parts" tab
onPanelLoad("related-parts", function() {

View File

@ -4,6 +4,7 @@
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
{% settings_value 'PART_SHOW_RELATED' as show_related %}
{% settings_value "PART_SHOW_PRICE_HISTORY" as show_price_history %}
{% trans "Parameters" as text %}
{% include "sidebar_item.html" with label="part-parameters" text=text icon="fa-th-list" %}
@ -25,8 +26,14 @@
{% trans "Used In" as text %}
{% include "sidebar_item.html" with label="used-in" text=text icon="fa-layer-group" %}
{% endif %}
{% if show_price_history %}
{% trans "Pricing" as text %}
{% include "sidebar_item.html" with label="pricing" text=text icon="fa-dollar-sign" %}
{% endif %}
{% if part.salable or part.component %}
{% trans "Allocations" as text %}
{% include "sidebar_item.html" with label="allocations" text=text icon="fa-bookmark" %}
{% endif %}
{% if part.purchaseable and roles.purchase_order.view %}
{% trans "Suppliers" as text %}
{% include "sidebar_item.html" with label="suppliers" text=text icon="fa-building" %}

View File

@ -0,0 +1,108 @@
{% extends "part/part_base.html" %}
{% load inventree_extras %}
{% load i18n %}
{% load static %}
{% block sidebar %}
{% url "part-detail" part.id as url %}
{% trans "Return to BOM" as text %}
{% include "sidebar_link.html" with url=url text=text icon="fa-undo" %}
{% endblock %}
{% block heading %}
{% trans "Upload Bill of Materials" %}
{% endblock %}
{% block actions %}
<!--
<button type='button' class='btn btn-outline-secondary' id='bom-info'>
<span class='fas fa-info-circle' title='{% trans "BOM upload requirements" %}'></span>
</button>
-->
<button type='button' class='btn btn-primary' id='bom-upload'>
<span class='fas fa-file-upload'></span> {% trans "Upload BOM File" %}
</button>
<button type='button' class='btn btn-success' disabled='true' id='bom-submit-icon' style='display: none;'>
<span class="fas fa-spin fa-circle-notch"></span>
</button>
<button type='button' class='btn btn-success' id='bom-submit' style='display: none;'>
<span class='fas fa-sign-in-alt' id='bom-submit-icon'></span> {% trans "Submit BOM Data" %}
</button>
{% endblock %}
{% block page_info %}
<div class='panel-content'>
<div class='alert alert-info alert-block'>
<strong>{% trans "Requirements for BOM upload" %}:</strong>
<ul>
<li>{% trans "The BOM file must contain the required named columns as provided in the " %} <strong><a href='#' id='bom-template-download'>{% trans "BOM Upload Template" %}</a></strong></li>
<li>{% trans "Each part must already exist in the database" %}</li>
</ul>
</div>
<div id='non-field-errors'>
<!-- Upload error messages go here -->
</div>
<!-- This table is filled out after BOM file is uploaded and processed -->
<table class='table table-condensed' id='bom-import-table'>
<thead>
<tr>
<th style='max-width: 500px;'>{% trans "Part" %}</th>
<th>{% trans "Quantity" %}</th>
<th>{% trans "Reference" %}</th>
<th>{% trans "Overage" %}</th>
<th>{% trans "Allow Variants" %}</th>
<th>{% trans "Inherited" %}</th>
<th>{% trans "Optional" %}</th>
<th>{% trans "Note" %}</th>
<th><!-- Buttons Column --></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
{% endblock page_info %}
{% block js_ready %}
{{ block.super }}
enableSidebar('bom-upload');
$('#bom-template-download').click(function() {
downloadBomTemplate();
});
$('#bom-upload').click(function() {
constructForm('{% url "api-bom-extract" %}', {
method: 'POST',
fields: {
bom_file: {},
part: {
value: {{ part.pk }},
hidden: true,
},
clear_existing: {},
},
title: '{% trans "Upload BOM File" %}',
onSuccess: function(response) {
$('#bom-upload').hide();
$('#bom-submit').show();
constructBomUploadTable(response);
$('#bom-submit').click(function() {
submitBomTable({{ part.pk }}, {
bom_data: response,
});
});
}
});
});
{% endblock js_ready %}

View File

@ -293,7 +293,7 @@ def progress_bar(val, max, *args, **kwargs):
Render a progress bar element
"""
id = kwargs.get('id', 'progress-bar')
item_id = kwargs.get('id', 'progress-bar')
if val > max:
style = 'progress-bar-over'
@ -317,7 +317,7 @@ def progress_bar(val, max, *args, **kwargs):
style_tags.append(f'max-width: {max_width};')
html = f"""
<div id='{id}' class='progress' style='{" ".join(style_tags)}'>
<div id='{item_id}' class='progress' style='{" ".join(style_tags)}'>
<div class='progress-bar {style}' role='progressbar' aria-valuemin='0' aria-valuemax='100' style='width:{percent}%'></div>
<div class='progress-value'>{val} / {max}</div>
</div>

View File

@ -107,7 +107,7 @@ class BomExportTest(TestCase):
"""
params = {
'file_format': 'csv',
'format': 'csv',
'cascade': True,
'parameter_data': True,
'stock_data': True,
@ -154,7 +154,9 @@ class BomExportTest(TestCase):
'inherited',
'allow_variants',
'Default Location',
'Total Stock',
'Available Stock',
'On Order',
]
for header in expected:
@ -169,7 +171,7 @@ class BomExportTest(TestCase):
"""
params = {
'file_format': 'xls',
'format': 'xls',
'cascade': True,
'parameter_data': True,
'stock_data': True,
@ -190,7 +192,7 @@ class BomExportTest(TestCase):
"""
params = {
'file_format': 'xlsx',
'format': 'xlsx',
'cascade': True,
'parameter_data': True,
'stock_data': True,
@ -208,7 +210,7 @@ class BomExportTest(TestCase):
"""
params = {
'file_format': 'json',
'format': 'json',
'cascade': True,
'parameter_data': True,
'stock_data': True,

View File

@ -0,0 +1,298 @@
"""
Unit testing for BOM upload / import functionality
"""
import tablib
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from InvenTree.api_tester import InvenTreeAPITestCase
from part.models import Part
class BomUploadTest(InvenTreeAPITestCase):
"""
Test BOM file upload API endpoint
"""
roles = [
'part.add',
'part.change',
]
def setUp(self):
super().setUp()
self.part = Part.objects.create(
name='Assembly',
description='An assembled part',
assembly=True,
component=False,
)
for i in range(10):
Part.objects.create(
name=f"Component {i}",
IPN=f"CMP_{i}",
description="A subcomponent that can be used in a BOM",
component=True,
assembly=False,
)
self.url = reverse('api-bom-extract')
def post_bom(self, filename, file_data, part=None, clear_existing=None, expected_code=None, content_type='text/plain'):
bom_file = SimpleUploadedFile(
filename,
file_data,
content_type=content_type,
)
if part is None:
part = self.part.pk
if clear_existing is None:
clear_existing = False
response = self.post(
self.url,
data={
'bom_file': bom_file,
'part': part,
'clear_existing': clear_existing,
},
expected_code=expected_code,
format='multipart',
)
return response
def test_missing_file(self):
"""
POST without a file
"""
response = self.post(
self.url,
data={},
expected_code=400
)
self.assertIn('No file was submitted', str(response.data['bom_file']))
self.assertIn('This field is required', str(response.data['part']))
self.assertIn('This field is required', str(response.data['clear_existing']))
def test_unsupported_file(self):
"""
POST with an unsupported file type
"""
response = self.post_bom(
'sample.txt',
b'hello world',
expected_code=400,
)
self.assertIn('Unsupported file type', str(response.data['bom_file']))
def test_broken_file(self):
"""
Test upload with broken (corrupted) files
"""
response = self.post_bom(
'sample.csv',
b'',
expected_code=400,
)
self.assertIn('The submitted file is empty', str(response.data['bom_file']))
response = self.post_bom(
'test.xls',
b'hello world',
expected_code=400,
content_type='application/xls',
)
self.assertIn('Unsupported format, or corrupt file', str(response.data['bom_file']))
def test_invalid_upload(self):
"""
Test upload of an invalid file
"""
dataset = tablib.Dataset()
dataset.headers = [
'apple',
'banana',
]
response = self.post_bom(
'test.csv',
bytes(dataset.csv, 'utf8'),
content_type='text/csv',
expected_code=400,
)
self.assertIn("Missing required column: 'quantity'", str(response.data))
# Try again, with an .xlsx file
response = self.post_bom(
'bom.xlsx',
dataset.xlsx,
content_type='application/xlsx',
expected_code=400,
)
self.assertIn("Missing required column: 'quantity'", str(response.data))
# Add the quantity field (or close enough)
dataset.headers.append('quAntiTy ')
response = self.post_bom(
'test.csv',
bytes(dataset.csv, 'utf8'),
content_type='text/csv',
expected_code=400,
)
self.assertIn('No part column found', str(response.data))
dataset.headers.append('part_id')
dataset.headers.append('part_name')
response = self.post_bom(
'test.csv',
bytes(dataset.csv, 'utf8'),
content_type='text/csv',
expected_code=400,
)
self.assertIn('No data rows found', str(response.data))
def test_invalid_data(self):
"""
Upload data which contains errors
"""
dataset = tablib.Dataset()
# Only these headers are strictly necessary
dataset.headers = ['part_id', 'quantity']
components = Part.objects.filter(component=True)
for idx, cmp in enumerate(components):
if idx == 5:
cmp.component = False
cmp.save()
dataset.append([cmp.pk, idx])
# Add a duplicate part too
dataset.append([components.first().pk, 'invalid'])
response = self.post_bom(
'test.csv',
bytes(dataset.csv, 'utf8'),
content_type='text/csv',
expected_code=201
)
errors = response.data['errors']
self.assertIn('Quantity must be greater than zero', str(errors[0]))
self.assertIn('Part is not designated as a component', str(errors[5]))
self.assertIn('Duplicate part selected', str(errors[-1]))
self.assertIn('Invalid quantity', str(errors[-1]))
for idx, row in enumerate(response.data['rows'][:-1]):
self.assertEqual(str(row['part']), str(components[idx].pk))
def test_part_guess(self):
"""
Test part 'guessing' when PK values are not supplied
"""
dataset = tablib.Dataset()
# Should be able to 'guess' the part from the name
dataset.headers = ['part_name', 'quantity']
components = Part.objects.filter(component=True)
for idx, cmp in enumerate(components):
dataset.append([
f"Component {idx}",
10,
])
response = self.post_bom(
'test.csv',
bytes(dataset.csv, 'utf8'),
expected_code=201,
)
rows = response.data['rows']
self.assertEqual(len(rows), 10)
for idx in range(10):
self.assertEqual(rows[idx]['part'], components[idx].pk)
# Should also be able to 'guess' part by the IPN value
dataset = tablib.Dataset()
dataset.headers = ['part_ipn', 'quantity']
for idx, cmp in enumerate(components):
dataset.append([
f"CMP_{idx}",
10,
])
response = self.post_bom(
'test.csv',
bytes(dataset.csv, 'utf8'),
expected_code=201,
)
rows = response.data['rows']
self.assertEqual(len(rows), 10)
for idx in range(10):
self.assertEqual(rows[idx]['part'], components[idx].pk)
def test_levels(self):
"""
Test that multi-level BOMs are correctly handled during upload
"""
dataset = tablib.Dataset()
dataset.headers = ['level', 'part', 'quantity']
components = Part.objects.filter(component=True)
for idx, cmp in enumerate(components):
dataset.append([
idx % 3,
cmp.pk,
2,
])
response = self.post_bom(
'test.csv',
bytes(dataset.csv, 'utf8'),
expected_code=201,
)
# Only parts at index 1, 4, 7 should have been returned
self.assertEqual(len(response.data['rows']), 3)

View File

@ -30,8 +30,8 @@ class TemplateTagTest(TestCase):
self.assertEqual(type(inventree_extras.inventree_version()), str)
def test_hash(self):
hash = inventree_extras.inventree_commit_hash()
self.assertGreater(len(hash), 5)
result_hash = inventree_extras.inventree_commit_hash()
self.assertGreater(len(result_hash), 5)
def test_date(self):
d = inventree_extras.inventree_commit_date()

View File

@ -1,7 +0,0 @@
from django.test import TestCase
class SupplierPartTest(TestCase):
def setUp(self):
pass

View File

@ -33,7 +33,6 @@ part_parameter_urls = [
part_detail_urls = [
url(r'^delete/?', views.PartDelete.as_view(), name='part-delete'),
url(r'^bom-export/?', views.BomExport.as_view(), name='bom-export'),
url(r'^bom-download/?', views.BomDownload.as_view(), name='bom-download'),
url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'),

View File

@ -28,20 +28,17 @@ import requests
import os
import io
from rapidfuzz import fuzz
from decimal import Decimal, InvalidOperation
from decimal import Decimal
from .models import PartCategory, Part
from .models import PartParameterTemplate
from .models import PartCategoryParameterTemplate
from .models import BomItem
from .models import PartSellPriceBreak, PartInternalPriceBreak
from common.models import InvenTreeSetting
from company.models import SupplierPart
from common.files import FileManager
from common.views import FileManagementFormView, FileManagementAjaxView
from common.forms import UploadFileForm, MatchFieldForm
from stock.models import StockItem, StockLocation
@ -395,10 +392,11 @@ class PartDetail(InvenTreeRoleMixin, DetailView):
context.update(**ctx)
# Pricing information
ctx = self.get_pricing(self.get_quantity())
ctx['form'] = self.form_class(initial=self.get_initials())
if InvenTreeSetting.get_setting('PART_SHOW_PRICE_HISTORY', False):
ctx = self.get_pricing(self.get_quantity())
ctx['form'] = self.form_class(initial=self.get_initials())
context.update(ctx)
context.update(ctx)
return context
@ -703,270 +701,12 @@ class PartImageSelect(AjaxUpdateView):
return self.renderJsonResponse(request, form, data)
class BomUpload(InvenTreeRoleMixin, FileManagementFormView):
""" View for uploading a BOM file, and handling BOM data importing.
class BomUpload(InvenTreeRoleMixin, DetailView):
""" View for uploading a BOM file, and handling BOM data importing. """
The BOM upload process is as follows:
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.
"""
role_required = ('part.change', 'part.add')
class BomFileManager(FileManager):
# Fields which are absolutely necessary for valid upload
REQUIRED_HEADERS = [
'Quantity'
]
# Fields which are used for part matching (only one of them is needed)
ITEM_MATCH_HEADERS = [
'Part_Name',
'Part_IPN',
'Part_ID',
]
# Fields which would be helpful but are not required
OPTIONAL_HEADERS = [
'Reference',
'Note',
'Overage',
]
EDITABLE_HEADERS = [
'Reference',
'Note',
'Overage'
]
name = 'order'
form_list = [
('upload', UploadFileForm),
('fields', MatchFieldForm),
('items', part_forms.BomMatchItemForm),
]
form_steps_template = [
'part/bom_upload/upload_file.html',
'part/bom_upload/match_fields.html',
'part/bom_upload/match_parts.html',
]
form_steps_description = [
_("Upload File"),
_("Match Fields"),
_("Match Parts"),
]
form_field_map = {
'item_select': 'part',
'quantity': 'quantity',
'overage': 'overage',
'reference': 'reference',
'note': 'note',
}
file_manager_class = BomFileManager
def get_part(self):
""" Get part or return 404 """
return get_object_or_404(Part, pk=self.kwargs['pk'])
def get_context_data(self, form, **kwargs):
""" Handle context data for order """
context = super().get_context_data(form=form, **kwargs)
part = self.get_part()
context.update({'part': part})
return context
def get_allowed_parts(self):
""" Return a queryset of parts which are allowed to be added to this BOM.
"""
return self.get_part().get_allowed_bom_items()
def get_field_selection(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.
"""
self.allowed_items = self.get_allowed_parts()
# Fields prefixed with "Part_" can be used to do "smart matching" against Part objects in the database
k_idx = self.get_column_index('Part_ID')
p_idx = self.get_column_index('Part_Name')
i_idx = self.get_column_index('Part_IPN')
q_idx = self.get_column_index('Quantity')
r_idx = self.get_column_index('Reference')
o_idx = self.get_column_index('Overage')
n_idx = self.get_column_index('Note')
for row in self.rows:
"""
Iterate through each row in the uploaded data,
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_items
# Check if there is a column corresponding to "quantity"
if q_idx >= 0:
q_val = row['data'][q_idx]['cell']
if q_val:
# Delete commas
q_val = q_val.replace(',', '')
try:
# Attempt to extract a valid quantity from the field
quantity = Decimal(q_val)
# Store the 'quantity' value
row['quantity'] = quantity
except (ValueError, InvalidOperation):
pass
# Check if there is a column corresponding to "PK"
if k_idx >= 0:
pk = row['data'][k_idx]['cell']
if pk:
try:
# Attempt Part lookup based on PK value
exact_match_part = self.allowed_items.get(pk=pk)
except (ValueError, Part.DoesNotExist):
exact_match_part = None
# Check if there is a column corresponding to "Part IPN" and no exact match found yet
if i_idx >= 0 and not exact_match_part:
part_ipn = row['data'][i_idx]['cell']
if part_ipn:
part_matches = [part for part in self.allowed_items if part.IPN and part_ipn.lower() == str(part.IPN.lower())]
# Check for single match
if len(part_matches) == 1:
exact_match_part = part_matches[0]
# Check if there is a column corresponding to "Part Name" and no exact match found yet
if p_idx >= 0 and not exact_match_part:
part_name = row['data'][p_idx]['cell']
row['part_name'] = part_name
matches = []
for part in self.allowed_items:
ratio = fuzz.partial_ratio(part.name + part.description, part_name)
matches.append({'part': part, 'match': ratio})
# Sort matches by the 'strength' of the match ratio
if len(matches) > 0:
matches = sorted(matches, key=lambda item: item['match'], reverse=True)
part_options = [m['part'] for m in matches]
# Supply list of part options for each row, sorted by how closely they match the part name
row['item_options'] = part_options
# Unless found, the 'item_match' is blank
row['item_match'] = None
if exact_match_part:
# If there is an exact match based on PK or IPN, use that
row['item_match'] = exact_match_part
# Check if there is a column corresponding to "Overage" field
if o_idx >= 0:
row['overage'] = row['data'][o_idx]['cell']
# Check if there is a column corresponding to "Reference" field
if r_idx >= 0:
row['reference'] = row['data'][r_idx]['cell']
# Check if there is a column corresponding to "Note" field
if n_idx >= 0:
row['note'] = row['data'][n_idx]['cell']
def done(self, form_list, **kwargs):
""" Once all the data is in, process it to add BomItem instances to the part """
self.part = self.get_part()
items = self.get_clean_items()
# Clear BOM
self.part.clear_bom()
# Generate new BOM items
for bom_item in items.values():
try:
part = Part.objects.get(pk=int(bom_item.get('part')))
except (ValueError, Part.DoesNotExist):
continue
quantity = bom_item.get('quantity')
overage = bom_item.get('overage', '')
reference = bom_item.get('reference', '')
note = bom_item.get('note', '')
# Create a new BOM item
item = BomItem(
part=self.part,
sub_part=part,
quantity=quantity,
overage=overage,
reference=reference,
note=note,
)
try:
item.save()
except IntegrityError:
# BomItem already exists
pass
return HttpResponseRedirect(reverse('part-detail', kwargs={'pk': self.kwargs['pk']}))
context_object_name = 'part'
queryset = Part.objects.all()
template_name = 'part/upload_bom.html'
class PartExport(AjaxView):
@ -1059,7 +799,7 @@ class BomDownload(AjaxView):
part = get_object_or_404(Part, pk=self.kwargs['pk'])
export_format = request.GET.get('file_format', 'csv')
export_format = request.GET.get('format', 'csv')
cascade = str2bool(request.GET.get('cascade', False))
@ -1102,55 +842,6 @@ class BomDownload(AjaxView):
}
class BomExport(AjaxView):
""" Provide a simple form to allow the user to select BOM download options.
"""
model = Part
ajax_form_title = _("Export Bill of Materials")
role_required = 'part.view'
def post(self, request, *args, **kwargs):
# Extract POSTed form data
fmt = request.POST.get('file_format', 'csv').lower()
cascade = str2bool(request.POST.get('cascading', False))
levels = request.POST.get('levels', None)
parameter_data = str2bool(request.POST.get('parameter_data', False))
stock_data = str2bool(request.POST.get('stock_data', False))
supplier_data = str2bool(request.POST.get('supplier_data', False))
manufacturer_data = str2bool(request.POST.get('manufacturer_data', False))
try:
part = Part.objects.get(pk=self.kwargs['pk'])
except:
part = None
# Format a URL to redirect to
if part:
url = reverse('bom-download', kwargs={'pk': part.pk})
else:
url = ''
url += '?file_format=' + fmt
url += '&cascade=' + str(cascade)
url += '&parameter_data=' + str(parameter_data)
url += '&stock_data=' + str(stock_data)
url += '&supplier_data=' + str(supplier_data)
url += '&manufacturer_data=' + str(manufacturer_data)
if levels:
url += '&levels=' + str(levels)
data = {
'form_valid': part is not None,
'url': url,
}
return self.renderJsonResponse(request, self.form_class(), data=data)
class PartDelete(AjaxDeleteView):
""" View to delete a Part object """