diff --git a/InvenTree/build/admin.py b/InvenTree/build/admin.py index dc9590890e..dfbefc45e1 100644 --- a/InvenTree/build/admin.py +++ b/InvenTree/build/admin.py @@ -8,7 +8,14 @@ from .models import Build class BuildAdmin(admin.ModelAdmin): - list_display = ('status', ) - + list_display = ('part', + 'status', + 'batch', + 'quantity', + 'creation_date', + 'completion_date', + 'title', + 'notes', + ) admin.site.register(Build, BuildAdmin) diff --git a/InvenTree/build/migrations/0004_auto_20180417_0657.py b/InvenTree/build/migrations/0004_auto_20180417_0657.py new file mode 100644 index 0000000000..23211a8d7c --- /dev/null +++ b/InvenTree/build/migrations/0004_auto_20180417_0657.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-04-17 06:57 +from __future__ import unicode_literals + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0019_auto_20180416_1249'), + ('build', '0003_build_part'), + ] + + operations = [ + migrations.CreateModel( + name='BuildOutput', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.PositiveIntegerField(default=1, help_text='Number of parts to build', validators=[django.core.validators.MinValueValidator(1)])), + ], + ), + migrations.RemoveField( + model_name='build', + name='part', + ), + migrations.RemoveField( + model_name='build', + name='quantity', + ), + migrations.AlterField( + model_name='build', + name='status', + field=models.PositiveIntegerField(choices=[(40, 'Cancelled'), (10, 'Pending'), (20, 'Allocated'), (50, 'Complete'), (30, 'Holding')], default=10, validators=[django.core.validators.MinValueValidator(0)]), + ), + migrations.AddField( + model_name='buildoutput', + name='build', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='outputs', to='build.Build'), + ), + migrations.AddField( + model_name='buildoutput', + name='part', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='builds', to='part.Part'), + ), + ] diff --git a/InvenTree/build/migrations/0005_buildoutput_batch.py b/InvenTree/build/migrations/0005_buildoutput_batch.py new file mode 100644 index 0000000000..b53c83270e --- /dev/null +++ b/InvenTree/build/migrations/0005_buildoutput_batch.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-04-17 08:29 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('build', '0004_auto_20180417_0657'), + ] + + operations = [ + migrations.AddField( + model_name='buildoutput', + name='batch', + field=models.CharField(blank=True, help_text='Batch code for this build output', max_length=100), + ), + ] diff --git a/InvenTree/build/migrations/0006_auto_20180417_0933.py b/InvenTree/build/migrations/0006_auto_20180417_0933.py new file mode 100644 index 0000000000..3ed5efb74e --- /dev/null +++ b/InvenTree/build/migrations/0006_auto_20180417_0933.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-04-17 09:33 +from __future__ import unicode_literals + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0022_auto_20180417_0819'), + ('build', '0005_buildoutput_batch'), + ] + + operations = [ + migrations.RemoveField( + model_name='buildoutput', + name='build', + ), + migrations.RemoveField( + model_name='buildoutput', + name='part', + ), + migrations.AddField( + model_name='build', + name='batch', + field=models.CharField(blank=True, help_text='Batch code for this build output', max_length=100, null=True), + ), + migrations.AddField( + model_name='build', + name='completion_date', + field=models.DateField(blank=True, null=True), + ), + migrations.AddField( + model_name='build', + name='creation_date', + field=models.DateField(auto_now=True), + ), + migrations.AddField( + model_name='build', + name='notes', + field=models.CharField(blank=True, max_length=500), + ), + migrations.AddField( + model_name='build', + name='part', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='builds', to='part.Part'), + preserve_default=False, + ), + migrations.AddField( + model_name='build', + name='quantity', + field=models.PositiveIntegerField(default=1, help_text='Number of parts to build', validators=[django.core.validators.MinValueValidator(1)]), + ), + migrations.AddField( + model_name='build', + name='title', + field=models.CharField(default='Build title', help_text='Brief description of the build', max_length=100), + preserve_default=False, + ), + migrations.DeleteModel( + name='BuildOutput', + ), + ] diff --git a/InvenTree/build/migrations/0007_auto_20180417_1025.py b/InvenTree/build/migrations/0007_auto_20180417_1025.py new file mode 100644 index 0000000000..d50af03384 --- /dev/null +++ b/InvenTree/build/migrations/0007_auto_20180417_1025.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-04-17 10:25 +from __future__ import unicode_literals + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('build', '0006_auto_20180417_0933'), + ] + + operations = [ + migrations.AlterField( + model_name='build', + name='status', + field=models.PositiveIntegerField(choices=[(40, 'Complete'), (10, 'Pending'), (20, 'Holding'), (30, 'Cancelled')], default=10, validators=[django.core.validators.MinValueValidator(0)]), + ), + ] diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index a55abb15fc..ef4b495ced 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +from django.utils.translation import ugettext as _ + from django.db import models from django.core.validators import MinValueValidator -from InvenTree.helpers import ChoiceEnum - from part.models import Part class Build(models.Model): @@ -14,26 +14,65 @@ class Build(models.Model): Parts are then taken from stock """ - class BUILD_STATUS(ChoiceEnum): - # The build is 'pending' - no action taken yet - Pending = 10 + # Build status codes + PENDING = 10 # Build is pending / active + HOLDING = 20 # Build is currently being held + CANCELLED = 30 # Build was cancelled + COMPLETE = 40 # Build is complete - # The parts required for this build have been allocated - Allocated = 20 + BUILD_STATUS_CODES = { + PENDING : _("Pending"), + HOLDING : _("Holding"), + CANCELLED : _("Cancelled"), + COMPLETE : _("Complete"), + } - # The build has been cancelled (parts unallocated) - Cancelled = 30 - - # The build is complete! - Complete = 40 + batch = models.CharField(max_length=100, blank=True, null=True, + help_text='Batch code for this build output') # Status of the build - status = models.PositiveIntegerField(default=BUILD_STATUS.Pending.value, - choices=BUILD_STATUS.choices()) + status = models.PositiveIntegerField(default=PENDING, + choices=BUILD_STATUS_CODES.items(), + validators=[MinValueValidator(0)]) + + # Date the build model was 'created' + creation_date = models.DateField(auto_now=True, editable=False) + + # Date the build was 'completed' + completion_date = models.DateField(null=True, blank=True) + + # Brief build title + title = models.CharField(max_length=100, help_text='Brief description of the build') + + # A reference to the part being built + # Only 'buildable' parts can be selected part = models.ForeignKey(Part, on_delete=models.CASCADE, - related_name='builds') + related_name='builds', + limit_choices_to={'buildable': True}, + ) + # How many parts to build? quantity = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], help_text='Number of parts to build') + + # Notes can be attached to each build output + notes = models.CharField(max_length=500, blank=True) + + @property + def is_active(self): + """ Is this build active? + An active build is either: + - Pending + - Holding + """ + + return self.status in [ + self.PENDING, + self.HOLDING + ] + + @property + def is_complete(self): + return self.status == self.COMPLETE diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py index f986e1a0ae..dce4f007d7 100644 --- a/InvenTree/part/forms.py +++ b/InvenTree/part/forms.py @@ -24,10 +24,13 @@ class EditPartForm(forms.ModelForm): 'description', 'IPN', 'URL', + 'default_location', + 'default_supplier', 'minimum_stock', 'buildable', 'trackable', 'purchaseable', + 'salable', ] diff --git a/InvenTree/part/migrations/0020_part_salable.py b/InvenTree/part/migrations/0020_part_salable.py new file mode 100644 index 0000000000..9d24532273 --- /dev/null +++ b/InvenTree/part/migrations/0020_part_salable.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-04-17 08:06 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0019_auto_20180416_1249'), + ] + + operations = [ + migrations.AddField( + model_name='part', + name='salable', + field=models.BooleanField(default=False, help_text='Can this part be sold to customers?'), + ), + ] diff --git a/InvenTree/part/migrations/0021_part_default_location.py b/InvenTree/part/migrations/0021_part_default_location.py new file mode 100644 index 0000000000..0690abb280 --- /dev/null +++ b/InvenTree/part/migrations/0021_part_default_location.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-04-17 08:12 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0010_stockitem_build'), + ('part', '0020_part_salable'), + ] + + operations = [ + migrations.AddField( + model_name='part', + name='default_location', + field=models.ForeignKey(blank=True, help_text='Where is this item normally stored?', null=True, on_delete=django.db.models.deletion.SET_NULL, to='stock.StockLocation'), + ), + ] diff --git a/InvenTree/part/migrations/0022_auto_20180417_0819.py b/InvenTree/part/migrations/0022_auto_20180417_0819.py new file mode 100644 index 0000000000..ed7c74db03 --- /dev/null +++ b/InvenTree/part/migrations/0022_auto_20180417_0819.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-04-17 08:19 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('supplier', '0007_auto_20180416_1253'), + ('part', '0021_part_default_location'), + ] + + operations = [ + migrations.AddField( + model_name='part', + name='default_supplier', + field=models.ForeignKey(blank=True, help_text='Default supplier part', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_parts', to='supplier.SupplierPart'), + ), + migrations.AlterField( + model_name='part', + name='default_location', + field=models.ForeignKey(blank=True, help_text='Where is this item normally stored?', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_parts', to='stock.StockLocation'), + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 3d2fe90628..2002165909 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -1,15 +1,18 @@ +# -*- coding: utf-8 -*- from __future__ import unicode_literals + +import os + from django.db import models from django.db.models import Sum from django.core.validators import MinValueValidator -from InvenTree.models import InvenTreeTree - -import os - from django.db.models.signals import pre_delete from django.dispatch import receiver +from InvenTree.models import InvenTreeTree +# from stock.models import StockLocation + class PartCategory(InvenTreeTree): """ PartCategory provides hierarchical organization of Part objects. @@ -103,6 +106,18 @@ class Part(models.Model): image = models.ImageField(upload_to=rename_part_image, max_length=255, null=True, blank=True) + default_location = models.ForeignKey('stock.StockLocation', on_delete=models.SET_NULL, + blank=True, null=True, + help_text='Where is this item normally stored?', + related_name='default_parts') + + # Default supplier part + default_supplier = models.ForeignKey('supplier.SupplierPart', + on_delete=models.SET_NULL, + blank=True, null=True, + help_text='Default supplier part', + related_name='default_parts') + # Minimum "allowed" stock level minimum_stock = models.PositiveIntegerField(default=0, validators=[MinValueValidator(0)], help_text='Minimum allowed stock level') @@ -121,6 +136,9 @@ class Part(models.Model): # 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?") + def __str__(self): if self.IPN: return "{name} ({ipn})".format( @@ -140,9 +158,11 @@ class Part(models.Model): This subtracts stock which is already allocated """ - # TODO - For now, just return total stock count - # TODO - In future must take account of allocated stock - return self.total_stock + total = self.total_stock + + total -= self.allocation_count + + return max(total, 0) @property def can_build(self): @@ -163,8 +183,68 @@ class Part(models.Model): if total is None or n < total: total = n + return max(total, 0) + + @property + def active_builds(self): + """ Return a list of outstanding builds. + Builds marked as 'complete' or 'cancelled' are ignored + """ + + return [b for b in self.builds.all() if b.is_active] + + @property + def inactive_builds(self): + """ Return a list of inactive builds + """ + + return [b for b in self.builds.all() if not b.is_active] + + @property + def quantity_being_built(self): + """ Return the current number of parts currently being built + """ + + return sum([b.quantity for b in self.active_builds]) + + @property + def allocated_builds(self): + """ Return list of builds to which this part is allocated + """ + + builds = [] + + for item in self.used_in.all(): + for build in item.part.active_builds: + builds.append(build) + + return builds + + @property + def allocated_build_count(self): + """ Return the total number of this that are allocated for builds + """ + + total = 0 + + for item in self.used_in.all(): + for build in item.part.active_builds: + n = build.quantity * item.quantity + total += n + return total + @property + def allocation_count(self): + """ Return true if any of this part is allocated + - To another build + - To a customer order + """ + + return sum([ + self.allocated_build_count, + ]) + @property def total_stock(self): """ Return the total stock quantity for this part. diff --git a/InvenTree/part/templates/part/allocation.html b/InvenTree/part/templates/part/allocation.html new file mode 100644 index 0000000000..35b8426815 --- /dev/null +++ b/InvenTree/part/templates/part/allocation.html @@ -0,0 +1,29 @@ +{% extends "part/part_base.html" %} + +{% block details %} + +{% include "part/tabs.html" with tab="allocation" %} + +

Part Allocation

+ +{% if part.allocated_build_count > 0 %} +

Allocated to Part Builds

+ + + + + + + +{% for build in part.allocated_builds %} + + + + + + +{% endfor %} +
BuildMakingAlloctedStatus
{{ build.title }}{{ build.part.name }}Quantity{% include "part/build_status.html" with build=build %}
+{% endif %} + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/build.html b/InvenTree/part/templates/part/build.html index 0c90630bc1..3aae16377f 100644 --- a/InvenTree/part/templates/part/build.html +++ b/InvenTree/part/templates/part/build.html @@ -4,10 +4,31 @@ {% include 'part/tabs.html' with tab='build' %} -

Build Part

+

Part Builds

-TODO -

-You can build {{ part.can_build }} of this part with current stock. + + + + + + + + +{% if part.active_builds|length > 0 %} + + + +{% include "part/build_list.html" with builds=part.active_builds %} +{% endif %} + +{% if part.inactive_builds|length > 0 %} + + + + +{% include "part/build_list.html" with builds=part.inactive_builds %} +{% endif %} + +
TitleQuantityStatusCompletion Date
Active Builds
Inactive Builds
{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/build_list.html b/InvenTree/part/templates/part/build_list.html new file mode 100644 index 0000000000..8798323204 --- /dev/null +++ b/InvenTree/part/templates/part/build_list.html @@ -0,0 +1,10 @@ +{% for build in builds %} + + {{ build.title }} + {{ build.quantity }} + + {% include "part/build_status.html" with build=build %} + + {% if build.completion_date %}{{ build.completion_date }}{% endif %} + +{% endfor %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/build_status.html b/InvenTree/part/templates/part/build_status.html new file mode 100644 index 0000000000..1702ceccae --- /dev/null +++ b/InvenTree/part/templates/part/build_status.html @@ -0,0 +1,11 @@ +{% if build.status == build.PENDING %} + +{% elif build.status == build.HOLDING %} + +{% elif build.status == build.CANCELLED %} + +{% elif build.status == build.COMPLETE %} + +{% endif %} +{{ build.get_status_display }} + \ No newline at end of file diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index 1059b65f04..b45d62aa15 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -23,21 +23,37 @@ {% endif %} +{% if part.default_location %} + + Default Location + {{ part.default_location.pathstring }} + +{% endif %} +{% if part.default_supplier %} + + Default Supplier + {{ part.default_supplier.supplier.name }} | {{ part.default_supplier.SKU }} + +{% endif %} Units {{ part.units }} Buildable - {{ part.buildable }} + {% include "yesnolabel.html" with value=part.buildable %} Trackable - {{ part.trackable }} + {% include "yesnolabel.html" with value=part.trackable %} Purchaseable - {{ part.purchaseable }} + {% include "yesnolabel.html" with value=part.purchaseable %} + + + Salable + {% include "yesnolabel.html" with value=part.salable %} {% if part.minimum_stock > 0 %} diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index 69840829e3..a9f66a76b9 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -22,11 +22,6 @@ {% if part.description %}

{{ part.description }}

{% endif %} - - - -
- {% if part.IPN %} @@ -39,15 +34,22 @@ {% endif %} + + + + +
+

Stock Status - {{ part.available_stock }} available

+
IPN{{ part.URL }}
- + @@ -62,6 +64,23 @@ {% endif %} + {% if part.quantity_being_built > 0 %} + + + + + {% endif %} + {% endif %} + {% if part.allocation_count > 0 %} + + + {% if part.allocation_count > part.total_stock %} + + {% endif %}
Available StockIn Stock - {% if part.available_stock == 0 %} - {{ part.available_stock }} - {% elif part.available_stock < part.minimum_stock %} - {{ part.available_stock }} + {% if part.stock == 0 %} + {{ part.total_stock }} + {% elif part.stock < part.minimum_stock %} + {{ part.total_stock }} {% else %} - {{ part.available_stock }} + {{ part.total_stock }} {% endif %}
Underway{{ part.quantity_being_built }}
Allocated{{ part.allocation_count }} + {% else %} + {{ part.allocation_count }} {{ part.total_stock }} + {% endif %} +
diff --git a/InvenTree/part/templates/part/tabs.html b/InvenTree/part/templates/part/tabs.html index ce1cb7645b..da0b1c9ec2 100644 --- a/InvenTree/part/templates/part/tabs.html +++ b/InvenTree/part/templates/part/tabs.html @@ -7,7 +7,10 @@ {% if part.used_in_count > 0 %} Used In{% if part.used_in_count > 0 %}{{ part.used_in_count }}{% endif %} {% endif %} - Stock {{ part.available_stock }} + Stock {{ part.total_stock }} + {% if part.allocation_count > 0 %} + Allocated {{ part.allocation_count }} + {% endif %} {% if part.purchaseable %} Suppliers {{ part.supplier_count }} diff --git a/InvenTree/part/templates/part/used_in.html b/InvenTree/part/templates/part/used_in.html index 86a57f0b69..4a691aac23 100644 --- a/InvenTree/part/templates/part/used_in.html +++ b/InvenTree/part/templates/part/used_in.html @@ -9,11 +9,13 @@ + {% for item in part.used_in.all %} + {% endfor %} diff --git a/InvenTree/part/templates/yesnolabel.html b/InvenTree/part/templates/yesnolabel.html new file mode 100644 index 0000000000..cdc6070560 --- /dev/null +++ b/InvenTree/part/templates/yesnolabel.html @@ -0,0 +1,5 @@ +{% if value %} +Yes +{% else %} +No +{% endif %} \ No newline at end of file diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index ab95b2f43f..97888dc20b 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -44,6 +44,7 @@ part_detail_urls = [ url(r'^build/?', views.PartDetail.as_view(template_name='part/build.html'), name='part-build'), url(r'^stock/?', views.PartDetail.as_view(template_name='part/stock.html'), name='part-stock'), url(r'^used/?', views.PartDetail.as_view(template_name='part/used_in.html'), name='part-used-in'), + url(r'^allocation/?', views.PartDetail.as_view(template_name='part/allocation.html'), name='part-allocation'), url(r'^suppliers/?', views.PartDetail.as_view(template_name='part/supplier.html'), name='part-suppliers'), # Any other URLs go to the part detail page diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 358d11e1d8..c3a5514730 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -1,5 +1,8 @@ +# -*- coding: utf-8 -*- from __future__ import unicode_literals + from django.utils.translation import ugettext as _ + from django.db import models, transaction from django.core.validators import MinValueValidator from django.contrib.auth.models import User diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index cc715c0669..089953b16f 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -88,7 +88,11 @@ class StockItemCreate(CreateView): loc_id = self.request.GET.get('location', None) if part_id: - initials['part'] = get_object_or_404(Part, pk=part_id) + part = get_object_or_404(Part, pk=part_id) + if part: + initials['part'] = get_object_or_404(Part, pk=part_id) + initials['location'] = part.default_location + initials['supplier_part'] = part.default_supplier if loc_id: initials['location'] = get_object_or_404(StockLocation, pk=loc_id) diff --git a/requirements/base.txt b/requirements/base.txt index b39b8ebd07..9c9b4ed8b5 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,9 +1,9 @@ -Django==1.11 -pillow==3.1.2 -djangorestframework==3.6.2 -django_filter==1.0.2 -django-simple-history==1.8.2 -coreapi==2.3.0 -pygments==2.2.0 -django-crispy-forms==1.7.2 -django-import-export==1.0.0 \ No newline at end of file +Django>=1.11 +pillow>=5.0.0 +djangorestframework>=3.6.2 +django_filter>=1.0.2 +django-simple-history>=1.8.2 +coreapi>=2.3.0 +pygments>=2.2.0 +django-crispy-forms>=1.7.2 +django-import-export>=1.0.0 \ No newline at end of file
PartUses Description
{{ item.part.name }}{{ item.quantity }} {{ item.part.description }}