Merge pull request #312 from SchrodingersGat/bom-check

Bom check
This commit is contained in:
Oliver 2019-05-12 16:38:34 +10:00 committed by GitHub
commit 50f2229685
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 185 additions and 22 deletions

View File

@ -24,6 +24,21 @@ class PartImageForm(HelperForm):
]
class BomValidateForm(HelperForm):
""" Simple confirmation form for BOM validation.
User is presented with a single checkbox input,
to confirm that the BOM for this part is valid
"""
validate = forms.BooleanField(required=False, initial=False, help_text='Confirm that the BOM is correct')
class Meta:
model = Part
fields = [
'validate'
]
class BomExportForm(HelperForm):
# TODO - Define these choices somewhere else, and import them here

View File

@ -0,0 +1,31 @@
# Generated by Django 2.2 on 2019-05-12 02:46
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('part', '0021_auto_20190510_2220'),
]
operations = [
migrations.AddField(
model_name='part',
name='bom_checked_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='boms_checked', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='part',
name='bom_checked_date',
field=models.DateField(blank=True, null=True),
),
migrations.AddField(
model_name='part',
name='bom_checksum',
field=models.CharField(blank=True, help_text='Stored BOM checksum', max_length=128),
),
]

View File

@ -16,7 +16,7 @@ from django.core.exceptions import ValidationError
from django.urls import reverse
from django.conf import settings
from django.db import models
from django.db import models, transaction
from django.core.validators import MinValueValidator
from django.contrib.staticfiles.templatetags.staticfiles import static
@ -24,7 +24,9 @@ from django.contrib.auth.models import User
from django.db.models.signals import pre_delete
from django.dispatch import receiver
from datetime import datetime
from fuzzywuzzy import fuzz
import hashlib
from InvenTree import helpers
from InvenTree import validators
@ -300,23 +302,23 @@ class Part(models.Model):
consumable = models.BooleanField(default=True, help_text='Can this part be used to build other parts?')
# Is this part "trackable"?
# Trackable parts can have unique instances
# which are assigned serial numbers (or batch numbers)
# and can have their movements tracked
trackable = models.BooleanField(default=False, help_text='Does this part have tracking for unique items?')
# Is this part "purchaseable"?
purchaseable = models.BooleanField(default=True, help_text='Can this part be purchased from external suppliers?')
# Can this part be sold to customers?
salable = models.BooleanField(default=False, help_text="Can this part be sold to customers?")
# Is this part active?
active = models.BooleanField(default=True, help_text='Is this part active?')
notes = models.TextField(blank=True)
bom_checksum = models.CharField(max_length=128, blank=True, help_text='Stored BOM checksum')
bom_checked_by = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True,
related_name='boms_checked')
bom_checked_date = models.DateField(blank=True, null=True)
def format_barcode(self):
""" Return a JSON string for formatting a barcode for this Part object """
@ -467,13 +469,59 @@ class Part(models.Model):
@property
def bom_count(self):
""" Return the number of items contained in the BOM for this part """
return self.bom_items.count()
@property
def used_in_count(self):
""" Return the number of part BOMs that this part appears in """
return self.used_in.count()
def get_bom_hash(self):
""" Return a checksum hash for the BOM for this part.
Used to determine if the BOM has changed (and needs to be signed off!)
For hash is calculated from the following fields of each BOM item:
- Part.full_name (if the part name changes, the BOM checksum is invalidated)
- quantity
- Note field
returns a string representation of a hash object which can be compared with a stored value
"""
hash = hashlib.md5('bom seed'.encode())
for item in self.bom_items.all():
hash.update(str(item.sub_part.full_name).encode())
hash.update(str(item.quantity).encode())
hash.update(str(item.note).encode())
return str(hash.digest())
@property
def is_bom_valid(self):
""" Check if the BOM is 'valid' - if the calculated checksum matches the stored value
"""
return self.get_bom_hash() == self.bom_checksum
@transaction.atomic
def validate_bom(self, user):
""" Validate the BOM (mark the BOM as validated by the given User.
- Calculates and stores the hash for the BOM
- Saves the current date and the checking user
"""
self.bom_checksum = self.get_bom_hash()
self.bom_checked_by = user
self.bom_checked_date = datetime.now().date()
self.save()
def required_parts(self):
""" Return a list of parts required to make this part (list of BOM items) """
parts = []
for bom in self.bom_items.all():
parts.append(bom.sub_part)
@ -481,7 +529,7 @@ class Part(models.Model):
@property
def supplier_count(self):
# Return the number of supplier parts available for this part
""" Return the number of supplier parts available for this part """
return self.supplier_parts.count()
def export_bom(self, **kwargs):

View File

@ -11,6 +11,21 @@
<h3>Bill of Materials</h3>
{% if part.bom_checked_date %}
{% if part.is_bom_valid %}
<div class='alert alert-block alert-info'>
{% else %}
<div class='alert alert-block alert-danger'>
The BOM for <i>{{ part.full_name }}</i> has changed, and must be validated.<br>
{% endif %}
The BOM for <i>{{ part.full_name }}</i> was last checked by {{ part.bom_checked_by }} on {{ part.bom_checked_date }}
</div>
{% else %}
<div class='alert alert-danger alert-block'>
<b>The BOM for <i>{{ part.full_name }}</i> has not been validated.</b>
</div>
{% endif %}
<div id='button-toolbar'>
{% if editing_enabled %}
<div class='btn-group' style='float: right;'>
@ -24,6 +39,9 @@
<span class='caret'></span>
</button>
<ul class='dropdown-menu'>
{% if part.is_bom_valid == False %}
<li><a href='#' id='validate-bom' title='Validate BOM'>Validate BOM</a></li>
{% 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>
</ul>
@ -72,6 +90,15 @@
{% else %}
$("#validate-bom").click(function() {
launchModalForm(
"{% url 'bom-validate' part.id %}",
{
reload: true,
}
);
});
$("#edit-bom").click(function () {
location.href = "{% url 'part-bom' part.id %}?edit=True";
});

View File

@ -0,0 +1,5 @@
{% extends "modal_form.html" %}
{% block pre_form_content %}
Confirm that the Bill of Materials (BOM) is valid for:<br><i>{{ part.full_name }}</i>
{% endblock %}

View File

@ -12,7 +12,7 @@
{% endif %}
{% if part.buildable %}
<li{% ifequal tab 'bom' %} class="active"{% endifequal %}>
<a href="{% url 'part-bom' part.id %}">BOM<span class="badge">{{ part.bom_count }}</span></a></li>
<a href="{% url 'part-bom' part.id %}">BOM<span class="badge{% if part.is_bom_valid == False %} badge-alert{% endif %}">{{ part.bom_count }}</span></a></li>
<li{% ifequal tab 'build' %} class="active"{% endifequal %}>
<a href="{% url 'part-build' part.id %}">Build<span class='badge'>{{ part.active_builds|length }}</span></a></li>
{% endif %}

View File

@ -35,6 +35,7 @@ part_detail_urls = [
url(r'^edit/?', views.PartEdit.as_view(), name='part-edit'),
url(r'^delete/?', views.PartDelete.as_view(), name='part-delete'),
url(r'^bom-export/?', views.BomDownload.as_view(), name='bom-export'),
url(r'^validate-bom/', views.BomValidate.as_view(), name='bom-validate'),
url(r'^track/?', views.PartDetail.as_view(template_name='part/track.html'), name='part-track'),
url(r'^attachments/?', views.PartDetail.as_view(template_name='part/attachments.html'), name='part-attachments'),

View File

@ -331,12 +331,50 @@ class PartEdit(AjaxUpdateView):
return form
class BomValidate(AjaxUpdateView):
""" Modal form view for validating a part BOM """
model = Part
ajax_form_title = "Validate BOM"
ajax_template_name = 'part/bom_validate.html'
context_object_name = 'part'
form_class = part_forms.BomValidateForm
def get_context(self):
return {
'part': self.get_object(),
}
def get(self, request, *args, **kwargs):
form = self.get_form()
return self.renderJsonResponse(request, form, context=self.get_context())
def post(self, request, *args, **kwargs):
form = self.get_form()
part = self.get_object()
confirmed = str2bool(request.POST.get('validate', False))
if confirmed:
part.validate_bom(request.user)
else:
form.errors['validate'] = ['Confirm that the BOM is valid']
data = {
'form_valid': confirmed
}
return self.renderJsonResponse(request, form, data, context=self.get_context())
class BomExport(AjaxView):
model = Part
ajax_form_title = 'Export BOM'
ajax_template_name = 'part/bom_export.html'
context_object_name = 'part'
form_class = part_forms.BomExportForm
def get_object(self):
@ -345,17 +383,6 @@ class BomExport(AjaxView):
def get(self, request, *args, **kwargs):
form = self.form_class()
"""
part = self.get_object()
context = {
'part': part
}
if request.is_ajax():
passs
"""
return self.renderJsonResponse(request, form)
def post(self, request, *args, **kwargs):

View File

@ -14,6 +14,7 @@
color: #ffcc00;
}
/* CSS overrides for treeview */
.expand-icon {
font-size: 11px;
@ -97,6 +98,10 @@
margin-left: 10px;
}
.badge-alert {
background-color: #f33;
}
.part-thumb {
width: 200px;
height: 200px;
@ -219,6 +224,10 @@
pointer-events: all;
}
.alert-block {
display: block;
}
.btn {
margin-left: 2px;
margin-right: 2px;