mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge pull request #336 from SchrodingersGat/bom-overage
Add 'overage' field to BOM item
This commit is contained in:
commit
b57c2d2050
@ -7,9 +7,56 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
def validate_part_name(value):
|
||||
# Prevent some illegal characters in part names
|
||||
for c in ['|', '#', '$']:
|
||||
""" Prevent some illegal characters in part names.
|
||||
"""
|
||||
|
||||
for c in ['|', '#', '$', '{', '}']:
|
||||
if c in str(value):
|
||||
raise ValidationError(
|
||||
_('Invalid character in part name')
|
||||
)
|
||||
|
||||
|
||||
def validate_overage(value):
|
||||
""" Validate that a BOM overage string is properly formatted.
|
||||
|
||||
An overage string can look like:
|
||||
|
||||
- An integer number ('1' / 3 / 4)
|
||||
- A percentage ('5%' / '10 %')
|
||||
"""
|
||||
|
||||
value = str(value).lower().strip()
|
||||
|
||||
# First look for a simple integer value
|
||||
try:
|
||||
i = int(value)
|
||||
|
||||
if i < 0:
|
||||
raise ValidationError(_("Overage value must not be negative"))
|
||||
|
||||
# Looks like an integer!
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Now look for a percentage value
|
||||
if value.endswith('%'):
|
||||
v = value[:-1].strip()
|
||||
|
||||
# Does it look like a number?
|
||||
try:
|
||||
f = float(v)
|
||||
|
||||
if f < 0:
|
||||
raise ValidationError(_("Overage value must not be negative"))
|
||||
elif f > 100:
|
||||
raise ValidationError(_("Overage must not exceed 100%"))
|
||||
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
raise ValidationError(
|
||||
_("Overage must be an integer value or a percentage")
|
||||
)
|
||||
|
@ -261,7 +261,7 @@ class Build(models.Model):
|
||||
|
||||
try:
|
||||
item = BomItem.objects.get(part=self.part.id, sub_part=part.id)
|
||||
return item.quantity * self.quantity
|
||||
return item.get_required_quantity(self.quantity)
|
||||
except BomItem.DoesNotExist:
|
||||
return 0
|
||||
|
||||
|
@ -33,6 +33,14 @@
|
||||
supplier: {{ company.id }}
|
||||
},
|
||||
reload: true,
|
||||
secondary: [
|
||||
{
|
||||
field: 'part',
|
||||
label: 'New Part',
|
||||
title: 'Create New Part',
|
||||
url: "{% url 'part-create' %}"
|
||||
},
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -3,19 +3,19 @@
|
||||
{% load static %}
|
||||
|
||||
{% block page_title %}
|
||||
InvenTree | Company List
|
||||
InvenTree | Supplier List
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class='row'>
|
||||
<div class='col-sm-6'>
|
||||
<h3>Company List</h3>
|
||||
<h3>Supplier List</h3>
|
||||
</div>
|
||||
<div class='col-sm-6'>
|
||||
<div class='container' id='active-build-toolbar' style='float: right;'>
|
||||
<div class='btn-group' style='float: right;'>
|
||||
<button type='button' class="btn btn-success" id='new-company'>New Company</button>
|
||||
<button type='button' class="btn btn-success" id='new-company' title='Add new supplier'>New Supplier</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -54,7 +54,7 @@ InvenTree | Company List
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
title: 'Company',
|
||||
title: 'Supplier',
|
||||
sortable: true,
|
||||
formatter: function(value, row, index, field) {
|
||||
return imageHoverIcon(row.image) + renderLink(value, row.url);
|
||||
|
@ -133,6 +133,7 @@ class EditBomItemForm(HelperForm):
|
||||
'part',
|
||||
'sub_part',
|
||||
'quantity',
|
||||
'overage',
|
||||
'note'
|
||||
]
|
||||
|
||||
|
46
InvenTree/part/migrations/0025_auto_20190515_0012.py
Normal file
46
InvenTree/part/migrations/0025_auto_20190515_0012.py
Normal file
@ -0,0 +1,46 @@
|
||||
# Generated by Django 2.2 on 2019-05-14 14:12
|
||||
|
||||
import InvenTree.validators
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('part', '0024_partcategory_default_keywords'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='bomitem',
|
||||
name='overage',
|
||||
field=models.CharField(blank=True, help_text='Estimated build wastage quantity (absolute or percentage)', max_length=24, validators=[InvenTree.validators.validate_overage]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='bomitem',
|
||||
name='note',
|
||||
field=models.CharField(blank=True, help_text='BOM item notes', max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='bomitem',
|
||||
name='part',
|
||||
field=models.ForeignKey(help_text='Select parent part', limit_choices_to={'active': True, 'buildable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='bom_items', to='part.Part'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='bomitem',
|
||||
name='quantity',
|
||||
field=models.PositiveIntegerField(default=1, help_text='BOM quantity for this BOM item', validators=[django.core.validators.MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='bomitem',
|
||||
name='sub_part',
|
||||
field=models.ForeignKey(help_text='Select part to be used in BOM', limit_choices_to={'active': True, 'consumable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='used_in', to='part.Part'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='supplierpart',
|
||||
name='URL',
|
||||
field=models.URLField(blank=True, help_text='URL for external supplier part link'),
|
||||
),
|
||||
]
|
@ -300,6 +300,23 @@ class Part(models.Model):
|
||||
# Default case - no default category found
|
||||
return None
|
||||
|
||||
def get_default_supplier(self):
|
||||
""" Get the default supplier part for this part (may be None).
|
||||
|
||||
- If the part specifies a default_supplier, return that
|
||||
- If there is only one supplier part available, return that
|
||||
- Else, return None
|
||||
"""
|
||||
|
||||
if self.default_supplier:
|
||||
return self.default_suppliers
|
||||
|
||||
if self.supplier_count == 1:
|
||||
return self.supplier_parts.first()
|
||||
|
||||
# Default to None if there are multiple suppliers to choose from
|
||||
return None
|
||||
|
||||
default_supplier = models.ForeignKey('part.SupplierPart',
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True, null=True,
|
||||
@ -661,6 +678,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'
|
||||
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
|
||||
"""
|
||||
|
||||
@ -688,6 +706,10 @@ class BomItem(models.Model):
|
||||
# Quantity required
|
||||
quantity = models.PositiveIntegerField(default=1, validators=[MinValueValidator(0)], help_text='BOM quantity for this BOM item')
|
||||
|
||||
overage = models.CharField(max_length=24, blank=True, validators=[validators.validate_overage],
|
||||
help_text='Estimated build wastage quantity (absolute or percentage)'
|
||||
)
|
||||
|
||||
# Note attached to this BOM line item
|
||||
note = models.CharField(max_length=100, blank=True, help_text='BOM item notes')
|
||||
|
||||
@ -721,6 +743,62 @@ class BomItem(models.Model):
|
||||
child=self.sub_part.full_name,
|
||||
n=self.quantity)
|
||||
|
||||
def get_overage_quantity(self, quantity):
|
||||
""" Calculate overage quantity
|
||||
"""
|
||||
|
||||
# Most of the time overage string will be empty
|
||||
if len(self.overage) == 0:
|
||||
return 0
|
||||
|
||||
overage = str(self.overage).strip()
|
||||
|
||||
# Is the overage an integer value?
|
||||
try:
|
||||
ovg = int(overage)
|
||||
|
||||
if ovg < 0:
|
||||
ovg = 0
|
||||
|
||||
return ovg
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Is the overage a percentage?
|
||||
if overage.endswith('%'):
|
||||
overage = overage[:-1].strip()
|
||||
|
||||
try:
|
||||
percent = float(overage) / 100.0
|
||||
if percent > 1:
|
||||
percent = 1
|
||||
if percent < 0:
|
||||
percent = 0
|
||||
|
||||
return int(percent * quantity)
|
||||
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Default = No overage
|
||||
return 0
|
||||
|
||||
def get_required_quantity(self, build_quantity):
|
||||
""" Calculate the required part quantity, based on the supplier build_quantity.
|
||||
Includes overage estimate in the returned value.
|
||||
|
||||
Args:
|
||||
build_quantity: Number of parts to build
|
||||
|
||||
Returns:
|
||||
Quantity required for this build (including overage)
|
||||
"""
|
||||
|
||||
# Base quantity requirement
|
||||
base_quantity = self.quantity * build_quantity
|
||||
|
||||
return base_quantity + self.get_overage_quantity(base_quantity)
|
||||
|
||||
|
||||
class SupplierPart(models.Model):
|
||||
""" Represents a unique part as provided by a Supplier
|
||||
|
@ -1,89 +0,0 @@
|
||||
"""
|
||||
TODO - Implement part parameters, and templates
|
||||
|
||||
See code below
|
||||
"""
|
||||
|
||||
|
||||
|
||||
class PartParameterTemplate(models.Model):
|
||||
""" A PartParameterTemplate pre-defines a parameter field,
|
||||
ready to be copied for use with a given Part.
|
||||
A PartParameterTemplate can be optionally associated with a PartCategory
|
||||
"""
|
||||
name = models.CharField(max_length=20, unique=True)
|
||||
units = models.CharField(max_length=10, blank=True)
|
||||
|
||||
# Parameter format
|
||||
PARAM_NUMERIC = 10
|
||||
PARAM_TEXT = 20
|
||||
PARAM_BOOL = 30
|
||||
|
||||
PARAM_TYPE_CODES = {
|
||||
PARAM_NUMERIC: _("Numeric"),
|
||||
PARAM_TEXT: _("Text"),
|
||||
PARAM_BOOL: _("Bool")
|
||||
}
|
||||
|
||||
format = models.PositiveIntegerField(
|
||||
default=PARAM_NUMERIC,
|
||||
choices=PARAM_TYPE_CODES.items(),
|
||||
validators=[MinValueValidator(0)])
|
||||
|
||||
def __str__(self):
|
||||
return "{name} ({units})".format(
|
||||
name=self.name,
|
||||
units=self.units)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Parameter Template"
|
||||
verbose_name_plural = "Parameter Templates"
|
||||
|
||||
|
||||
class CategoryParameterLink(models.Model):
|
||||
""" Links a PartParameterTemplate to a PartCategory
|
||||
"""
|
||||
category = models.ForeignKey(PartCategory, on_delete=models.CASCADE)
|
||||
template = models.ForeignKey(PartParameterTemplate, on_delete=models.CASCADE)
|
||||
|
||||
def __str__(self):
|
||||
return "{name} - {cat}".format(
|
||||
name=self.template.name,
|
||||
cat=self.category)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Category Parameter"
|
||||
verbose_name_plural = "Category Parameters"
|
||||
unique_together = ('category', 'template')
|
||||
|
||||
|
||||
class PartParameter(models.Model):
|
||||
""" PartParameter is associated with a single part
|
||||
"""
|
||||
|
||||
part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='parameters')
|
||||
template = models.ForeignKey(PartParameterTemplate)
|
||||
|
||||
# Value data
|
||||
value = models.CharField(max_length=50, blank=True)
|
||||
min_value = models.CharField(max_length=50, blank=True)
|
||||
max_value = models.CharField(max_length=50, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return "{name} : {val}{units}".format(
|
||||
name=self.template.name,
|
||||
val=self.value,
|
||||
units=self.template.units)
|
||||
|
||||
@property
|
||||
def units(self):
|
||||
return self.template.units
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.template.name
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Part Parameter"
|
||||
verbose_name_plural = "Part Parameters"
|
||||
unique_together = ('part', 'template')
|
@ -104,8 +104,6 @@ class PartStarSerializer(InvenTreeModelSerializer):
|
||||
class BomItemSerializer(InvenTreeModelSerializer):
|
||||
""" Serializer for BomItem object """
|
||||
|
||||
# url = serializers.CharField(source='get_absolute_url', read_only=True)
|
||||
|
||||
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
||||
sub_part_detail = PartBriefSerializer(source='sub_part', many=False, read_only=True)
|
||||
|
||||
@ -113,12 +111,12 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
model = BomItem
|
||||
fields = [
|
||||
'pk',
|
||||
# 'url',
|
||||
'part',
|
||||
'part_detail',
|
||||
'sub_part',
|
||||
'sub_part_detail',
|
||||
'quantity',
|
||||
'overage',
|
||||
'note',
|
||||
]
|
||||
|
||||
|
@ -73,8 +73,22 @@
|
||||
{% if category %}
|
||||
data: {
|
||||
category: {{ category.id }}
|
||||
}
|
||||
},
|
||||
{% endif %}
|
||||
secondary: [
|
||||
{
|
||||
field: 'default_location',
|
||||
label: 'New Location',
|
||||
title: 'Create new location',
|
||||
url: "{% url 'stock-location-create' %}",
|
||||
},
|
||||
{
|
||||
field: 'parent',
|
||||
label: 'New Category',
|
||||
title: 'Create new category',
|
||||
url: "{% url 'category-create' %}",
|
||||
},
|
||||
]
|
||||
});
|
||||
})
|
||||
|
||||
|
@ -113,6 +113,15 @@ function loadBomTable(table, options) {
|
||||
title: 'Required',
|
||||
searchable: false,
|
||||
sortable: true,
|
||||
formatter: function(value, row, index, field) {
|
||||
var text = value;
|
||||
|
||||
if (row.overage) {
|
||||
text += "<small> (+" + row.overage + ") </small>";
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -80,7 +80,15 @@
|
||||
location: {{ location.id }}
|
||||
{% endif %}
|
||||
},
|
||||
follow: true
|
||||
follow: true,
|
||||
secondary: [
|
||||
{
|
||||
field: 'parent',
|
||||
label: 'New Location',
|
||||
title: 'Create new location',
|
||||
url: "{% url 'stock-location-create' %}",
|
||||
},
|
||||
]
|
||||
});
|
||||
return false;
|
||||
});
|
||||
|
@ -1,5 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% load static %}
|
||||
|
||||
{% block page_title %}
|
||||
InvenTree | Search Results
|
||||
{% endblock %}
|
||||
@ -19,6 +21,11 @@ InvenTree | Search Results
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_load %}
|
||||
{{ block.super }}
|
||||
<script type='text/javascript' src="{% static 'script/inventree/part.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
|
@ -9,7 +9,7 @@
|
||||
<li><a href="{% url 'part-index' %}">Parts</a></li>
|
||||
<li><a href="{% url 'stock-index' %}">Stock</a></li>
|
||||
<li><a href="{% url 'build-index' %}">Build</a></li>
|
||||
<li><a href="{% url 'company-index' %}">Companies</a></li>
|
||||
<li><a href="{% url 'company-index' %}">Suppliers</a></li>
|
||||
</ul>
|
||||
<ul class="nav navbar-nav navbar-right">
|
||||
{% include "search_form.html" %}
|
||||
|
Loading…
Reference in New Issue
Block a user