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" %} + +
Build | +Making | +Allocted | +Status | +
---|---|---|---|
{{ build.title }} | +{{ build.part.name }} | +Quantity | +{% include "part/build_status.html" with build=build %} | +
Title | +Quantity | +Status | +Completion Date | +
---|---|---|---|
Active Builds | +|||
Inactive Builds | +
{{ part.description }}
{% endif %} - - - -