diff --git a/.gitignore b/.gitignore index 6229f979df..bfbaf7c285 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ var/ *.log local_settings.py *.sqlite3 +*.backup # Sphinx files docs/_build diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index 2872096c08..1bffd90229 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -11,9 +11,10 @@ from django.contrib.auth import views as auth_views from qr_code import urls as qr_code_urls from company.urls import company_urls +from company.urls import supplier_part_urls +from company.urls import price_break_urls from part.urls import part_urls -from part.urls import supplier_part_urls from stock.urls import stock_urls @@ -50,6 +51,7 @@ apipatterns = [ urlpatterns = [ url(r'^part/', include(part_urls)), url(r'^supplier-part/', include(supplier_part_urls)), + url(r'^price-break/', include(price_break_urls)), url(r'^stock/', include(stock_urls)), diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py index 9d5e1a2dc4..f6fdbc4d46 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -223,6 +223,7 @@ class AjaxCreateView(AjaxMixin, CreateView): super(CreateView, self).get(request, *args, **kwargs) + self.request = request form = self.get_form() return self.renderJsonResponse(request, form) @@ -233,6 +234,7 @@ class AjaxCreateView(AjaxMixin, CreateView): - If valid, save form - Return status info (success / failure) """ + self.request = request form = self.get_form() # Extra JSON data sent alongside form diff --git a/InvenTree/build/migrations/0001_initial.py b/InvenTree/build/migrations/0001_initial.py index 26e835b978..aa7356389a 100644 --- a/InvenTree/build/migrations/0001_initial.py +++ b/InvenTree/build/migrations/0001_initial.py @@ -1,7 +1,6 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.12 on 2018-04-22 11:53 -from __future__ import unicode_literals +# Generated by Django 2.2 on 2019-05-18 14:04 +from django.conf import settings import django.core.validators from django.db import migrations, models import django.db.models.deletion @@ -12,7 +11,9 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('part', '0001_initial'), + ('part', '0002_auto_20190519_0004'), + ('stock', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ @@ -20,14 +21,29 @@ class Migration(migrations.Migration): name='Build', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('batch', models.CharField(blank=True, help_text='Batch code for this build output', max_length=100, null=True)), - ('status', models.PositiveIntegerField(choices=[(40, 'Complete'), (10, 'Pending'), (20, 'Holding'), (30, 'Cancelled')], default=10, validators=[django.core.validators.MinValueValidator(0)])), - ('creation_date', models.DateField(auto_now=True)), - ('completion_date', models.DateField(blank=True, null=True)), ('title', models.CharField(help_text='Brief description of the build', max_length=100)), ('quantity', models.PositiveIntegerField(default=1, help_text='Number of parts to build', validators=[django.core.validators.MinValueValidator(1)])), - ('notes', models.TextField(blank=True)), - ('part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='builds', to='part.Part')), + ('status', models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Allocated'), (30, 'Cancelled'), (40, 'Complete')], default=10, help_text='Build status', validators=[django.core.validators.MinValueValidator(0)])), + ('batch', models.CharField(blank=True, help_text='Batch code for this build output', max_length=100, null=True)), + ('creation_date', models.DateField(auto_now=True)), + ('completion_date', models.DateField(blank=True, null=True)), + ('URL', models.URLField(blank=True, help_text='Link to external URL')), + ('notes', models.TextField(blank=True, help_text='Extra build notes')), + ('completed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='builds_completed', to=settings.AUTH_USER_MODEL)), + ('part', models.ForeignKey(help_text='Select part to build', limit_choices_to={'active': True, 'buildable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='builds', to='part.Part')), + ('take_from', models.ForeignKey(blank=True, help_text='Select location to take stock from for this build (leave blank to take from any stock location)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sourcing_builds', to='stock.StockLocation')), ], ), + migrations.CreateModel( + name='BuildItem', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.PositiveIntegerField(default=1, help_text='Stock quantity to allocate to build', validators=[django.core.validators.MinValueValidator(1)])), + ('build', models.ForeignKey(help_text='Build to allocate parts', on_delete=django.db.models.deletion.CASCADE, related_name='allocated_stock', to='build.Build')), + ('stock_item', models.ForeignKey(help_text='Stock Item to allocate to build', on_delete=django.db.models.deletion.CASCADE, related_name='allocations', to='stock.StockItem')), + ], + options={ + 'unique_together': {('build', 'stock_item')}, + }, + ), ] diff --git a/InvenTree/build/migrations/0002_auto_20190412_2030.py b/InvenTree/build/migrations/0002_auto_20190412_2030.py deleted file mode 100644 index eca2bf5adb..0000000000 --- a/InvenTree/build/migrations/0002_auto_20190412_2030.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 2.2 on 2019-04-12 10:30 - -import django.core.validators -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('build', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='build', - name='part', - field=models.ForeignKey(limit_choices_to={'buildable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='builds', to='part.Part'), - ), - migrations.AlterField( - model_name='build', - name='status', - field=models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Holding'), (30, 'Cancelled'), (40, 'Complete')], default=10, validators=[django.core.validators.MinValueValidator(0)]), - ), - ] diff --git a/InvenTree/build/migrations/0003_builditemallocation.py b/InvenTree/build/migrations/0003_builditemallocation.py deleted file mode 100644 index add13c7ac1..0000000000 --- a/InvenTree/build/migrations/0003_builditemallocation.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 2.2 on 2019-04-29 12:14 - -import django.core.validators -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('stock', '0009_auto_20190428_0841'), - ('build', '0002_auto_20190412_2030'), - ] - - operations = [ - migrations.CreateModel( - name='BuildItemAllocation', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('quantity', models.PositiveIntegerField(default=1, help_text='Stock quantity to allocate to build', validators=[django.core.validators.MinValueValidator(1)])), - ('build', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allocated_stock', to='build.Build')), - ('stock', models.ForeignKey(help_text='Stock Item to allocate to build', on_delete=django.db.models.deletion.CASCADE, related_name='allocations', to='stock.StockItem')), - ], - ), - ] diff --git a/InvenTree/build/migrations/0004_build_url.py b/InvenTree/build/migrations/0004_build_url.py deleted file mode 100644 index 187ac938d1..0000000000 --- a/InvenTree/build/migrations/0004_build_url.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.2 on 2019-04-29 12:18 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('build', '0003_builditemallocation'), - ] - - operations = [ - migrations.AddField( - model_name='build', - name='URL', - field=models.URLField(blank=True, help_text='Link to external URL'), - ), - ] diff --git a/InvenTree/build/migrations/0005_auto_20190429_2229.py b/InvenTree/build/migrations/0005_auto_20190429_2229.py deleted file mode 100644 index 73ebca7e1b..0000000000 --- a/InvenTree/build/migrations/0005_auto_20190429_2229.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.2 on 2019-04-29 12:29 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('stock', '0009_auto_20190428_0841'), - ('build', '0004_build_url'), - ] - - operations = [ - migrations.RenameModel( - old_name='BuildItemAllocation', - new_name='BuildItem', - ), - ] diff --git a/InvenTree/build/migrations/0006_auto_20190429_2233.py b/InvenTree/build/migrations/0006_auto_20190429_2233.py deleted file mode 100644 index 5d03e13b75..0000000000 --- a/InvenTree/build/migrations/0006_auto_20190429_2233.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.2 on 2019-04-29 12:33 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('build', '0005_auto_20190429_2229'), - ] - - operations = [ - migrations.RenameField( - model_name='builditem', - old_name='stock', - new_name='stock_item', - ), - ] diff --git a/InvenTree/build/migrations/0007_auto_20190429_2255.py b/InvenTree/build/migrations/0007_auto_20190429_2255.py deleted file mode 100644 index 8a01606b84..0000000000 --- a/InvenTree/build/migrations/0007_auto_20190429_2255.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.2 on 2019-04-29 12:55 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('stock', '0009_auto_20190428_0841'), - ('build', '0006_auto_20190429_2233'), - ] - - operations = [ - migrations.AlterUniqueTogether( - name='builditem', - unique_together={('build', 'stock_item')}, - ), - ] diff --git a/InvenTree/build/migrations/0008_auto_20190501_2344.py b/InvenTree/build/migrations/0008_auto_20190501_2344.py deleted file mode 100644 index febdd2d1b1..0000000000 --- a/InvenTree/build/migrations/0008_auto_20190501_2344.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 2.2 on 2019-05-01 13:44 - -import django.core.validators -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('build', '0007_auto_20190429_2255'), - ] - - operations = [ - migrations.AlterField( - model_name='build', - name='status', - field=models.PositiveIntegerField(choices=[(10, 'Pending'), (30, 'Cancelled'), (40, 'Complete')], default=10, validators=[django.core.validators.MinValueValidator(0)]), - ), - migrations.AlterField( - model_name='builditem', - name='build', - field=models.ForeignKey(help_text='Build to allocate parts', on_delete=django.db.models.deletion.CASCADE, related_name='allocated_stock', to='build.Build'), - ), - ] diff --git a/InvenTree/build/migrations/0009_build_completed_by.py b/InvenTree/build/migrations/0009_build_completed_by.py deleted file mode 100644 index dc6c05a3f7..0000000000 --- a/InvenTree/build/migrations/0009_build_completed_by.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 2.2 on 2019-05-02 21:26 - -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), - ('build', '0008_auto_20190501_2344'), - ] - - operations = [ - migrations.AddField( - model_name='build', - name='completed_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='builds_completed', to=settings.AUTH_USER_MODEL), - ), - ] diff --git a/InvenTree/build/migrations/0010_auto_20190505_2233.py b/InvenTree/build/migrations/0010_auto_20190505_2233.py deleted file mode 100644 index 5be6c393c9..0000000000 --- a/InvenTree/build/migrations/0010_auto_20190505_2233.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.2 on 2019-05-05 12:33 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('build', '0009_build_completed_by'), - ] - - operations = [ - migrations.AlterField( - model_name='build', - name='part', - field=models.ForeignKey(limit_choices_to={'active': True, 'buildable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='builds', to='part.Part'), - ), - ] diff --git a/InvenTree/build/migrations/0011_auto_20190508_0748.py b/InvenTree/build/migrations/0011_auto_20190508_0748.py deleted file mode 100644 index e4c0ec5fa7..0000000000 --- a/InvenTree/build/migrations/0011_auto_20190508_0748.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.2 on 2019-05-07 21:48 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('build', '0010_auto_20190505_2233'), - ] - - operations = [ - migrations.AlterField( - model_name='build', - name='status', - field=models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Allocated'), (30, 'Cancelled'), (40, 'Complete')], default=10, validators=[django.core.validators.MinValueValidator(0)]), - ), - ] diff --git a/InvenTree/build/migrations/0012_auto_20190508_2332.py b/InvenTree/build/migrations/0012_auto_20190508_2332.py deleted file mode 100644 index 099fce3fe9..0000000000 --- a/InvenTree/build/migrations/0012_auto_20190508_2332.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 2.2 on 2019-05-08 13:32 - -import django.core.validators -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('build', '0011_auto_20190508_0748'), - ] - - operations = [ - migrations.AlterField( - model_name='build', - name='notes', - field=models.TextField(blank=True, help_text='Extra build notes'), - ), - migrations.AlterField( - model_name='build', - name='part', - field=models.ForeignKey(help_text='Select part to build', limit_choices_to={'active': True, 'buildable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='builds', to='part.Part'), - ), - migrations.AlterField( - model_name='build', - name='status', - field=models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Allocated'), (30, 'Cancelled'), (40, 'Complete')], default=10, help_text='Build status', validators=[django.core.validators.MinValueValidator(0)]), - ), - ] diff --git a/InvenTree/build/migrations/0013_build_take_from.py b/InvenTree/build/migrations/0013_build_take_from.py deleted file mode 100644 index 9945da2be1..0000000000 --- a/InvenTree/build/migrations/0013_build_take_from.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 2.2 on 2019-05-10 08:50 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('stock', '0015_stockitem_delete_on_deplete'), - ('build', '0012_auto_20190508_2332'), - ] - - operations = [ - migrations.AddField( - model_name='build', - name='take_from', - field=models.ForeignKey(blank=True, help_text='Select location to take stock from for this build (leave blank to take from any stock location', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sourcing_builds', to='stock.StockLocation'), - ), - ] diff --git a/InvenTree/build/templates/build/build_base.html b/InvenTree/build/templates/build/build_base.html index 620db18df7..b2102ed74b 100644 --- a/InvenTree/build/templates/build/build_base.html +++ b/InvenTree/build/templates/build/build_base.html @@ -36,6 +36,19 @@ InvenTree | Build - {{ build }} Quantity {{ build.quantity }} + + BOM Price + + {% if bom_price %} + {{ bom_price }} + {% if build.part.has_complete_bom_pricing == False %} + BOM pricing is incomplete + {% endif %} + {% else %} + No pricing information + {% endif %} + + diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py index 6f19c67754..14e22a3fe8 100644 --- a/InvenTree/build/views.py +++ b/InvenTree/build/views.py @@ -265,6 +265,16 @@ class BuildDetail(DetailView): template_name = 'build/detail.html' context_object_name = 'build' + def get_context_data(self, **kwargs): + + ctx = super(DetailView, self).get_context_data(**kwargs) + + build = self.get_object() + + ctx['bom_price'] = build.part.get_price_info(build.quantity, buy=False) + + return ctx + class BuildAllocate(DetailView): """ View for allocating parts to a Build """ diff --git a/InvenTree/company/admin.py b/InvenTree/company/admin.py index cbeff65cf8..607f773198 100644 --- a/InvenTree/company/admin.py +++ b/InvenTree/company/admin.py @@ -2,10 +2,22 @@ from django.contrib import admin from import_export.admin import ImportExportModelAdmin from .models import Company +from .models import SupplierPart +from .models import SupplierPriceBreak class CompanyAdmin(ImportExportModelAdmin): list_display = ('name', 'website', 'contact') +class SupplierPartAdmin(ImportExportModelAdmin): + list_display = ('part', 'supplier', 'SKU') + + +class SupplierPriceBreakAdmin(ImportExportModelAdmin): + list_display = ('part', 'quantity', 'cost') + + admin.site.register(Company, CompanyAdmin) +admin.site.register(SupplierPart, SupplierPartAdmin) +admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin) diff --git a/InvenTree/company/api.py b/InvenTree/company/api.py index dfa3ebfb77..894974d3e9 100644 --- a/InvenTree/company/api.py +++ b/InvenTree/company/api.py @@ -9,10 +9,13 @@ from django_filters.rest_framework import DjangoFilterBackend from rest_framework import filters from rest_framework import generics, permissions -from django.conf.urls import url +from django.conf.urls import url, include from .models import Company +from .models import SupplierPart, SupplierPriceBreak + from .serializers import CompanySerializer +from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer class CompanyList(generics.ListCreateAPIView): @@ -65,7 +68,85 @@ class CompanyDetail(generics.RetrieveUpdateDestroyAPIView): ] +class SupplierPartList(generics.ListCreateAPIView): + """ API endpoint for list view of SupplierPart object + + - GET: Return list of SupplierPart objects + - POST: Create a new SupplierPart object + """ + + queryset = SupplierPart.objects.all() + serializer_class = SupplierPartSerializer + + permission_classes = [ + permissions.IsAuthenticatedOrReadOnly, + ] + + filter_backends = [ + DjangoFilterBackend, + filters.SearchFilter, + filters.OrderingFilter, + ] + + filter_fields = [ + 'part', + 'supplier' + ] + + +class SupplierPartDetail(generics.RetrieveUpdateDestroyAPIView): + """ API endpoint for detail view of SupplierPart object + + - GET: Retrieve detail view + - PATCH: Update object + - DELETE: Delete objec + """ + + queryset = SupplierPart.objects.all() + serializer_class = SupplierPartSerializer + permission_classes = (permissions.IsAuthenticatedOrReadOnly,) + + read_only_fields = [ + ] + + +class SupplierPriceBreakList(generics.ListCreateAPIView): + """ API endpoint for list view of SupplierPriceBreak object + + - GET: Retrieve list of SupplierPriceBreak objects + - POST: Create a new SupplierPriceBreak object + """ + + queryset = SupplierPriceBreak.objects.all() + serializer_class = SupplierPriceBreakSerializer + + permission_classes = [ + permissions.IsAuthenticatedOrReadOnly, + ] + + filter_backends = [ + DjangoFilterBackend, + ] + + filter_fields = [ + 'part', + ] + + +supplier_part_api_urls = [ + + url(r'^(?P\d+)/?', SupplierPartDetail.as_view(), name='api-supplier-part-detail'), + + # Catch anything else + url(r'^.*$', SupplierPartList.as_view(), name='api-part-supplier-list'), +] + + company_api_urls = [ + + url(r'^part/', include(supplier_part_api_urls)), + + url(r'^price-break/?', SupplierPriceBreakList.as_view(), name='api-part-supplier-price'), url(r'^(?P\d+)/?', CompanyDetail.as_view(), name='api-company-detail'), diff --git a/InvenTree/company/forms.py b/InvenTree/company/forms.py index 679fe323ee..0ad63b9d63 100644 --- a/InvenTree/company/forms.py +++ b/InvenTree/company/forms.py @@ -8,6 +8,8 @@ from __future__ import unicode_literals from InvenTree.forms import HelperForm from .models import Company +from .models import SupplierPart +from .models import SupplierPriceBreak class EditCompanyForm(HelperForm): @@ -37,3 +39,37 @@ class CompanyImageForm(HelperForm): fields = [ 'image' ] + + +class EditSupplierPartForm(HelperForm): + """ Form for editing a SupplierPart object """ + + class Meta: + model = SupplierPart + fields = [ + 'part', + 'supplier', + 'SKU', + 'description', + 'manufacturer', + 'MPN', + 'URL', + 'note', + 'base_cost', + 'multiple', + 'minimum', + 'packaging', + 'lead_time' + ] + + +class EditPriceBreakForm(HelperForm): + """ Form for creating / editing a supplier price break """ + + class Meta: + model = SupplierPriceBreak + fields = [ + 'part', + 'quantity', + 'cost' + ] diff --git a/InvenTree/company/migrations/0001_initial.py b/InvenTree/company/migrations/0001_initial.py index 791011304c..b4da1df89e 100644 --- a/InvenTree/company/migrations/0001_initial.py +++ b/InvenTree/company/migrations/0001_initial.py @@ -1,9 +1,9 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.12 on 2018-04-22 11:53 -from __future__ import unicode_literals +# Generated by Django 2.2 on 2019-05-18 14:04 import company.models +import django.core.validators from django.db import migrations, models +import django.db.models.deletion class Migration(migrations.Migration): @@ -18,15 +18,60 @@ class Migration(migrations.Migration): name='Company', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(help_text='Company naem', max_length=100, unique=True)), - ('description', models.CharField(max_length=500)), + ('name', models.CharField(help_text='Company name', max_length=100, unique=True)), + ('description', models.CharField(help_text='Description of the company', max_length=500)), ('website', models.URLField(blank=True, help_text='Company website URL')), ('address', models.CharField(blank=True, help_text='Company address', max_length=200)), - ('phone', models.CharField(blank=True, max_length=50)), - ('email', models.EmailField(blank=True, max_length=254)), - ('contact', models.CharField(blank=True, max_length=100)), + ('phone', models.CharField(blank=True, help_text='Contact phone number', max_length=50)), + ('email', models.EmailField(blank=True, help_text='Contact email address', max_length=254)), + ('contact', models.CharField(blank=True, help_text='Point of contact', max_length=100)), + ('URL', models.URLField(blank=True, help_text='Link to external company information')), ('image', models.ImageField(blank=True, max_length=255, null=True, upload_to=company.models.rename_company_image)), ('notes', models.TextField(blank=True)), + ('is_customer', models.BooleanField(default=False, help_text='Do you sell items to this company?')), + ('is_supplier', models.BooleanField(default=True, help_text='Do you purchase items from this company?')), ], ), + migrations.CreateModel( + name='Contact', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('phone', models.CharField(blank=True, max_length=100)), + ('email', models.EmailField(blank=True, max_length=254)), + ('role', models.CharField(blank=True, max_length=100)), + ], + ), + migrations.CreateModel( + name='SupplierPart', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('SKU', models.CharField(help_text='Supplier stock keeping unit', max_length=100)), + ('manufacturer', models.CharField(blank=True, help_text='Manufacturer', max_length=100)), + ('MPN', models.CharField(blank=True, help_text='Manufacturer part number', max_length=100)), + ('URL', models.URLField(blank=True, help_text='URL for external supplier part link')), + ('description', models.CharField(blank=True, help_text='Supplier part description', max_length=250)), + ('note', models.CharField(blank=True, help_text='Notes', max_length=100)), + ('base_cost', models.DecimalField(decimal_places=3, default=0, help_text='Minimum charge (e.g. stocking fee)', max_digits=10, validators=[django.core.validators.MinValueValidator(0)])), + ('packaging', models.CharField(blank=True, help_text='Part packaging', max_length=50)), + ('multiple', models.PositiveIntegerField(default=1, help_text='Order multiple', validators=[django.core.validators.MinValueValidator(1)])), + ('minimum', models.PositiveIntegerField(default=1, help_text='Minimum order quantity (MOQ)', validators=[django.core.validators.MinValueValidator(1)])), + ('lead_time', models.DurationField(blank=True, null=True)), + ], + options={ + 'db_table': 'part_supplierpart', + }, + ), + migrations.CreateModel( + name='SupplierPriceBreak', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.PositiveIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1)])), + ('cost', models.DecimalField(decimal_places=5, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])), + ('part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pricebreaks', to='company.SupplierPart')), + ], + options={ + 'db_table': 'part_supplierpricebreak', + }, + ), ] diff --git a/InvenTree/company/migrations/0002_auto_20180422_1201.py b/InvenTree/company/migrations/0002_auto_20180422_1201.py deleted file mode 100644 index 38578a58fe..0000000000 --- a/InvenTree/company/migrations/0002_auto_20180422_1201.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.12 on 2018-04-22 12:01 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('company', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='company', - name='is_customer', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='company', - name='is_supplier', - field=models.BooleanField(default=False), - ), - ] diff --git a/InvenTree/part/migrations/0019_auto_20190508_2332.py b/InvenTree/company/migrations/0002_auto_20190519_0004.py similarity index 51% rename from InvenTree/part/migrations/0019_auto_20190508_2332.py rename to InvenTree/company/migrations/0002_auto_20190519_0004.py index b61bbee82b..c34c315d48 100644 --- a/InvenTree/part/migrations/0019_auto_20190508_2332.py +++ b/InvenTree/company/migrations/0002_auto_20190519_0004.py @@ -1,35 +1,40 @@ -# Generated by Django 2.2 on 2019-05-08 13:32 +# Generated by Django 2.2 on 2019-05-18 14:04 from django.db import migrations, models import django.db.models.deletion -import part.models class Migration(migrations.Migration): + initial = True + dependencies = [ - ('part', '0018_auto_20190505_2231'), + ('company', '0001_initial'), + ('part', '0001_initial'), ] operations = [ - migrations.AlterField( - model_name='part', - name='units', - field=models.CharField(blank=True, default='pcs', help_text='Stock keeping units for this part', max_length=20), - ), - migrations.AlterField( - model_name='partattachment', - name='attachment', - field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=part.models.attach_file), - ), - migrations.AlterField( + migrations.AddField( model_name='supplierpart', name='part', field=models.ForeignKey(help_text='Select part', limit_choices_to={'purchaseable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='supplier_parts', to='part.Part'), ), - migrations.AlterField( + migrations.AddField( model_name='supplierpart', name='supplier', field=models.ForeignKey(help_text='Select supplier', limit_choices_to={'is_supplier': True}, on_delete=django.db.models.deletion.CASCADE, related_name='parts', to='company.Company'), ), + migrations.AddField( + model_name='contact', + name='company', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contacts', to='company.Company'), + ), + migrations.AlterUniqueTogether( + name='supplierpricebreak', + unique_together={('part', 'quantity')}, + ), + migrations.AlterUniqueTogether( + name='supplierpart', + unique_together={('part', 'supplier', 'SKU')}, + ), ] diff --git a/InvenTree/company/migrations/0003_auto_20180423_1117.py b/InvenTree/company/migrations/0003_auto_20180423_1117.py deleted file mode 100644 index 638e3a1ab7..0000000000 --- a/InvenTree/company/migrations/0003_auto_20180423_1117.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.12 on 2018-04-23 11:17 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('company', '0002_auto_20180422_1201'), - ] - - operations = [ - migrations.AlterField( - model_name='company', - name='is_supplier', - field=models.BooleanField(default=True), - ), - migrations.AlterField( - model_name='company', - name='name', - field=models.CharField(help_text='Company name', max_length=100, unique=True), - ), - ] diff --git a/InvenTree/company/migrations/0004_company_url.py b/InvenTree/company/migrations/0004_company_url.py deleted file mode 100644 index e478757394..0000000000 --- a/InvenTree/company/migrations/0004_company_url.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.12 on 2018-04-24 08:01 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('company', '0003_auto_20180423_1117'), - ] - - operations = [ - migrations.AddField( - model_name='company', - name='URL', - field=models.URLField(blank=True, help_text='Link to external company information'), - ), - ] diff --git a/InvenTree/company/migrations/0005_contact.py b/InvenTree/company/migrations/0005_contact.py deleted file mode 100644 index 8a668a0590..0000000000 --- a/InvenTree/company/migrations/0005_contact.py +++ /dev/null @@ -1,27 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.12 on 2018-04-30 07:19 -from __future__ import unicode_literals - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('company', '0004_company_url'), - ] - - operations = [ - migrations.CreateModel( - name='Contact', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100)), - ('phone', models.CharField(blank=True, max_length=100)), - ('email', models.EmailField(blank=True, max_length=254)), - ('role', models.CharField(blank=True, max_length=100)), - ('company', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contacts', to='company.Company')), - ], - ), - ] diff --git a/InvenTree/company/migrations/0006_auto_20190508_2332.py b/InvenTree/company/migrations/0006_auto_20190508_2332.py deleted file mode 100644 index 232ccdd4f1..0000000000 --- a/InvenTree/company/migrations/0006_auto_20190508_2332.py +++ /dev/null @@ -1,43 +0,0 @@ -# Generated by Django 2.2 on 2019-05-08 13:32 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('company', '0005_contact'), - ] - - operations = [ - migrations.AlterField( - model_name='company', - name='contact', - field=models.CharField(blank=True, help_text='Point of contact', max_length=100), - ), - migrations.AlterField( - model_name='company', - name='description', - field=models.CharField(help_text='Description of the company', max_length=500), - ), - migrations.AlterField( - model_name='company', - name='email', - field=models.EmailField(blank=True, help_text='Contact email address', max_length=254), - ), - migrations.AlterField( - model_name='company', - name='is_customer', - field=models.BooleanField(default=False, help_text='Do you sell items to this company?'), - ), - migrations.AlterField( - model_name='company', - name='is_supplier', - field=models.BooleanField(default=True, help_text='Do you purchase items from this company?'), - ), - migrations.AlterField( - model_name='company', - name='phone', - field=models.CharField(blank=True, help_text='Contact phone number', max_length=50), - ), - ] diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index 63fcf4add8..90a0df23a2 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -7,6 +7,10 @@ from __future__ import unicode_literals import os +import math + +from django.core.validators import MinValueValidator + from django.apps import apps from django.db import models from django.urls import reverse @@ -150,3 +154,174 @@ class Contact(models.Model): company = models.ForeignKey(Company, related_name='contacts', on_delete=models.CASCADE) + + +class SupplierPart(models.Model): + """ Represents a unique part as provided by a Supplier + Each SupplierPart is identified by a MPN (Manufacturer Part Number) + Each SupplierPart is also linked to a Part object. + A Part may be available from multiple suppliers + + Attributes: + part: Link to the master Part + supplier: Company that supplies this SupplierPart object + SKU: Stock keeping unit (supplier part number) + manufacturer: Manufacturer name + MPN: Manufacture part number + URL: Link to external website for this part + description: Descriptive notes field + note: Longer form note field + base_cost: Base charge added to order independent of quantity e.g. "Reeling Fee" + multiple: Multiple that the part is provided in + minimum: MOQ (minimum order quantity) required for purchase + lead_time: Supplier lead time + packaging: packaging that the part is supplied in, e.g. "Reel" + """ + + def get_absolute_url(self): + return reverse('supplier-part-detail', kwargs={'pk': self.id}) + + class Meta: + unique_together = ('part', 'supplier', 'SKU') + + # This model was moved from the 'Part' app + db_table = 'part_supplierpart' + + part = models.ForeignKey('part.Part', on_delete=models.CASCADE, + related_name='supplier_parts', + limit_choices_to={'purchaseable': True}, + help_text='Select part', + ) + + supplier = models.ForeignKey(Company, on_delete=models.CASCADE, + related_name='parts', + limit_choices_to={'is_supplier': True}, + help_text='Select supplier', + ) + + SKU = models.CharField(max_length=100, help_text='Supplier stock keeping unit') + + manufacturer = models.CharField(max_length=100, blank=True, help_text='Manufacturer') + + MPN = models.CharField(max_length=100, blank=True, help_text='Manufacturer part number') + + URL = models.URLField(blank=True, help_text='URL for external supplier part link') + + description = models.CharField(max_length=250, blank=True, help_text='Supplier part description') + + note = models.CharField(max_length=100, blank=True, help_text='Notes') + + base_cost = models.DecimalField(max_digits=10, decimal_places=3, default=0, validators=[MinValueValidator(0)], help_text='Minimum charge (e.g. stocking fee)') + + packaging = models.CharField(max_length=50, blank=True, help_text='Part packaging') + + multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], help_text='Order multiple') + + minimum = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], help_text='Minimum order quantity (MOQ)') + + lead_time = models.DurationField(blank=True, null=True) + + @property + def manufacturer_string(self): + + items = [] + + if self.manufacturer: + items.append(self.manufacturer) + if self.MPN: + items.append(self.MPN) + + return ' | '.join(items) + + @property + def has_price_breaks(self): + return self.price_breaks.count() > 0 + + @property + def price_breaks(self): + """ Return the associated price breaks in the correct order """ + return self.pricebreaks.order_by('quantity').all() + + def get_price(self, quantity, moq=True, multiples=True): + """ Calculate the supplier price based on quantity price breaks. + + - Don't forget to add in flat-fee cost (base_cost field) + - If MOQ (minimum order quantity) is required, bump quantity + - If order multiples are to be observed, then we need to calculate based on that, too + """ + + price_breaks = self.price_breaks.all() + + # No price break information available? + if len(price_breaks) == 0: + return None + + # Minimum ordering requirement + if moq and self.minimum > quantity: + quantity = self.minimum + + # Order multiples + if multiples: + quantity = int(math.ceil(quantity / self.multiple) * self.multiple) + + pb_found = False + pb_quantity = -1 + pb_cost = 0.0 + + for pb in self.price_breaks.all(): + # Ignore this pricebreak (quantity is too high) + if pb.quantity > quantity: + continue + + pb_found = True + + # If this price-break quantity is the largest so far, use it! + if pb.quantity > pb_quantity: + pb_quantity = pb.quantity + pb_cost = pb.cost + + if pb_found: + cost = pb_cost * quantity + return cost + self.base_cost + else: + return None + + def __str__(self): + s = "{supplier} ({sku})".format( + sku=self.SKU, + supplier=self.supplier.name) + + if self.manufacturer_string: + s = s + ' - ' + self.manufacturer_string + + return s + + +class SupplierPriceBreak(models.Model): + """ Represents a quantity price break for a SupplierPart. + - Suppliers can offer discounts at larger quantities + - SupplierPart(s) may have zero-or-more associated SupplierPriceBreak(s) + + Attributes: + part: Link to a SupplierPart object that this price break applies to + quantity: Quantity required for price break + cost: Cost at specified quantity + """ + + part = models.ForeignKey(SupplierPart, on_delete=models.CASCADE, related_name='pricebreaks') + + quantity = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)]) + + cost = models.DecimalField(max_digits=10, decimal_places=5, validators=[MinValueValidator(0)]) + + class Meta: + unique_together = ("part", "quantity") + + # This model was moved from the 'Part' app + db_table = 'part_supplierpricebreak' + + def __str__(self): + return "{mpn} - {cost} @ {quan}".format( + mpn=self.part.MPN, + cost=self.cost, + quan=self.quantity) diff --git a/InvenTree/company/serializers.py b/InvenTree/company/serializers.py index 2967dbebd5..b09bccd234 100644 --- a/InvenTree/company/serializers.py +++ b/InvenTree/company/serializers.py @@ -5,6 +5,9 @@ JSON serializers for Company app from rest_framework import serializers from .models import Company +from .models import SupplierPart, SupplierPriceBreak + +from part.serializers import PartBriefSerializer class CompanyBriefSerializer(serializers.ModelSerializer): @@ -47,3 +50,43 @@ class CompanySerializer(serializers.ModelSerializer): 'is_supplier', 'part_count' ] + + +class SupplierPartSerializer(serializers.ModelSerializer): + """ Serializer for SupplierPart object """ + + url = serializers.CharField(source='get_absolute_url', read_only=True) + + part_detail = PartBriefSerializer(source='part', many=False, read_only=True) + + supplier_name = serializers.CharField(source='supplier.name', read_only=True) + supplier_logo = serializers.CharField(source='supplier.get_image_url', read_only=True) + + class Meta: + model = SupplierPart + fields = [ + 'pk', + 'url', + 'part', + 'part_detail', + 'supplier', + 'supplier_name', + 'supplier_logo', + 'SKU', + 'manufacturer', + 'MPN', + 'URL', + ] + + +class SupplierPriceBreakSerializer(serializers.ModelSerializer): + """ Serializer for SupplierPriceBreak object """ + + class Meta: + model = SupplierPriceBreak + fields = [ + 'pk', + 'part', + 'quantity', + 'cost' + ] diff --git a/InvenTree/company/templates/company/partdetail.html b/InvenTree/company/templates/company/partdetail.html index b2fdf8aeb9..5d0eee89a9 100644 --- a/InvenTree/company/templates/company/partdetail.html +++ b/InvenTree/company/templates/company/partdetail.html @@ -10,7 +10,7 @@ InvenTree | {{ company.name }} - Parts

Supplier Part

-

{{ part.SKU }} - {{ part.supplier.name }}

+

{{ part.supplier.name }} - {{ part.SKU }}

@@ -59,29 +59,46 @@ InvenTree | {{ company.name }} - Parts
- - - {% if part.multiple > 1 %} + + + - {% endif %} {% if part.base_cost > 0 %} {% endif %} {% if part.minimum > 1 %} {% endif %} - {% if part.price_breaks.all %} - + + + + + {% if part.price_breaks.all %} {% for pb in part.price_breaks.all %} - + {% endfor %} + {% else %} + + + {% endif %}
Pricing
Single Price{{ part.single_price }}
Pricing
Order Multiple{{ part.multiple }}
Base Price (Flat Fee){{ part.base_cost }}
Minimum Order Quantity{{ part.minimum }}
Price Breaks
Price Breaks +
+ +
+
Quantity Price
{{ pb.quantity }}{{ pb.cost }}{{ pb.cost }} +
+ + +
+
+ No price breaks have been added for this part +
@@ -89,9 +106,7 @@ InvenTree | {{ company.name }} - Parts
-
- -
+ {% include 'modals.html' %} @@ -116,4 +131,36 @@ InvenTree | {{ company.name }} - Parts } ); }); + + $('#new-price-break').click(function() { + launchModalForm("{% url 'price-break-create' %}", + { + reload: true, + data: { + part: {{ part.id }}, + } + } + ); + }); + + $('.pb-edit-button').click(function() { + var button = $(this); + + launchModalForm(button.attr('url'), + { + reload: true, + } + ); + }); + + $('.pb-delete-button').click(function() { + var button = $(this); + + launchModalForm(button.attr('url'), + { + reload: true, + } + ); + }); + {% endblock %} \ No newline at end of file diff --git a/InvenTree/company/urls.py b/InvenTree/company/urls.py index 0f8719fe01..7f617baafd 100644 --- a/InvenTree/company/urls.py +++ b/InvenTree/company/urls.py @@ -36,3 +36,23 @@ company_urls = [ # Redirect any other patterns url(r'^.*$', RedirectView.as_view(url='', permanent=False), name='company-index'), ] + +price_break_urls = [ + url('^new/', views.PriceBreakCreate.as_view(), name='price-break-create'), + + url(r'^(?P\d+)/edit/', views.PriceBreakEdit.as_view(), name='price-break-edit'), + url(r'^(?P\d+)/delete/', views.PriceBreakDelete.as_view(), name='price-break-delete'), +] + +supplier_part_detail_urls = [ + url(r'edit/?', views.SupplierPartEdit.as_view(), name='supplier-part-edit'), + url(r'delete/?', views.SupplierPartDelete.as_view(), name='supplier-part-delete'), + + url('^.*$', views.SupplierPartDetail.as_view(), name='supplier-part-detail'), +] + +supplier_part_urls = [ + url(r'^new/?', views.SupplierPartCreate.as_view(), name='supplier-part-create'), + + url(r'^(?P\d+)/', include(supplier_part_detail_urls)), +] diff --git a/InvenTree/company/views.py b/InvenTree/company/views.py index 0630556946..cce518676a 100644 --- a/InvenTree/company/views.py +++ b/InvenTree/company/views.py @@ -8,12 +8,20 @@ from __future__ import unicode_literals from django.views.generic import DetailView, ListView +from django.forms import HiddenInput + from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView from .models import Company +from .models import SupplierPart +from .models import SupplierPriceBreak + +from part.models import Part from .forms import EditCompanyForm from .forms import CompanyImageForm +from .forms import EditSupplierPartForm +from .forms import EditPriceBreakForm class CompanyIndex(ListView): @@ -104,3 +112,142 @@ class CompanyDelete(AjaxDeleteView): return { 'danger': 'Company was deleted', } + + +class SupplierPartDetail(DetailView): + """ Detail view for SupplierPart """ + model = SupplierPart + template_name = 'company/partdetail.html' + context_object_name = 'part' + queryset = SupplierPart.objects.all() + + +class SupplierPartEdit(AjaxUpdateView): + """ Update view for editing SupplierPart """ + + model = SupplierPart + context_object_name = 'part' + form_class = EditSupplierPartForm + ajax_template_name = 'modal_form.html' + ajax_form_title = 'Edit Supplier Part' + + +class SupplierPartCreate(AjaxCreateView): + """ Create view for making new SupplierPart """ + + model = SupplierPart + form_class = EditSupplierPartForm + ajax_template_name = 'modal_form.html' + ajax_form_title = 'Create new Supplier Part' + context_object_name = 'part' + + def get_form(self): + """ Create Form instance to create a new SupplierPart object. + Hide some fields if they are not appropriate in context + """ + form = super(AjaxCreateView, self).get_form() + + if form.initial.get('supplier', None): + # Hide the supplier field + form.fields['supplier'].widget = HiddenInput() + + if form.initial.get('part', None): + # Hide the part field + form.fields['part'].widget = HiddenInput() + + return form + + def get_initial(self): + """ Provide initial data for new SupplierPart: + + - If 'supplier_id' provided, pre-fill supplier field + - If 'part_id' provided, pre-fill part field + """ + initials = super(SupplierPartCreate, self).get_initial().copy() + + supplier_id = self.get_param('supplier') + part_id = self.get_param('part') + + if supplier_id: + try: + initials['supplier'] = Company.objects.get(pk=supplier_id) + except Company.DoesNotExist: + initials['supplier'] = None + + if part_id: + try: + initials['part'] = Part.objects.get(pk=part_id) + except Part.DoesNotExist: + initials['part'] = None + + return initials + + +class SupplierPartDelete(AjaxDeleteView): + """ Delete view for removing a SupplierPart """ + model = SupplierPart + success_url = '/supplier/' + ajax_template_name = 'company/partdelete.html' + ajax_form_title = 'Delete Supplier Part' + context_object_name = 'supplier_part' + + +class PriceBreakCreate(AjaxCreateView): + """ View for creating a supplier price break """ + + model = SupplierPriceBreak + form_class = EditPriceBreakForm + ajax_form_title = 'Add Price Break' + ajax_template_name = 'modal_form.html' + + def get_data(self): + return { + 'success': 'Added new price break' + } + + def get_part(self): + try: + return SupplierPart.objects.get(id=self.request.GET.get('part')) + except SupplierPart.DoesNotExist: + return SupplierPart.objects.get(id=self.request.POST.get('part')) + + def get_form(self): + + form = super(AjaxCreateView, self).get_form() + form.fields['part'].widget = HiddenInput() + + return form + + def get_initial(self): + + initials = super(AjaxCreateView, self).get_initial() + + print("GETTING INITIAL DAtA") + + initials['part'] = self.get_part() + + return initials + + +class PriceBreakEdit(AjaxUpdateView): + """ View for editing a supplier price break """ + + model = SupplierPriceBreak + form_class = EditPriceBreakForm + ajax_form_title = 'Edit Price Break' + ajax_template_name = 'modal_form.html' + + def get_form(self): + + form = super(AjaxUpdateView, self).get_form() + form.fields['part'].widget = HiddenInput() + + return form + + +class PriceBreakDelete(AjaxDeleteView): + """ View for deleting a supplier price break """ + + model = SupplierPriceBreak + ajax_form_title = "Delete Price Break" + ajax_template_name = 'modal_delete_form.html' diff --git a/InvenTree/part/__init__.py b/InvenTree/part/__init__.py index da73be7753..e5912481ff 100644 --- a/InvenTree/part/__init__.py +++ b/InvenTree/part/__init__.py @@ -5,6 +5,5 @@ It includes models for: - PartCategory - Part -- SupplierPart - BomItem """ diff --git a/InvenTree/part/admin.py b/InvenTree/part/admin.py index 18e20cb9ee..136d486b0b 100644 --- a/InvenTree/part/admin.py +++ b/InvenTree/part/admin.py @@ -3,7 +3,6 @@ from import_export.admin import ImportExportModelAdmin from .models import PartCategory, Part from .models import PartAttachment, PartStar -from .models import SupplierPart from .models import BomItem @@ -31,10 +30,6 @@ class BomItemAdmin(ImportExportModelAdmin): list_display = ('part', 'sub_part', 'quantity') -class SupplierPartAdmin(ImportExportModelAdmin): - list_display = ('part', 'supplier', 'SKU') - - """ class ParameterTemplateAdmin(admin.ModelAdmin): list_display = ('name', 'units', 'format') @@ -49,4 +44,3 @@ admin.site.register(PartCategory, PartCategoryAdmin) admin.site.register(PartAttachment, PartAttachmentAdmin) admin.site.register(PartStar, PartStarAdmin) admin.site.register(BomItem, BomItemAdmin) -admin.site.register(SupplierPart, SupplierPartAdmin) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 0973138b21..aa065e9720 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -16,10 +16,8 @@ from django.conf.urls import url, include from django.urls import reverse from .models import Part, PartCategory, BomItem, PartStar -from .models import SupplierPart, SupplierPriceBreak from .serializers import PartSerializer, BomItemSerializer -from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer from .serializers import CategorySerializer from .serializers import PartStarSerializer @@ -233,71 +231,6 @@ class BomDetail(generics.RetrieveUpdateDestroyAPIView): ] -class SupplierPartList(generics.ListCreateAPIView): - """ API endpoint for list view of SupplierPart object - - - GET: Return list of SupplierPart objects - - POST: Create a new SupplierPart object - """ - - queryset = SupplierPart.objects.all() - serializer_class = SupplierPartSerializer - - permission_classes = [ - permissions.IsAuthenticatedOrReadOnly, - ] - - filter_backends = [ - DjangoFilterBackend, - filters.SearchFilter, - filters.OrderingFilter, - ] - - filter_fields = [ - 'part', - 'supplier' - ] - - -class SupplierPartDetail(generics.RetrieveUpdateDestroyAPIView): - """ API endpoint for detail view of SupplierPart object - - - GET: Retrieve detail view - - PATCH: Update object - - DELETE: Delete objec - """ - - queryset = SupplierPart.objects.all() - serializer_class = SupplierPartSerializer - permission_classes = (permissions.IsAuthenticatedOrReadOnly,) - - read_only_fields = [ - ] - - -class SupplierPriceBreakList(generics.ListCreateAPIView): - """ API endpoint for list view of SupplierPriceBreak object - - - GET: Retrieve list of SupplierPriceBreak objects - - POST: Create a new SupplierPriceBreak object - """ - - queryset = SupplierPriceBreak.objects.all() - serializer_class = SupplierPriceBreakSerializer - - permission_classes = [ - permissions.IsAuthenticatedOrReadOnly, - ] - - filter_backends = [ - DjangoFilterBackend, - ] - - filter_fields = [ - 'part', - ] - - cat_api_urls = [ url(r'^(?P\d+)/?', CategoryDetail.as_view(), name='api-part-category-detail'), @@ -305,13 +238,6 @@ cat_api_urls = [ url(r'^$', CategoryList.as_view(), name='api-part-category-list'), ] -supplier_part_api_urls = [ - - url(r'^(?P\d+)/?', SupplierPartDetail.as_view(), name='api-supplier-part-detail'), - - # Catch anything else - url(r'^.*$', SupplierPartList.as_view(), name='api-part-supplier-list'), -] part_star_api_urls = [ url(r'^(?P\d+)/?', PartStarDetail.as_view(), name='api-part-star-detail'), @@ -320,21 +246,19 @@ part_star_api_urls = [ url(r'^.*$', PartStarList.as_view(), name='api-part-star-list'), ] + part_api_urls = [ url(r'^tree/?', PartCategoryTree.as_view(), name='api-part-tree'), url(r'^category/', include(cat_api_urls)), - url(r'^supplier/', include(supplier_part_api_urls)), - url(r'^star/', include(part_star_api_urls)), - url(r'^price-break/?', SupplierPriceBreakList.as_view(), name='api-part-supplier-price'), - url(r'^(?P\d+)/', PartDetail.as_view(), name='api-part-detail'), url(r'^.*$', PartList.as_view(), name='api-part-list'), ] + bom_api_urls = [ # BOM Item Detail url('^(?P\d+)/', BomDetail.as_view(), name='api-bom-detail'), diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py index d4e70ee47a..e069df6ed6 100644 --- a/InvenTree/part/forms.py +++ b/InvenTree/part/forms.py @@ -11,7 +11,6 @@ from django import forms from .models import Part, PartCategory, PartAttachment from .models import BomItem -from .models import SupplierPart class PartImageForm(HelperForm): @@ -141,24 +140,17 @@ class EditBomItemForm(HelperForm): widgets = {'part': forms.HiddenInput()} -class EditSupplierPartForm(HelperForm): - """ Form for editing a SupplierPart object """ +class PartPriceForm(forms.Form): + """ Simple form for viewing part pricing information """ + + quantity = forms.IntegerField( + required=True, + initial=1, + help_text='Input quantity for price calculation' + ) class Meta: - model = SupplierPart + model = Part fields = [ - 'part', - 'supplier', - 'SKU', - 'description', - 'manufacturer', - 'MPN', - 'URL', - 'note', - 'single_price', - 'base_cost', - 'multiple', - 'minimum', - 'packaging', - 'lead_time' + 'quantity' ] diff --git a/InvenTree/part/migrations/0001_initial.py b/InvenTree/part/migrations/0001_initial.py index 361436047e..c0413a909a 100644 --- a/InvenTree/part/migrations/0001_initial.py +++ b/InvenTree/part/migrations/0001_initial.py @@ -1,7 +1,7 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.12 on 2018-04-22 11:53 -from __future__ import unicode_literals +# Generated by Django 2.2 on 2019-05-18 14:04 +import InvenTree.validators +from django.conf import settings import django.core.validators from django.db import migrations, models import django.db.models.deletion @@ -13,7 +13,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('company', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ @@ -21,7 +21,9 @@ class Migration(migrations.Migration): name='BomItem', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('quantity', models.PositiveIntegerField(default=1, validators=[django.core.validators.MinValueValidator(0)])), + ('quantity', models.PositiveIntegerField(default=1, help_text='BOM quantity for this BOM item', validators=[django.core.validators.MinValueValidator(0)])), + ('overage', models.CharField(blank=True, help_text='Estimated build wastage quantity (absolute or percentage)', max_length=24, validators=[InvenTree.validators.validate_overage])), + ('note', models.CharField(blank=True, help_text='BOM item notes', max_length=100)), ], options={ 'verbose_name': 'BOM Item', @@ -31,18 +33,24 @@ class Migration(migrations.Migration): name='Part', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(help_text='Part name (must be unique)', max_length=100, unique=True)), + ('name', models.CharField(help_text='Part name', max_length=100, validators=[InvenTree.validators.validate_part_name])), + ('variant', models.CharField(blank=True, help_text='Part variant or revision code', max_length=32)), ('description', models.CharField(help_text='Part description', max_length=250)), + ('keywords', models.CharField(blank=True, help_text='Part keywords to improve visibility in search results', max_length=250)), ('IPN', models.CharField(blank=True, help_text='Internal Part Number', max_length=100)), ('URL', models.URLField(blank=True, help_text='Link to extenal URL')), ('image', models.ImageField(blank=True, max_length=255, null=True, upload_to=part.models.rename_part_image)), ('minimum_stock', models.PositiveIntegerField(default=0, help_text='Minimum allowed stock level', validators=[django.core.validators.MinValueValidator(0)])), - ('units', models.CharField(blank=True, default='pcs', max_length=20)), + ('units', models.CharField(blank=True, default='pcs', help_text='Stock keeping units for this part', max_length=20)), ('buildable', models.BooleanField(default=False, help_text='Can this part be built from other parts?')), + ('consumable', models.BooleanField(default=True, help_text='Can this part be used to build other parts?')), ('trackable', models.BooleanField(default=False, help_text='Does this part have tracking for unique items?')), ('purchaseable', models.BooleanField(default=True, help_text='Can this part be purchased from external suppliers?')), ('salable', models.BooleanField(default=False, help_text='Can this part be sold to customers?')), + ('active', models.BooleanField(default=True, help_text='Is this part active?')), ('notes', models.TextField(blank=True)), + ('bom_checksum', models.CharField(blank=True, help_text='Stored BOM checksum', max_length=128)), + ('bom_checked_date', models.DateField(blank=True, null=True)), ], options={ 'verbose_name': 'Part', @@ -53,8 +61,8 @@ class Migration(migrations.Migration): name='PartAttachment', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('attachment', models.FileField(blank=True, null=True, upload_to=part.models.attach_file)), - ('part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='part.Part')), + ('attachment', models.FileField(help_text='Select file to attach', upload_to=part.models.attach_file)), + ('comment', models.CharField(help_text='File comment', max_length=100)), ], ), migrations.CreateModel( @@ -63,7 +71,7 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=100, unique=True)), ('description', models.CharField(max_length=250)), - ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='children', to='part.PartCategory')), + ('default_keywords', models.CharField(blank=True, help_text='Default keywords for parts in this category', max_length=250)), ], options={ 'verbose_name': 'Part Category', @@ -71,63 +79,11 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( - name='SupplierPart', + name='PartStar', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('SKU', models.CharField(help_text='Supplier stock keeping unit', max_length=100)), - ('manufacturer', models.CharField(blank=True, help_text='Manufacturer', max_length=100)), - ('MPN', models.CharField(blank=True, help_text='Manufacturer part number', max_length=100)), - ('URL', models.URLField(blank=True)), - ('description', models.CharField(blank=True, max_length=250)), - ('single_price', models.DecimalField(decimal_places=3, default=0, max_digits=10)), - ('base_cost', models.DecimalField(decimal_places=3, default=0, max_digits=10)), - ('packaging', models.CharField(blank=True, max_length=50)), - ('multiple', models.PositiveIntegerField(default=1, validators=[django.core.validators.MinValueValidator(0)])), - ('minimum', models.PositiveIntegerField(default=1, validators=[django.core.validators.MinValueValidator(0)])), - ('lead_time', models.DurationField(blank=True, null=True)), - ('part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='supplier_parts', to='part.Part')), - ('supplier', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='parts', to='company.Company')), + ('part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='starred_users', to='part.Part')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='starred_parts', to=settings.AUTH_USER_MODEL)), ], ), - migrations.CreateModel( - name='SupplierPriceBreak', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('quantity', models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(0)])), - ('cost', models.DecimalField(decimal_places=3, max_digits=10)), - ('part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='price_breaks', to='part.SupplierPart')), - ], - ), - migrations.AddField( - model_name='part', - name='category', - field=models.ForeignKey(blank=True, help_text='Part category', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='parts', to='part.PartCategory'), - ), - 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='part.SupplierPart'), - ), - migrations.AddField( - model_name='bomitem', - name='part', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bom_items', to='part.Part'), - ), - migrations.AddField( - model_name='bomitem', - name='sub_part', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='used_in', to='part.Part'), - ), - migrations.AlterUniqueTogether( - name='supplierpricebreak', - unique_together=set([('part', 'quantity')]), - ), - migrations.AlterUniqueTogether( - name='supplierpart', - unique_together=set([('part', 'supplier', 'SKU')]), - ), - migrations.AlterUniqueTogether( - name='bomitem', - unique_together=set([('part', 'sub_part')]), - ), ] diff --git a/InvenTree/part/migrations/0002_auto_20190519_0004.py b/InvenTree/part/migrations/0002_auto_20190519_0004.py new file mode 100644 index 0000000000..02df1a7fad --- /dev/null +++ b/InvenTree/part/migrations/0002_auto_20190519_0004.py @@ -0,0 +1,77 @@ +# Generated by Django 2.2 on 2019-05-18 14:04 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('company', '0002_auto_20190519_0004'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('stock', '0001_initial'), + ('part', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='partcategory', + name='default_location', + field=models.ForeignKey(blank=True, help_text='Default location for parts in this category', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_categories', to='stock.StockLocation'), + ), + migrations.AddField( + model_name='partcategory', + name='parent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='children', to='part.PartCategory'), + ), + migrations.AddField( + model_name='partattachment', + name='part', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='part.Part'), + ), + 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='category', + field=models.ForeignKey(blank=True, help_text='Part category', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='parts', to='part.PartCategory'), + ), + 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, related_name='default_parts', to='stock.StockLocation'), + ), + 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='company.SupplierPart'), + ), + migrations.AddField( + 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.AddField( + 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.AlterUniqueTogether( + name='partstar', + unique_together={('part', 'user')}, + ), + migrations.AlterUniqueTogether( + name='part', + unique_together={('name', 'variant')}, + ), + migrations.AlterUniqueTogether( + name='bomitem', + unique_together={('part', 'sub_part')}, + ), + ] diff --git a/InvenTree/part/migrations/0002_part_default_location.py b/InvenTree/part/migrations/0002_part_default_location.py deleted file mode 100644 index c5a65d284d..0000000000 --- a/InvenTree/part/migrations/0002_part_default_location.py +++ /dev/null @@ -1,22 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.12 on 2018-04-22 11:53 -from __future__ import unicode_literals - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('stock', '0001_initial'), - ('part', '0001_initial'), - ] - - 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, related_name='default_parts', to='stock.StockLocation'), - ), - ] diff --git a/InvenTree/part/migrations/0003_auto_20190412_2030.py b/InvenTree/part/migrations/0003_auto_20190412_2030.py deleted file mode 100644 index 3439c09483..0000000000 --- a/InvenTree/part/migrations/0003_auto_20190412_2030.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.2 on 2019-04-12 10:30 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('part', '0002_part_default_location'), - ] - - operations = [ - migrations.AlterField( - model_name='bomitem', - name='part', - field=models.ForeignKey(limit_choices_to={'buildable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='bom_items', to='part.Part'), - ), - ] diff --git a/InvenTree/part/migrations/0004_bomitem_note.py b/InvenTree/part/migrations/0004_bomitem_note.py deleted file mode 100644 index 69bea83175..0000000000 --- a/InvenTree/part/migrations/0004_bomitem_note.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.2 on 2019-04-14 08:25 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('part', '0003_auto_20190412_2030'), - ] - - operations = [ - migrations.AddField( - model_name='bomitem', - name='note', - field=models.CharField(blank=True, help_text='Item notes', max_length=100), - ), - ] diff --git a/InvenTree/part/migrations/0005_part_consumable.py b/InvenTree/part/migrations/0005_part_consumable.py deleted file mode 100644 index 4bb1056ae7..0000000000 --- a/InvenTree/part/migrations/0005_part_consumable.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.2 on 2019-04-15 13:48 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('part', '0004_bomitem_note'), - ] - - operations = [ - migrations.AddField( - model_name='part', - name='consumable', - field=models.BooleanField(default=False, help_text='Can this part be used to build other parts?'), - ), - ] diff --git a/InvenTree/part/migrations/0006_auto_20190416_2354.py b/InvenTree/part/migrations/0006_auto_20190416_2354.py deleted file mode 100644 index f2b2f42398..0000000000 --- a/InvenTree/part/migrations/0006_auto_20190416_2354.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 2.2 on 2019-04-16 13:54 - -import django.core.validators -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('part', '0005_part_consumable'), - ] - - operations = [ - migrations.AlterField( - model_name='bomitem', - name='sub_part', - field=models.ForeignKey(limit_choices_to={'consumable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='used_in', to='part.Part'), - ), - migrations.AlterField( - model_name='part', - name='consumable', - field=models.BooleanField(default=True, help_text='Can this part be used to build other parts?'), - ), - migrations.AlterField( - model_name='supplierpricebreak', - name='quantity', - field=models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(2)]), - ), - ] diff --git a/InvenTree/part/migrations/0007_auto_20190417_0007.py b/InvenTree/part/migrations/0007_auto_20190417_0007.py deleted file mode 100644 index 554a471132..0000000000 --- a/InvenTree/part/migrations/0007_auto_20190417_0007.py +++ /dev/null @@ -1,54 +0,0 @@ -# Generated by Django 2.2 on 2019-04-16 14:07 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('part', '0006_auto_20190416_2354'), - ] - - operations = [ - migrations.AddField( - model_name='supplierpart', - name='note', - field=models.CharField(blank=True, help_text='Notes', max_length=100), - ), - migrations.AlterField( - model_name='supplierpart', - name='base_cost', - field=models.DecimalField(decimal_places=3, default=0, help_text='Minimum charge (e.g. stocking fee)', max_digits=10, validators=[django.core.validators.MinValueValidator(0)]), - ), - migrations.AlterField( - model_name='supplierpart', - name='description', - field=models.CharField(blank=True, help_text='Supplier part description', max_length=250), - ), - migrations.AlterField( - model_name='supplierpart', - name='minimum', - field=models.PositiveIntegerField(default=1, help_text='Minimum order quantity (MOQ)', validators=[django.core.validators.MinValueValidator(0)]), - ), - migrations.AlterField( - model_name='supplierpart', - name='multiple', - field=models.PositiveIntegerField(default=1, help_text='Order multiple', validators=[django.core.validators.MinValueValidator(0)]), - ), - migrations.AlterField( - model_name='supplierpart', - name='packaging', - field=models.CharField(blank=True, help_text='Part packaging', max_length=50), - ), - migrations.AlterField( - model_name='supplierpart', - name='single_price', - field=models.DecimalField(decimal_places=3, default=0, help_text='Price for single quantity', max_digits=10, validators=[django.core.validators.MinValueValidator(0)]), - ), - migrations.AlterField( - model_name='supplierpricebreak', - name='cost', - field=models.DecimalField(decimal_places=3, max_digits=10, validators=[django.core.validators.MinValueValidator(0)]), - ), - ] diff --git a/InvenTree/part/migrations/0008_auto_20190417_0013.py b/InvenTree/part/migrations/0008_auto_20190417_0013.py deleted file mode 100644 index e7311e2be8..0000000000 --- a/InvenTree/part/migrations/0008_auto_20190417_0013.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.2 on 2019-04-16 14:13 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('part', '0007_auto_20190417_0007'), - ] - - operations = [ - migrations.AlterField( - model_name='supplierpart', - name='part', - field=models.ForeignKey(limit_choices_to={'purchasable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='supplier_parts', to='part.Part'), - ), - ] diff --git a/InvenTree/part/migrations/0009_auto_20190417_0019.py b/InvenTree/part/migrations/0009_auto_20190417_0019.py deleted file mode 100644 index 2a6eba4960..0000000000 --- a/InvenTree/part/migrations/0009_auto_20190417_0019.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.2 on 2019-04-16 14:19 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('part', '0008_auto_20190417_0013'), - ] - - operations = [ - migrations.AlterField( - model_name='supplierpart', - name='part', - field=models.ForeignKey(limit_choices_to={'purchaseable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='supplier_parts', to='part.Part'), - ), - ] diff --git a/InvenTree/part/migrations/0010_auto_20190417_0045.py b/InvenTree/part/migrations/0010_auto_20190417_0045.py deleted file mode 100644 index 1040afc67c..0000000000 --- a/InvenTree/part/migrations/0010_auto_20190417_0045.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 2.2 on 2019-04-16 14:45 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('part', '0009_auto_20190417_0019'), - ] - - operations = [ - migrations.AlterField( - model_name='supplierpart', - name='minimum', - field=models.PositiveIntegerField(default=1, help_text='Minimum order quantity (MOQ)', validators=[django.core.validators.MinValueValidator(1)]), - ), - migrations.AlterField( - model_name='supplierpart', - name='multiple', - field=models.PositiveIntegerField(default=1, help_text='Order multiple', validators=[django.core.validators.MinValueValidator(1)]), - ), - ] diff --git a/InvenTree/part/migrations/0011_auto_20190428_0841.py b/InvenTree/part/migrations/0011_auto_20190428_0841.py deleted file mode 100644 index 0146b6795a..0000000000 --- a/InvenTree/part/migrations/0011_auto_20190428_0841.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.2 on 2019-04-27 22:41 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('part', '0010_auto_20190417_0045'), - ] - - operations = [ - migrations.AlterField( - model_name='supplierpart', - name='supplier', - field=models.ForeignKey(limit_choices_to={'is_supplier': True}, on_delete=django.db.models.deletion.CASCADE, related_name='parts', to='company.Company'), - ), - ] diff --git a/InvenTree/part/migrations/0012_part_active.py b/InvenTree/part/migrations/0012_part_active.py deleted file mode 100644 index 87929e2f85..0000000000 --- a/InvenTree/part/migrations/0012_part_active.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 2.2 on 2019-04-28 13:00 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('part', '0011_auto_20190428_0841'), - ] - - operations = [ - migrations.AddField( - model_name='part', - name='active', - field=models.BooleanField(default=True, help_text='Is this part active?'), - ), - migrations.AddField( - model_name='partattachment', - name='comment', - field=models.CharField(blank=True, help_text='File comment', max_length=100), - ), - ] diff --git a/InvenTree/part/migrations/0013_auto_20190429_2229.py b/InvenTree/part/migrations/0013_auto_20190429_2229.py deleted file mode 100644 index 9c339b18b1..0000000000 --- a/InvenTree/part/migrations/0013_auto_20190429_2229.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.2 on 2019-04-29 12:29 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('part', '0012_part_active'), - ] - - operations = [ - migrations.AlterField( - model_name='part', - name='URL', - field=models.URLField(blank=True, help_text='Link to external URL'), - ), - ] diff --git a/InvenTree/part/migrations/0014_auto_20190502_2039.py b/InvenTree/part/migrations/0014_auto_20190502_2039.py deleted file mode 100644 index c32c3afe32..0000000000 --- a/InvenTree/part/migrations/0014_auto_20190502_2039.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.2 on 2019-05-02 10:39 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('part', '0013_auto_20190429_2229'), - ] - - operations = [ - migrations.AlterField( - model_name='part', - name='URL', - field=models.URLField(blank=True, help_text='Link to extenal URL'), - ), - ] diff --git a/InvenTree/part/migrations/0015_partcategory_default_location.py b/InvenTree/part/migrations/0015_partcategory_default_location.py deleted file mode 100644 index 33a47cc440..0000000000 --- a/InvenTree/part/migrations/0015_partcategory_default_location.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 2.2 on 2019-05-04 08:57 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('stock', '0013_remove_stockitem_uuid'), - ('part', '0014_auto_20190502_2039'), - ] - - operations = [ - migrations.AddField( - model_name='partcategory', - name='default_location', - field=models.ForeignKey(blank=True, help_text='Default location for parts in this category', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_categories', to='stock.StockLocation'), - ), - ] diff --git a/InvenTree/part/migrations/0016_partstar.py b/InvenTree/part/migrations/0016_partstar.py deleted file mode 100644 index baa5c83d5b..0000000000 --- a/InvenTree/part/migrations/0016_partstar.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 2.2 on 2019-05-04 22:45 - -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', '0015_partcategory_default_location'), - ] - - operations = [ - migrations.CreateModel( - name='PartStar', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='starred_users', to='part.Part')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='starred_parts', to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/InvenTree/part/migrations/0017_auto_20190505_0848.py b/InvenTree/part/migrations/0017_auto_20190505_0848.py deleted file mode 100644 index 90162132f9..0000000000 --- a/InvenTree/part/migrations/0017_auto_20190505_0848.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.2 on 2019-05-04 22:48 - -from django.conf import settings -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('part', '0016_partstar'), - ] - - operations = [ - migrations.AlterUniqueTogether( - name='partstar', - unique_together={('part', 'user')}, - ), - ] diff --git a/InvenTree/part/migrations/0018_auto_20190505_2231.py b/InvenTree/part/migrations/0018_auto_20190505_2231.py deleted file mode 100644 index 32aa1e3a1b..0000000000 --- a/InvenTree/part/migrations/0018_auto_20190505_2231.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 2.2 on 2019-05-05 12:31 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('part', '0017_auto_20190505_0848'), - ] - - operations = [ - migrations.AlterField( - model_name='bomitem', - name='part', - field=models.ForeignKey(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='sub_part', - field=models.ForeignKey(limit_choices_to={'active': True, 'consumable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='used_in', to='part.Part'), - ), - ] diff --git a/InvenTree/part/migrations/0020_auto_20190510_2022.py b/InvenTree/part/migrations/0020_auto_20190510_2022.py deleted file mode 100644 index 8bf6db8f18..0000000000 --- a/InvenTree/part/migrations/0020_auto_20190510_2022.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 2.2 on 2019-05-10 10:22 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('part', '0019_auto_20190508_2332'), - ] - - operations = [ - migrations.AddField( - model_name='part', - name='variant', - field=models.CharField(blank=True, help_text='Part variant or revision code', max_length=32), - ), - migrations.AlterField( - model_name='part', - name='name', - field=models.CharField(help_text='Part name', max_length=100), - ), - migrations.AlterUniqueTogether( - name='part', - unique_together={('name', 'variant')}, - ), - ] diff --git a/InvenTree/part/migrations/0021_auto_20190510_2220.py b/InvenTree/part/migrations/0021_auto_20190510_2220.py deleted file mode 100644 index 294bd112ae..0000000000 --- a/InvenTree/part/migrations/0021_auto_20190510_2220.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.2 on 2019-05-10 12:20 - -import InvenTree.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('part', '0020_auto_20190510_2022'), - ] - - operations = [ - migrations.AlterField( - model_name='part', - name='name', - field=models.CharField(help_text='Part name', max_length=100, validators=[InvenTree.validators.validate_part_name]), - ), - ] diff --git a/InvenTree/part/migrations/0022_auto_20190512_1246.py b/InvenTree/part/migrations/0022_auto_20190512_1246.py deleted file mode 100644 index 40f4d0dd4c..0000000000 --- a/InvenTree/part/migrations/0022_auto_20190512_1246.py +++ /dev/null @@ -1,31 +0,0 @@ -# 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), - ), - ] diff --git a/InvenTree/part/migrations/0023_part_keywords.py b/InvenTree/part/migrations/0023_part_keywords.py deleted file mode 100644 index 4752d80740..0000000000 --- a/InvenTree/part/migrations/0023_part_keywords.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.2 on 2019-05-14 07:15 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('part', '0022_auto_20190512_1246'), - ] - - operations = [ - migrations.AddField( - model_name='part', - name='keywords', - field=models.CharField(blank=True, help_text='Part keywords to improve visibility in search results', max_length=250), - ), - ] diff --git a/InvenTree/part/migrations/0024_partcategory_default_keywords.py b/InvenTree/part/migrations/0024_partcategory_default_keywords.py deleted file mode 100644 index 317d982f7d..0000000000 --- a/InvenTree/part/migrations/0024_partcategory_default_keywords.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.2 on 2019-05-14 07:27 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('part', '0023_part_keywords'), - ] - - operations = [ - migrations.AddField( - model_name='partcategory', - name='default_keywords', - field=models.CharField(blank=True, help_text='Default keywords for parts in this category', max_length=250), - ), - ] diff --git a/InvenTree/part/migrations/0025_auto_20190515_0012.py b/InvenTree/part/migrations/0025_auto_20190515_0012.py deleted file mode 100644 index aaeb8ea1a3..0000000000 --- a/InvenTree/part/migrations/0025_auto_20190515_0012.py +++ /dev/null @@ -1,46 +0,0 @@ -# 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'), - ), - ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index f4673dc309..d8b7529b23 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -7,8 +7,6 @@ from __future__ import unicode_literals import os -import math - import tablib from django.utils.translation import gettext_lazy as _ @@ -32,7 +30,8 @@ import hashlib from InvenTree import helpers from InvenTree import validators from InvenTree.models import InvenTreeTree -from company.models import Company + +from company.models import SupplierPart class PartCategory(InvenTreeTree): @@ -317,7 +316,7 @@ class Part(models.Model): # Default to None if there are multiple suppliers to choose from return None - default_supplier = models.ForeignKey('part.SupplierPart', + default_supplier = models.ForeignKey(SupplierPart, on_delete=models.SET_NULL, blank=True, null=True, help_text='Default supplier part', @@ -562,6 +561,203 @@ class Part(models.Model): """ Return the number of supplier parts available for this part """ return self.supplier_parts.count() + @property + def min_single_price(self): + return self.get_min_supplier_price(1) + + @property + def max_single_price(self): + return self.get_max_supplier_price(1) + + @property + def min_bom_price(self): + return self.get_min_bom_price(1) + + @property + def max_bom_price(self): + return self.get_max_bom_price(1) + + @property + def has_pricing_info(self): + """ Return true if there is pricing information for this part """ + return self.get_min_price() is not None + + @property + def has_complete_bom_pricing(self): + """ Return true if there is pricing information for each item in the BOM. """ + + for item in self.bom_items.all(): + if not item.sub_part.has_pricing_info: + return False + + return True + + @property + def single_price_info(self): + """ Return a simplified pricing string for this part at single quantity """ + + return self.get_price_info() + + def get_price_info(self, quantity=1, buy=True, bom=True): + """ Return a simplified pricing string for this part + + Args: + quantity: Number of units to calculate price for + buy: Include supplier pricing (default = True) + bom: Include BOM pricing (default = True) + """ + + min_price = self.get_min_price(quantity, buy, bom) + max_price = self.get_max_price(quantity, buy, bom) + + if min_price is None: + return None + + if min_price == max_price: + return min_price + + return "{a} to {b}".format(a=min_price, b=max_price) + + def get_min_supplier_price(self, quantity=1): + """ Return the minimum price of this part from all available suppliers. + + Args: + quantity: Number of units we wish to purchase (default = 1) + + Returns: + Numerical price if pricing is available, else None + """ + + min_price = None + + for supplier_part in self.supplier_parts.all(): + supplier_price = supplier_part.get_price(quantity) + + if supplier_price is None: + continue + + if min_price is None or supplier_price < min_price: + min_price = supplier_price + + if min_price is None: + return None + else: + return min_price + + def get_max_supplier_price(self, quantity=1): + """ Return the maximum price of this part from all available suppliers. + + Args: + quantity: Number of units we wish to purchase (default = 1) + + Returns: + Numerical price if pricing is available, else None + """ + + max_price = None + + for supplier_part in self.supplier_parts.all(): + supplier_price = supplier_part.get_price(quantity) + + if supplier_price is None: + continue + + if max_price is None or supplier_price > max_price: + max_price = supplier_price + + if max_price is None: + return None + else: + return max_price + + def get_min_bom_price(self, quantity=1): + """ Return the minimum price of the BOM for this part. + Adds the minimum price for all components in the BOM. + + Note: If the BOM contains items without pricing information, + these items cannot be included in the BOM! + """ + + min_price = None + + for item in self.bom_items.all(): + price = item.sub_part.get_min_price(quantity * item.quantity) + + if price is None: + continue + + if min_price is None: + min_price = 0 + + min_price += price + + return min_price + + def get_max_bom_price(self, quantity=1): + """ Return the maximum price of the BOM for this part. + Adds the maximum price for all components in the BOM. + + Note: If the BOM contains items without pricing information, + these items cannot be included in the BOM! + """ + + max_price = None + + for item in self.bom_items.all(): + price = item.sub_part.get_max_price(quantity * item.quantity) + + if price is None: + continue + + if max_price is None: + max_price = 0 + + max_price += price + + return max_price + + def get_min_price(self, quantity=1, buy=True, bom=True): + """ Return the minimum price for this part. This price can be either: + + - Supplier price (if purchased from suppliers) + - BOM price (if built from other parts) + + Returns: + Minimum of the supplier price or BOM price. If no pricing available, returns None + """ + + buy_price = self.get_min_supplier_price(quantity) if buy else None + bom_price = self.get_min_bom_price(quantity) if bom else None + + if buy_price is None: + return bom_price + + if bom_price is None: + return buy_price + + return min(buy_price, bom_price) + + def get_max_price(self, quantity=1, buy=True, bom=True): + """ Return the maximum price for this part. This price can be either: + + - Supplier price (if purchsed from suppliers) + - BOM price (if built from other parts) + + Returns: + Maximum of the supplier price or BOM price. If no pricing available, returns None + """ + + buy_price = self.get_max_supplier_price(quantity) if buy else None + bom_price = self.get_max_bom_price(quantity) if bom else None + + if buy_price is None: + return bom_price + + if bom_price is None: + return buy_price + + return max(buy_price, bom_price) + def deepCopy(self, other, **kwargs): """ Duplicates non-field data from another part. Does not alter the normal fields of this part, @@ -641,10 +837,10 @@ class PartAttachment(models.Model): part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='attachments') - attachment = models.FileField(upload_to=attach_file, null=True, blank=True, + attachment = models.FileField(upload_to=attach_file, help_text='Select file to attach') - comment = models.CharField(max_length=100, blank=True, help_text='File comment') + comment = models.CharField(max_length=100, help_text='File comment') @property def basename(self): @@ -800,164 +996,7 @@ class BomItem(models.Model): return base_quantity + self.get_overage_quantity(base_quantity) - -class SupplierPart(models.Model): - """ Represents a unique part as provided by a Supplier - Each SupplierPart is identified by a MPN (Manufacturer Part Number) - Each SupplierPart is also linked to a Part object. - A Part may be available from multiple suppliers - - Attributes: - part: Link to the master Part - supplier: Company that supplies this SupplierPart object - SKU: Stock keeping unit (supplier part number) - manufacturer: Manufacturer name - MPN: Manufacture part number - URL: Link to external website for this part - description: Descriptive notes field - note: Longer form note field - single_price: Default price for a single unit - base_cost: Base charge added to order independent of quantity e.g. "Reeling Fee" - multiple: Multiple that the part is provided in - minimum: MOQ (minimum order quantity) required for purchase - lead_time: Supplier lead time - packaging: packaging that the part is supplied in, e.g. "Reel" - """ - - def get_absolute_url(self): - return reverse('supplier-part-detail', kwargs={'pk': self.id}) - - class Meta: - unique_together = ('part', 'supplier', 'SKU') - - part = models.ForeignKey(Part, on_delete=models.CASCADE, - related_name='supplier_parts', - limit_choices_to={'purchaseable': True}, - help_text='Select part', - ) - - supplier = models.ForeignKey(Company, on_delete=models.CASCADE, - related_name='parts', - limit_choices_to={'is_supplier': True}, - help_text='Select supplier', - ) - - SKU = models.CharField(max_length=100, help_text='Supplier stock keeping unit') - - manufacturer = models.CharField(max_length=100, blank=True, help_text='Manufacturer') - - MPN = models.CharField(max_length=100, blank=True, help_text='Manufacturer part number') - - URL = models.URLField(blank=True, help_text='URL for external supplier part link') - - description = models.CharField(max_length=250, blank=True, help_text='Supplier part description') - - note = models.CharField(max_length=100, blank=True, help_text='Notes') - - single_price = models.DecimalField(max_digits=10, decimal_places=3, default=0, validators=[MinValueValidator(0)], help_text='Price for single quantity') - - base_cost = models.DecimalField(max_digits=10, decimal_places=3, default=0, validators=[MinValueValidator(0)], help_text='Minimum charge (e.g. stocking fee)') - - packaging = models.CharField(max_length=50, blank=True, help_text='Part packaging') - - multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], help_text='Order multiple') - - minimum = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], help_text='Minimum order quantity (MOQ)') - - lead_time = models.DurationField(blank=True, null=True) - @property - def manufacturer_string(self): - - items = [] - - if self.manufacturer: - items.append(self.manufacturer) - if self.MPN: - items.append(self.MPN) - - return ' | '.join(items) - - @property - def has_price_breaks(self): - return self.price_breaks.count() > 0 - - def get_price(self, quantity, moq=True, multiples=True): - """ Calculate the supplier price based on quantity price breaks. - - - If no price breaks available, use the single_price field - - Don't forget to add in flat-fee cost (base_cost field) - - If MOQ (minimum order quantity) is required, bump quantity - - If order multiples are to be observed, then we need to calculate based on that, too - """ - - # Order multiples - if multiples: - quantity = int(math.ceil(quantity / self.multipe) * self.multiple) - - # Minimum ordering requirement - if moq and self.minimum > quantity: - quantity = self.minimum - - pb_found = False - pb_quantity = -1 - pb_cost = 0.0 - - for pb in self.price_breaks.all(): - # Ignore this pricebreak! - if pb.quantity > quantity: - continue - - pb_found = True - - # If this price-break quantity is the largest so far, use it! - if pb.quantity > pb_quantity: - pb_quantity = pb.quantity - pb_cost = pb.cost - - # No appropriate price-break found - use the single cost! - if pb_found: - cost = pb_cost * quantity - else: - cost = self.single_price * quantity - - return cost + self.base_cost - - def __str__(self): - s = "{supplier} ({sku})".format( - sku=self.SKU, - supplier=self.supplier.name) - - if self.manufacturer_string: - s = s + ' - ' + self.manufacturer_string - - return s - - -class SupplierPriceBreak(models.Model): - """ Represents a quantity price break for a SupplierPart. - - Suppliers can offer discounts at larger quantities - - SupplierPart(s) may have zero-or-more associated SupplierPriceBreak(s) - - Attributes: - part: Link to a SupplierPart object that this price break applies to - quantity: Quantity required for price break - cost: Cost at specified quantity - """ - - part = models.ForeignKey(SupplierPart, on_delete=models.CASCADE, related_name='price_breaks') - - # At least 2 units are required for a 'price break' - Otherwise, just use single-price! - quantity = models.PositiveIntegerField(validators=[MinValueValidator(2)]) - - cost = models.DecimalField(max_digits=10, decimal_places=3, validators=[MinValueValidator(0)]) - - class Meta: - unique_together = ("part", "quantity") - - def __str__(self): - return "{mpn} - {cost}{currency} @ {quan}".format( - mpn=self.part.MPN, - cost=self.cost, - currency=self.currency if self.currency else '', - quan=self.quantity) + def price_info(self): + """ Return the price for this item in the BOM """ + return self.sub_part.get_price_info(self.quantity) diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 87ca59c13b..593f7003f9 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -5,7 +5,7 @@ JSON serializers for Part app from rest_framework import serializers from .models import Part, PartStar -from .models import SupplierPart, SupplierPriceBreak + from .models import PartCategory from .models import BomItem @@ -34,6 +34,7 @@ class PartBriefSerializer(serializers.ModelSerializer): url = serializers.CharField(source='get_absolute_url', read_only=True) image_url = serializers.CharField(source='get_image_url', read_only=True) + single_price_info = serializers.CharField(read_only=True) class Meta: model = Part @@ -43,6 +44,7 @@ class PartBriefSerializer(serializers.ModelSerializer): 'full_name', 'description', 'available_stock', + 'single_price_info', 'image_url', ] @@ -106,6 +108,7 @@ class BomItemSerializer(InvenTreeModelSerializer): part_detail = PartBriefSerializer(source='part', many=False, read_only=True) sub_part_detail = PartBriefSerializer(source='sub_part', many=False, read_only=True) + price_info = serializers.CharField(read_only=True) class Meta: model = BomItem @@ -116,46 +119,7 @@ class BomItemSerializer(InvenTreeModelSerializer): 'sub_part', 'sub_part_detail', 'quantity', + 'price_info', 'overage', 'note', ] - - -class SupplierPartSerializer(serializers.ModelSerializer): - """ Serializer for SupplierPart object """ - - url = serializers.CharField(source='get_absolute_url', read_only=True) - - part_detail = PartBriefSerializer(source='part', many=False, read_only=True) - - supplier_name = serializers.CharField(source='supplier.name', read_only=True) - supplier_logo = serializers.CharField(source='supplier.get_image_url', read_only=True) - - class Meta: - model = SupplierPart - fields = [ - 'pk', - 'url', - 'part', - 'part_detail', - 'supplier', - 'supplier_name', - 'supplier_logo', - 'SKU', - 'manufacturer', - 'MPN', - 'URL', - ] - - -class SupplierPriceBreakSerializer(serializers.ModelSerializer): - """ Serializer for SupplierPriceBreak object """ - - class Meta: - model = SupplierPriceBreak - fields = [ - 'pk', - 'part', - 'quantity', - 'cost' - ] diff --git a/InvenTree/part/templates/part/bom.html b/InvenTree/part/templates/part/bom.html index 3990bfab6c..bff082b6ae 100644 --- a/InvenTree/part/templates/part/bom.html +++ b/InvenTree/part/templates/part/bom.html @@ -11,6 +11,14 @@

Bill of Materials

+{% if part.has_complete_bom_pricing == False %} +
+ The BOM for {{ part.full_name }} does not have complete pricing information +
+{% endif %} +
+ Single BOM Price: {{ part.min_bom_price }} to {{ part.max_bom_price }} +
{% if part.bom_checked_date %} {% if part.is_bom_valid %}
diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index ea3bbc7617..5e4f85d238 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -32,10 +32,13 @@

{{ part.description }}

- {% include "qr_button.html" %} + {% include "qr_button.html" %} +

@@ -82,6 +85,25 @@ {% endif %} + {% if part.supplier_count > 0 %} + + + + + {% endif %}
{{ part.allocation_count }}
+ Price + + {% if part.min_single_price %} + {% if part.min_single_price == part.max_single_price %} + {{ part.min_single_price }} + {% else %} + {{ part.min_single_price }} to {{ part.max_single_price }} + {% endif %} + from {{ part.supplier_count }} suppliers. + {% else %} + No pricing data avilable + {% endif %} +

@@ -122,6 +144,15 @@ ); }); + $("#price-button").click(function() { + launchModalForm( + "{% url 'part-pricing' part.id %}", + { + submit_text: 'Calculate', + } + ); + }); + $("#toggle-starred").click(function() { toggleStar({ part: {{ part.id }}, diff --git a/InvenTree/part/templates/part/part_pricing.html b/InvenTree/part/templates/part/part_pricing.html new file mode 100644 index 0000000000..6d82549bb2 --- /dev/null +++ b/InvenTree/part/templates/part/part_pricing.html @@ -0,0 +1,88 @@ +{% extends "modal_form.html" %} + +{% block pre_form_content %} + +
+Calculate pricing information for {{ part }}. +
+ +

Quantity

+ + + + + + + + + +
Part{{ part }}
Quantity{{ quantity }}
+ {% if part.supplier_count > 0 %} +

Supplier Pricing

+ + {% if min_total_buy_price %} + + + + + + {% if quantity > 1 %} + + + + + + {% endif %} + {% else %} + + + + {% endif %} +
Unit CostMin: {{ min_unit_buy_price }}Max: {{ max_unit_buy_price }}
Total CostMin: {{ min_total_buy_price }}Max: {{ max_total_buy_price }}
+ No supplier pricing available +
+ {% endif %} + + {% if part.bom_count > 0 %} +

BOM Pricing

+ + {% if min_total_bom_price %} + + + + + + {% if quantity > 1 %} + + + + + + {% endif %} + {% if part.has_complete_bom_pricing == False %} + + + + {% endif %} + {% else %} + + + + {% endif %} +
Unit CostMin: {{ min_unit_bom_price }}Max: {{ max_unit_bom_price }}
Total CostMin: {{ min_total_bom_price }}Max: {{ max_total_bom_price }}
+ Note: BOM pricing is incomplete for this part +
+ No BOM pricing available +
+ {% endif %} + +{% if min_unit_buy_price or min_unit_bom_price %} +{% else %} +
+ No pricing information is available for this part. +
+{% endif %} + +
+ +{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index 0671240e73..900ebe8127 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -12,19 +12,6 @@ from django.conf.urls import url, include from . import views -supplier_part_detail_urls = [ - url(r'edit/?', views.SupplierPartEdit.as_view(), name='supplier-part-edit'), - url(r'delete/?', views.SupplierPartDelete.as_view(), name='supplier-part-delete'), - - url('^.*$', views.SupplierPartDetail.as_view(), name='supplier-part-detail'), -] - -supplier_part_urls = [ - url(r'^new/?', views.SupplierPartCreate.as_view(), name='supplier-part-create'), - - url(r'^(?P\d+)/', include(supplier_part_detail_urls)), -] - part_attachment_urls = [ url('^new/?', views.PartAttachmentCreate.as_view(), name='part-attachment-create'), url(r'^(?P\d+)/edit/?', views.PartAttachmentEdit.as_view(), name='part-attachment-edit'), @@ -37,6 +24,7 @@ part_detail_urls = [ url(r'^bom-export/?', views.BomDownload.as_view(), name='bom-export'), url(r'^validate-bom/', views.BomValidate.as_view(), name='bom-validate'), url(r'^duplicate/', views.PartDuplicate.as_view(), name='part-duplicate'), + url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'), 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'), diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 55482d3284..d5824b2dab 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -12,12 +12,12 @@ from django.views.generic import DetailView, ListView from django.forms.models import model_to_dict from django.forms import HiddenInput, CheckboxInput -from company.models import Company from .models import PartCategory, Part, PartAttachment from .models import BomItem -from .models import SupplierPart from .models import match_part_names +from company.models import SupplierPart + from . import forms as part_forms from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView @@ -551,6 +551,80 @@ class PartDelete(AjaxDeleteView): } +class PartPricing(AjaxView): + """ View for inspecting part pricing information """ + + model = Part + ajax_template_name = "part/part_pricing.html" + ajax_form_title = "Part Pricing" + form_class = part_forms.PartPriceForm + + def get_part(self): + try: + return Part.objects.get(id=self.kwargs['pk']) + except Part.DoesNotExist: + return None + + def get_pricing(self, quantity=1): + + part = self.get_part() + + ctx = { + 'part': part, + 'quantity': quantity + } + + if part is None: + return ctx + + # Supplier pricing information + if part.supplier_count > 0: + min_buy_price = part.get_min_supplier_price(quantity) + max_buy_price = part.get_max_supplier_price(quantity) + + if min_buy_price: + ctx['min_total_buy_price'] = min_buy_price + ctx['min_unit_buy_price'] = min_buy_price / quantity + + if max_buy_price: + ctx['max_total_buy_price'] = max_buy_price + ctx['max_unit_buy_price'] = max_buy_price / quantity + + # BOM pricing information + if part.bom_count > 0: + + min_bom_price = part.get_min_bom_price(quantity) + max_bom_price = part.get_max_bom_price(quantity) + + if min_bom_price: + ctx['min_total_bom_price'] = min_bom_price + ctx['min_unit_bom_price'] = min_bom_price / quantity + + if max_bom_price: + ctx['max_total_bom_price'] = max_bom_price + ctx['max_unit_bom_price'] = max_bom_price / quantity + + return ctx + + def get(self, request, *args, **kwargs): + + return self.renderJsonResponse(request, self.form_class(), context=self.get_pricing()) + + def post(self, request, *args, **kwargs): + + try: + quantity = int(self.request.POST.get('quantity', 1)) + except ValueError: + quantity = 1 + + # Always mark the form as 'invalid' (the user may wish to keep getting pricing data) + data = { + 'form_valid': False, + } + + return self.renderJsonResponse(request, self.form_class(), data=data, context=self.get_pricing(quantity)) + + class CategoryDetail(DetailView): """ Detail view for PartCategory """ model = PartCategory @@ -731,81 +805,3 @@ class BomItemDelete(AjaxDeleteView): ajax_template_name = 'part/bom-delete.html' context_object_name = 'item' ajax_form_title = 'Confim BOM item deletion' - - -class SupplierPartDetail(DetailView): - """ Detail view for SupplierPart """ - model = SupplierPart - template_name = 'company/partdetail.html' - context_object_name = 'part' - queryset = SupplierPart.objects.all() - - -class SupplierPartEdit(AjaxUpdateView): - """ Update view for editing SupplierPart """ - - model = SupplierPart - context_object_name = 'part' - form_class = part_forms.EditSupplierPartForm - ajax_template_name = 'modal_form.html' - ajax_form_title = 'Edit Supplier Part' - - -class SupplierPartCreate(AjaxCreateView): - """ Create view for making new SupplierPart """ - - model = SupplierPart - form_class = part_forms.EditSupplierPartForm - ajax_template_name = 'modal_form.html' - ajax_form_title = 'Create new Supplier Part' - context_object_name = 'part' - - def get_form(self): - """ Create Form instance to create a new SupplierPart object. - Hide some fields if they are not appropriate in context - """ - form = super(AjaxCreateView, self).get_form() - - if form.initial.get('supplier', None): - # Hide the supplier field - form.fields['supplier'].widget = HiddenInput() - - if form.initial.get('part', None): - # Hide the part field - form.fields['part'].widget = HiddenInput() - - return form - - def get_initial(self): - """ Provide initial data for new SupplierPart: - - - If 'supplier_id' provided, pre-fill supplier field - - If 'part_id' provided, pre-fill part field - """ - initials = super(SupplierPartCreate, self).get_initial().copy() - - supplier_id = self.get_param('supplier') - part_id = self.get_param('part') - - if supplier_id: - try: - initials['supplier'] = Company.objects.get(pk=supplier_id) - except Company.DoesNotExist: - initials['supplier'] = None - - if part_id: - try: - initials['part'] = Part.objects.get(pk=part_id) - except Part.DoesNotExist: - initials['part'] = None - - return initials - - -class SupplierPartDelete(AjaxDeleteView): - """ Delete view for removing a SupplierPart """ - model = SupplierPart - success_url = '/supplier/' - ajax_template_name = 'company/partdelete.html' - ajax_form_title = 'Delete Supplier Part' - context_object_name = 'supplier_part' diff --git a/InvenTree/static/css/inventree.css b/InvenTree/static/css/inventree.css index 59e7899891..ff7ac4e98b 100644 --- a/InvenTree/static/css/inventree.css +++ b/InvenTree/static/css/inventree.css @@ -3,15 +3,19 @@ } .glyphicon { - font-size: 20px; + font-size: 18px; } .glyphicon-small { - font-size: 14px; + font-size: 12px; } .starred-part { - color: #ffcc00; + color: #ffbb00; +} + +.part-price { + color: rgb(13, 245, 25); } /* CSS overrides for treeview */ diff --git a/InvenTree/static/script/inventree/bom.js b/InvenTree/static/script/inventree/bom.js index 6ff81de4fc..6e00eabe67 100644 --- a/InvenTree/static/script/inventree/bom.js +++ b/InvenTree/static/script/inventree/bom.js @@ -151,6 +151,19 @@ function loadBomTable(table, options) { } } ); + + cols.push({ + field: 'price_info', + title: 'Price', + sortable: true, + formatter: function(value, row, index, field) { + if (value) { + return value; + } else { + return "No pricing information"; + } + }, + }); } // Part notes diff --git a/InvenTree/stock/migrations/0001_initial.py b/InvenTree/stock/migrations/0001_initial.py index b4d15a7f64..2fc4e96608 100644 --- a/InvenTree/stock/migrations/0001_initial.py +++ b/InvenTree/stock/migrations/0001_initial.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.12 on 2018-04-22 11:53 -from __future__ import unicode_literals +# Generated by Django 2.2 on 2019-05-18 14:04 from django.conf import settings import django.core.validators @@ -14,8 +12,8 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('part', '0001_initial'), ('company', '0001_initial'), + ('part', '0001_initial'), ] operations = [ @@ -25,30 +23,19 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('serial', models.PositiveIntegerField(blank=True, help_text='Serial number for this item', null=True)), ('URL', models.URLField(blank=True, max_length=125)), - ('batch', models.CharField(blank=True, help_text='Batch code for this stock item', max_length=100)), - ('quantity', models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(0)])), - ('updated', models.DateField(auto_now=True)), + ('batch', models.CharField(blank=True, help_text='Batch code for this stock item', max_length=100, null=True)), + ('quantity', models.PositiveIntegerField(default=1, validators=[django.core.validators.MinValueValidator(0)])), + ('updated', models.DateField(auto_now=True, null=True)), ('stocktake_date', models.DateField(blank=True, null=True)), ('review_needed', models.BooleanField(default=False)), - ('status', models.PositiveIntegerField(choices=[(10, 'OK'), (60, 'Destroyed'), (50, 'Attention needed'), (55, 'Damaged')], default=10, validators=[django.core.validators.MinValueValidator(0)])), - ('notes', models.TextField(blank=True)), + ('delete_on_deplete', models.BooleanField(default=True, help_text='Delete this Stock Item when stock is depleted')), + ('status', models.PositiveIntegerField(choices=[(10, 'OK'), (50, 'Attention needed'), (55, 'Damaged'), (60, 'Destroyed')], default=10, validators=[django.core.validators.MinValueValidator(0)])), + ('notes', models.CharField(blank=True, help_text='Stock Item Notes', max_length=250)), ('infinite', models.BooleanField(default=False)), ('belongs_to', models.ForeignKey(blank=True, help_text='Is this item installed in another item?', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='owned_parts', to='stock.StockItem')), ('customer', models.ForeignKey(blank=True, help_text='Item assigned to customer?', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stockitems', to='company.Company')), ], ), - migrations.CreateModel( - name='StockItemTracking', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('date', models.DateField(auto_now_add=True)), - ('title', models.CharField(max_length=250)), - ('notes', models.TextField(blank=True)), - ('system', models.BooleanField(default=False)), - ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tracking_info', to='stock.StockItem')), - ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), - ], - ), migrations.CreateModel( name='StockLocation', fields=[ @@ -59,34 +46,44 @@ class Migration(migrations.Migration): ], options={ 'abstract': False, + 'unique_together': {('name', 'parent')}, }, ), + migrations.CreateModel( + name='StockItemTracking', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateTimeField(auto_now_add=True)), + ('title', models.CharField(max_length=250)), + ('notes', models.TextField(blank=True)), + ('system', models.BooleanField(default=False)), + ('quantity', models.PositiveIntegerField(default=1, validators=[django.core.validators.MinValueValidator(0)])), + ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tracking_info', to='stock.StockItem')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + ), migrations.AddField( model_name='stockitem', name='location', - field=models.ForeignKey(blank=True, help_text='Where is this stock item located?', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='items', to='stock.StockLocation'), + field=models.ForeignKey(blank=True, help_text='Where is this stock item located?', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='stock_items', to='stock.StockLocation'), ), migrations.AddField( model_name='stockitem', name='part', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='locations', to='part.Part'), + field=models.ForeignKey(help_text='Base part', on_delete=django.db.models.deletion.CASCADE, related_name='locations', to='part.Part'), ), migrations.AddField( model_name='stockitem', name='stocktake_user', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stocktake_stock', to=settings.AUTH_USER_MODEL), ), migrations.AddField( model_name='stockitem', name='supplier_part', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='part.SupplierPart'), - ), - migrations.AlterUniqueTogether( - name='stocklocation', - unique_together=set([('name', 'parent')]), + field=models.ForeignKey(blank=True, help_text='Select a matching supplier part for this stock item', null=True, on_delete=django.db.models.deletion.SET_NULL, to='company.SupplierPart'), ), migrations.AlterUniqueTogether( name='stockitem', - unique_together=set([('part', 'serial')]), + unique_together={('part', 'serial')}, ), ] diff --git a/InvenTree/stock/migrations/0002_auto_20180430_1218.py b/InvenTree/stock/migrations/0002_auto_20180430_1218.py deleted file mode 100644 index a3ed70090f..0000000000 --- a/InvenTree/stock/migrations/0002_auto_20180430_1218.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.12 on 2018-04-30 12:18 -from __future__ import unicode_literals - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('stock', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='stockitem', - name='quantity', - field=models.PositiveIntegerField(default=1, validators=[django.core.validators.MinValueValidator(0)]), - ), - ] diff --git a/InvenTree/stock/migrations/0003_auto_20180510_1042.py b/InvenTree/stock/migrations/0003_auto_20180510_1042.py deleted file mode 100644 index dfa34c6b5c..0000000000 --- a/InvenTree/stock/migrations/0003_auto_20180510_1042.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.12 on 2018-05-10 10:42 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('stock', '0002_auto_20180430_1218'), - ] - - operations = [ - migrations.AlterField( - model_name='stockitemtracking', - name='date', - field=models.DateTimeField(auto_now_add=True), - ), - ] diff --git a/InvenTree/stock/migrations/0004_auto_20190412_2030.py b/InvenTree/stock/migrations/0004_auto_20190412_2030.py deleted file mode 100644 index d55dbc6fff..0000000000 --- a/InvenTree/stock/migrations/0004_auto_20190412_2030.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.2 on 2019-04-12 10:30 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('stock', '0003_auto_20180510_1042'), - ] - - operations = [ - migrations.AlterField( - model_name='stockitem', - name='status', - field=models.PositiveIntegerField(choices=[(10, 'OK'), (50, 'Attention needed'), (55, 'Damaged'), (60, 'Destroyed')], default=10, validators=[django.core.validators.MinValueValidator(0)]), - ), - ] diff --git a/InvenTree/stock/migrations/0005_stockitemtracking_quantity.py b/InvenTree/stock/migrations/0005_stockitemtracking_quantity.py deleted file mode 100644 index f654486075..0000000000 --- a/InvenTree/stock/migrations/0005_stockitemtracking_quantity.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.2 on 2019-04-12 14:09 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('stock', '0004_auto_20190412_2030'), - ] - - operations = [ - migrations.AddField( - model_name='stockitemtracking', - name='quantity', - field=models.PositiveIntegerField(default=1, validators=[django.core.validators.MinValueValidator(0)]), - ), - ] diff --git a/InvenTree/stock/migrations/0006_stockitem_uuid.py b/InvenTree/stock/migrations/0006_stockitem_uuid.py deleted file mode 100644 index 4d358e7201..0000000000 --- a/InvenTree/stock/migrations/0006_stockitem_uuid.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.2 on 2019-04-12 15:06 - -from django.db import migrations, models -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('stock', '0005_stockitemtracking_quantity'), - ] - - operations = [ - migrations.AddField( - model_name='stockitem', - name='uuid', - field=models.UUIDField(blank=True, default=uuid.uuid4, editable=False), - ), - ] diff --git a/InvenTree/stock/migrations/0007_auto_20190417_1812.py b/InvenTree/stock/migrations/0007_auto_20190417_1812.py deleted file mode 100644 index 8b9ec638e3..0000000000 --- a/InvenTree/stock/migrations/0007_auto_20190417_1812.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.2 on 2019-04-17 08:12 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('stock', '0006_stockitem_uuid'), - ] - - operations = [ - migrations.AlterField( - model_name='stockitem', - name='notes', - field=models.CharField(blank=True, help_text='Stock Item Notes', max_length=250), - ), - ] diff --git a/InvenTree/stock/migrations/0008_auto_20190417_1819.py b/InvenTree/stock/migrations/0008_auto_20190417_1819.py deleted file mode 100644 index 46659abd66..0000000000 --- a/InvenTree/stock/migrations/0008_auto_20190417_1819.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 2.2 on 2019-04-17 08:19 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('stock', '0007_auto_20190417_1812'), - ] - - operations = [ - migrations.AlterField( - model_name='stockitem', - name='stocktake_user', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stocktake_stock', to=settings.AUTH_USER_MODEL), - ), - ] diff --git a/InvenTree/stock/migrations/0009_auto_20190428_0841.py b/InvenTree/stock/migrations/0009_auto_20190428_0841.py deleted file mode 100644 index 4e22dad76b..0000000000 --- a/InvenTree/stock/migrations/0009_auto_20190428_0841.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.2 on 2019-04-27 22:41 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('stock', '0008_auto_20190417_1819'), - ] - - operations = [ - migrations.AlterField( - model_name='stockitem', - name='location', - field=models.ForeignKey(blank=True, help_text='Where is this stock item located?', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='stock_items', to='stock.StockLocation'), - ), - ] diff --git a/InvenTree/stock/migrations/0010_auto_20190501_2344.py b/InvenTree/stock/migrations/0010_auto_20190501_2344.py deleted file mode 100644 index 61ea730b03..0000000000 --- a/InvenTree/stock/migrations/0010_auto_20190501_2344.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.2 on 2019-05-01 13:44 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('stock', '0009_auto_20190428_0841'), - ] - - operations = [ - migrations.AlterField( - model_name='stockitem', - name='batch', - field=models.CharField(blank=True, help_text='Batch code for this stock item', max_length=100, null=True), - ), - ] diff --git a/InvenTree/stock/migrations/0011_auto_20190502_0041.py b/InvenTree/stock/migrations/0011_auto_20190502_0041.py deleted file mode 100644 index b540421de5..0000000000 --- a/InvenTree/stock/migrations/0011_auto_20190502_0041.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.2 on 2019-05-01 14:41 - -from django.db import migrations, models -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('stock', '0010_auto_20190501_2344'), - ] - - operations = [ - migrations.AlterField( - model_name='stockitem', - name='uuid', - field=models.UUIDField(blank=True, default=uuid.uuid4, editable=False, help_text='Unique ID for the StockItem'), - ), - ] diff --git a/InvenTree/stock/migrations/0012_auto_20190502_0058.py b/InvenTree/stock/migrations/0012_auto_20190502_0058.py deleted file mode 100644 index 1c86926fcd..0000000000 --- a/InvenTree/stock/migrations/0012_auto_20190502_0058.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.2 on 2019-05-01 14:58 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('stock', '0011_auto_20190502_0041'), - ] - - operations = [ - migrations.AlterField( - model_name='stockitem', - name='part', - field=models.ForeignKey(help_text='Base part', on_delete=django.db.models.deletion.CASCADE, related_name='locations', to='part.Part'), - ), - ] diff --git a/InvenTree/stock/migrations/0013_remove_stockitem_uuid.py b/InvenTree/stock/migrations/0013_remove_stockitem_uuid.py deleted file mode 100644 index 79184862ed..0000000000 --- a/InvenTree/stock/migrations/0013_remove_stockitem_uuid.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 2.2 on 2019-05-02 10:39 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('stock', '0012_auto_20190502_0058'), - ] - - operations = [ - migrations.RemoveField( - model_name='stockitem', - name='uuid', - ), - ] diff --git a/InvenTree/stock/migrations/0014_auto_20190508_2332.py b/InvenTree/stock/migrations/0014_auto_20190508_2332.py deleted file mode 100644 index 829bd34df8..0000000000 --- a/InvenTree/stock/migrations/0014_auto_20190508_2332.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.2 on 2019-05-08 13:32 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('stock', '0013_remove_stockitem_uuid'), - ] - - operations = [ - migrations.AlterField( - model_name='stockitem', - name='supplier_part', - field=models.ForeignKey(blank=True, help_text='Select a matching supplier part for this stock item', null=True, on_delete=django.db.models.deletion.SET_NULL, to='part.SupplierPart'), - ), - ] diff --git a/InvenTree/stock/migrations/0015_stockitem_delete_on_deplete.py b/InvenTree/stock/migrations/0015_stockitem_delete_on_deplete.py deleted file mode 100644 index 29631b94d8..0000000000 --- a/InvenTree/stock/migrations/0015_stockitem_delete_on_deplete.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.2 on 2019-05-09 12:59 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('stock', '0014_auto_20190508_2332'), - ] - - operations = [ - migrations.AddField( - model_name='stockitem', - name='delete_on_deplete', - field=models.BooleanField(default=True, help_text='Delete this Stock Item when stock is depleted'), - ), - ] diff --git a/InvenTree/stock/migrations/0016_auto_20190512_2119.py b/InvenTree/stock/migrations/0016_auto_20190512_2119.py deleted file mode 100644 index 582e68d277..0000000000 --- a/InvenTree/stock/migrations/0016_auto_20190512_2119.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.2 on 2019-05-12 11:19 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('stock', '0015_stockitem_delete_on_deplete'), - ] - - operations = [ - migrations.AlterField( - model_name='stockitem', - name='updated', - field=models.DateField(auto_now=True, null=True), - ), - ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 950d6237dd..fcb1622f27 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -188,7 +188,7 @@ class StockItem(models.Model): part = models.ForeignKey('part.Part', on_delete=models.CASCADE, related_name='locations', help_text='Base part') - supplier_part = models.ForeignKey('part.SupplierPart', blank=True, null=True, on_delete=models.SET_NULL, + supplier_part = models.ForeignKey('company.SupplierPart', blank=True, null=True, on_delete=models.SET_NULL, help_text='Select a matching supplier part for this stock item') location = models.ForeignKey(StockLocation, on_delete=models.DO_NOTHING,