Merge pull request #350 from SchrodingersGat/part-pricing

Part pricing
This commit is contained in:
Oliver 2019-05-19 00:14:02 +10:00 committed by GitHub
commit 2e2c51b271
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
88 changed files with 1274 additions and 1775 deletions

1
.gitignore vendored
View File

@ -26,6 +26,7 @@ var/
*.log *.log
local_settings.py local_settings.py
*.sqlite3 *.sqlite3
*.backup
# Sphinx files # Sphinx files
docs/_build docs/_build

View File

@ -11,9 +11,10 @@ from django.contrib.auth import views as auth_views
from qr_code import urls as qr_code_urls from qr_code import urls as qr_code_urls
from company.urls import company_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 part_urls
from part.urls import supplier_part_urls
from stock.urls import stock_urls from stock.urls import stock_urls
@ -50,6 +51,7 @@ apipatterns = [
urlpatterns = [ urlpatterns = [
url(r'^part/', include(part_urls)), url(r'^part/', include(part_urls)),
url(r'^supplier-part/', include(supplier_part_urls)), url(r'^supplier-part/', include(supplier_part_urls)),
url(r'^price-break/', include(price_break_urls)),
url(r'^stock/', include(stock_urls)), url(r'^stock/', include(stock_urls)),

View File

@ -223,6 +223,7 @@ class AjaxCreateView(AjaxMixin, CreateView):
super(CreateView, self).get(request, *args, **kwargs) super(CreateView, self).get(request, *args, **kwargs)
self.request = request
form = self.get_form() form = self.get_form()
return self.renderJsonResponse(request, form) return self.renderJsonResponse(request, form)
@ -233,6 +234,7 @@ class AjaxCreateView(AjaxMixin, CreateView):
- If valid, save form - If valid, save form
- Return status info (success / failure) - Return status info (success / failure)
""" """
self.request = request
form = self.get_form() form = self.get_form()
# Extra JSON data sent alongside form # Extra JSON data sent alongside form

View File

@ -1,7 +1,6 @@
# -*- coding: utf-8 -*- # Generated by Django 2.2 on 2019-05-18 14:04
# Generated by Django 1.11.12 on 2018-04-22 11:53
from __future__ import unicode_literals
from django.conf import settings
import django.core.validators import django.core.validators
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
@ -12,7 +11,9 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('part', '0001_initial'), ('part', '0002_auto_20190519_0004'),
('stock', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
@ -20,14 +21,29 @@ class Migration(migrations.Migration):
name='Build', name='Build',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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)), ('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)])), ('quantity', models.PositiveIntegerField(default=1, help_text='Number of parts to build', validators=[django.core.validators.MinValueValidator(1)])),
('notes', models.TextField(blank=True)), ('status', models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Allocated'), (30, 'Cancelled'), (40, 'Complete')], default=10, help_text='Build status', validators=[django.core.validators.MinValueValidator(0)])),
('part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='builds', to='part.Part')), ('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')},
},
),
] ]

View File

@ -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)]),
),
]

View File

@ -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')),
],
),
]

View File

@ -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'),
),
]

View File

@ -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',
),
]

View File

@ -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',
),
]

View File

@ -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')},
),
]

View File

@ -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'),
),
]

View File

@ -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),
),
]

View File

@ -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'),
),
]

View File

@ -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)]),
),
]

View File

@ -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)]),
),
]

View File

@ -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'),
),
]

View File

@ -36,6 +36,19 @@ InvenTree | Build - {{ build }}
<td>Quantity</td> <td>Quantity</td>
<td>{{ build.quantity }}</td> <td>{{ build.quantity }}</td>
</tr> </tr>
<tr>
<td>BOM Price</td>
<td>
{% if bom_price %}
{{ bom_price }}
{% if build.part.has_complete_bom_pricing == False %}
<span class='warning-msg'><i>BOM pricing is incomplete</i></span>
{% endif %}
{% else %}
<span class='warning-msg'><i>No pricing information</i></span>
{% endif %}
</td>
</tr>
</table> </table>
</div> </div>
</div> </div>

View File

@ -265,6 +265,16 @@ class BuildDetail(DetailView):
template_name = 'build/detail.html' template_name = 'build/detail.html'
context_object_name = 'build' 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): class BuildAllocate(DetailView):
""" View for allocating parts to a Build """ """ View for allocating parts to a Build """

View File

@ -2,10 +2,22 @@ from django.contrib import admin
from import_export.admin import ImportExportModelAdmin from import_export.admin import ImportExportModelAdmin
from .models import Company from .models import Company
from .models import SupplierPart
from .models import SupplierPriceBreak
class CompanyAdmin(ImportExportModelAdmin): class CompanyAdmin(ImportExportModelAdmin):
list_display = ('name', 'website', 'contact') 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(Company, CompanyAdmin)
admin.site.register(SupplierPart, SupplierPartAdmin)
admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin)

View File

@ -9,10 +9,13 @@ from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters from rest_framework import filters
from rest_framework import generics, permissions 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 Company
from .models import SupplierPart, SupplierPriceBreak
from .serializers import CompanySerializer from .serializers import CompanySerializer
from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer
class CompanyList(generics.ListCreateAPIView): 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<pk>\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 = [ 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<pk>\d+)/?', CompanyDetail.as_view(), name='api-company-detail'), url(r'^(?P<pk>\d+)/?', CompanyDetail.as_view(), name='api-company-detail'),

View File

@ -8,6 +8,8 @@ from __future__ import unicode_literals
from InvenTree.forms import HelperForm from InvenTree.forms import HelperForm
from .models import Company from .models import Company
from .models import SupplierPart
from .models import SupplierPriceBreak
class EditCompanyForm(HelperForm): class EditCompanyForm(HelperForm):
@ -37,3 +39,37 @@ class CompanyImageForm(HelperForm):
fields = [ fields = [
'image' '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'
]

View File

@ -1,9 +1,9 @@
# -*- coding: utf-8 -*- # Generated by Django 2.2 on 2019-05-18 14:04
# Generated by Django 1.11.12 on 2018-04-22 11:53
from __future__ import unicode_literals
import company.models import company.models
import django.core.validators
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -18,15 +18,60 @@ class Migration(migrations.Migration):
name='Company', name='Company',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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)), ('name', models.CharField(help_text='Company name', max_length=100, unique=True)),
('description', models.CharField(max_length=500)), ('description', models.CharField(help_text='Description of the company', max_length=500)),
('website', models.URLField(blank=True, help_text='Company website URL')), ('website', models.URLField(blank=True, help_text='Company website URL')),
('address', models.CharField(blank=True, help_text='Company address', max_length=200)), ('address', models.CharField(blank=True, help_text='Company address', max_length=200)),
('phone', models.CharField(blank=True, max_length=50)), ('phone', models.CharField(blank=True, help_text='Contact phone number', max_length=50)),
('email', models.EmailField(blank=True, max_length=254)), ('email', models.EmailField(blank=True, help_text='Contact email address', max_length=254)),
('contact', models.CharField(blank=True, max_length=100)), ('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)), ('image', models.ImageField(blank=True, max_length=255, null=True, upload_to=company.models.rename_company_image)),
('notes', models.TextField(blank=True)), ('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',
},
),
] ]

View File

@ -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),
),
]

View File

@ -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 from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import part.models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True
dependencies = [ dependencies = [
('part', '0018_auto_20190505_2231'), ('company', '0001_initial'),
('part', '0001_initial'),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AddField(
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(
model_name='supplierpart', model_name='supplierpart',
name='part', 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'), 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', model_name='supplierpart',
name='supplier', 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'), 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')},
),
] ]

View File

@ -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),
),
]

View File

@ -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'),
),
]

View File

@ -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')),
],
),
]

View File

@ -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),
),
]

View File

@ -7,6 +7,10 @@ from __future__ import unicode_literals
import os import os
import math
from django.core.validators import MinValueValidator
from django.apps import apps from django.apps import apps
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
@ -150,3 +154,174 @@ class Contact(models.Model):
company = models.ForeignKey(Company, related_name='contacts', company = models.ForeignKey(Company, related_name='contacts',
on_delete=models.CASCADE) 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)

View File

@ -5,6 +5,9 @@ JSON serializers for Company app
from rest_framework import serializers from rest_framework import serializers
from .models import Company from .models import Company
from .models import SupplierPart, SupplierPriceBreak
from part.serializers import PartBriefSerializer
class CompanyBriefSerializer(serializers.ModelSerializer): class CompanyBriefSerializer(serializers.ModelSerializer):
@ -47,3 +50,43 @@ class CompanySerializer(serializers.ModelSerializer):
'is_supplier', 'is_supplier',
'part_count' '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'
]

View File

@ -10,7 +10,7 @@ InvenTree | {{ company.name }} - Parts
<div class='row'> <div class='row'>
<div class='col-sm-6'> <div class='col-sm-6'>
<h3>Supplier Part</h3> <h3>Supplier Part</h3>
<p>{{ part.SKU }} - <a href="{% url 'company-detail-parts' part.supplier.id %}">{{ part.supplier.name }}</a></p> <p><a href="{% url 'company-detail-parts' part.supplier.id %}">{{ part.supplier.name }}</a> - {{ part.SKU }}</p>
</div> </div>
<div class='col-sm-6'> <div class='col-sm-6'>
<h3> <h3>
@ -59,29 +59,46 @@ InvenTree | {{ company.name }} - Parts
<div class='col-sm-6'> <div class='col-sm-6'>
<table class="table table-striped table-condensed"> <table class="table table-striped table-condensed">
<tr><th colspan='2'>Pricing</th></tr> <tr>
<tr><td>Single Price</td><td>{{ part.single_price }}</td></tr> <th colspan='2'>Pricing</th>
{% if part.multiple > 1 %} </tr>
<tr><td>Order Multiple</td><td>{{ part.multiple }}</td></tr> <tr><td>Order Multiple</td><td>{{ part.multiple }}</td></tr>
{% endif %}
{% if part.base_cost > 0 %} {% if part.base_cost > 0 %}
<tr><td>Base Price (Flat Fee)</td><td>{{ part.base_cost }}</td></tr> <tr><td>Base Price (Flat Fee)</td><td>{{ part.base_cost }}</td></tr>
{% endif %} {% endif %}
{% if part.minimum > 1 %} {% if part.minimum > 1 %}
<tr><td>Minimum Order Quantity</td><td>{{ part.minimum }}</td></tr> <tr><td>Minimum Order Quantity</td><td>{{ part.minimum }}</td></tr>
{% endif %} {% endif %}
{% if part.price_breaks.all %} <tr>
<tr><th colspan='2'>Price Breaks</th></tr> <th>Price Breaks</th>
<th>
<div style='float: right;'>
<button class='btn btn-primary' id='new-price-break' type='button'>New Price Break</button>
</div>
</th>
</tr>
<tr> <tr>
<th>Quantity</th> <th>Quantity</th>
<th>Price</th> <th>Price</th>
</tr> </tr>
{% if part.price_breaks.all %}
{% for pb in part.price_breaks.all %} {% for pb in part.price_breaks.all %}
<tr> <tr>
<td>{{ pb.quantity }}</td> <td>{{ pb.quantity }}</td>
<td>{{ pb.cost }}</td> <td>{{ pb.cost }}
<div class='btn-group' style='float: right;'>
<button title='Edit Price Break' class='btn btn-primary pb-edit-button btn-sm' type='button' url="{% url 'price-break-edit' pb.id %}"><span class='glyphicon glyphicon-small glyphicon-pencil'></span></button>
<button title='Delete Price Break' class='btn btn-danger pb-delete-button btn-sm' type='button' url="{% url 'price-break-delete' pb.id %}"><span class='glyphicon glyphicon-small glyphicon-trash'></span></button>
</div>
</td>
</tr> </tr>
{% endfor %} {% endfor %}
{% else %}
<tr>
<td colspan='2'>
<span class='warning-msg'><i>No price breaks have been added for this part</i></span>
</td>
</tr>
{% endif %} {% endif %}
</table> </table>
</div> </div>
@ -89,9 +106,7 @@ InvenTree | {{ company.name }} - Parts
<br> <br>
<div>
<button class='btn btn-primary' type='button'>New Price Break</button>
</div>
{% include 'modals.html' %} {% 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 %} {% endblock %}

View File

@ -36,3 +36,23 @@ company_urls = [
# Redirect any other patterns # Redirect any other patterns
url(r'^.*$', RedirectView.as_view(url='', permanent=False), name='company-index'), 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<pk>\d+)/edit/', views.PriceBreakEdit.as_view(), name='price-break-edit'),
url(r'^(?P<pk>\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<pk>\d+)/', include(supplier_part_detail_urls)),
]

View File

@ -8,12 +8,20 @@ from __future__ import unicode_literals
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from django.forms import HiddenInput
from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView
from .models import Company from .models import Company
from .models import SupplierPart
from .models import SupplierPriceBreak
from part.models import Part
from .forms import EditCompanyForm from .forms import EditCompanyForm
from .forms import CompanyImageForm from .forms import CompanyImageForm
from .forms import EditSupplierPartForm
from .forms import EditPriceBreakForm
class CompanyIndex(ListView): class CompanyIndex(ListView):
@ -104,3 +112,142 @@ class CompanyDelete(AjaxDeleteView):
return { return {
'danger': 'Company was deleted', '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'

View File

@ -5,6 +5,5 @@ It includes models for:
- PartCategory - PartCategory
- Part - Part
- SupplierPart
- BomItem - BomItem
""" """

View File

@ -3,7 +3,6 @@ from import_export.admin import ImportExportModelAdmin
from .models import PartCategory, Part from .models import PartCategory, Part
from .models import PartAttachment, PartStar from .models import PartAttachment, PartStar
from .models import SupplierPart
from .models import BomItem from .models import BomItem
@ -31,10 +30,6 @@ class BomItemAdmin(ImportExportModelAdmin):
list_display = ('part', 'sub_part', 'quantity') list_display = ('part', 'sub_part', 'quantity')
class SupplierPartAdmin(ImportExportModelAdmin):
list_display = ('part', 'supplier', 'SKU')
""" """
class ParameterTemplateAdmin(admin.ModelAdmin): class ParameterTemplateAdmin(admin.ModelAdmin):
list_display = ('name', 'units', 'format') list_display = ('name', 'units', 'format')
@ -49,4 +44,3 @@ admin.site.register(PartCategory, PartCategoryAdmin)
admin.site.register(PartAttachment, PartAttachmentAdmin) admin.site.register(PartAttachment, PartAttachmentAdmin)
admin.site.register(PartStar, PartStarAdmin) admin.site.register(PartStar, PartStarAdmin)
admin.site.register(BomItem, BomItemAdmin) admin.site.register(BomItem, BomItemAdmin)
admin.site.register(SupplierPart, SupplierPartAdmin)

View File

@ -16,10 +16,8 @@ from django.conf.urls import url, include
from django.urls import reverse from django.urls import reverse
from .models import Part, PartCategory, BomItem, PartStar from .models import Part, PartCategory, BomItem, PartStar
from .models import SupplierPart, SupplierPriceBreak
from .serializers import PartSerializer, BomItemSerializer from .serializers import PartSerializer, BomItemSerializer
from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer
from .serializers import CategorySerializer from .serializers import CategorySerializer
from .serializers import PartStarSerializer 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 = [ cat_api_urls = [
url(r'^(?P<pk>\d+)/?', CategoryDetail.as_view(), name='api-part-category-detail'), url(r'^(?P<pk>\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'), url(r'^$', CategoryList.as_view(), name='api-part-category-list'),
] ]
supplier_part_api_urls = [
url(r'^(?P<pk>\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 = [ part_star_api_urls = [
url(r'^(?P<pk>\d+)/?', PartStarDetail.as_view(), name='api-part-star-detail'), url(r'^(?P<pk>\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'), url(r'^.*$', PartStarList.as_view(), name='api-part-star-list'),
] ]
part_api_urls = [ part_api_urls = [
url(r'^tree/?', PartCategoryTree.as_view(), name='api-part-tree'), url(r'^tree/?', PartCategoryTree.as_view(), name='api-part-tree'),
url(r'^category/', include(cat_api_urls)), 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'^star/', include(part_star_api_urls)),
url(r'^price-break/?', SupplierPriceBreakList.as_view(), name='api-part-supplier-price'),
url(r'^(?P<pk>\d+)/', PartDetail.as_view(), name='api-part-detail'), url(r'^(?P<pk>\d+)/', PartDetail.as_view(), name='api-part-detail'),
url(r'^.*$', PartList.as_view(), name='api-part-list'), url(r'^.*$', PartList.as_view(), name='api-part-list'),
] ]
bom_api_urls = [ bom_api_urls = [
# BOM Item Detail # BOM Item Detail
url('^(?P<pk>\d+)/', BomDetail.as_view(), name='api-bom-detail'), url('^(?P<pk>\d+)/', BomDetail.as_view(), name='api-bom-detail'),

View File

@ -11,7 +11,6 @@ from django import forms
from .models import Part, PartCategory, PartAttachment from .models import Part, PartCategory, PartAttachment
from .models import BomItem from .models import BomItem
from .models import SupplierPart
class PartImageForm(HelperForm): class PartImageForm(HelperForm):
@ -141,24 +140,17 @@ class EditBomItemForm(HelperForm):
widgets = {'part': forms.HiddenInput()} widgets = {'part': forms.HiddenInput()}
class EditSupplierPartForm(HelperForm): class PartPriceForm(forms.Form):
""" Form for editing a SupplierPart object """ """ Simple form for viewing part pricing information """
quantity = forms.IntegerField(
required=True,
initial=1,
help_text='Input quantity for price calculation'
)
class Meta: class Meta:
model = SupplierPart model = Part
fields = [ fields = [
'part', 'quantity'
'supplier',
'SKU',
'description',
'manufacturer',
'MPN',
'URL',
'note',
'single_price',
'base_cost',
'multiple',
'minimum',
'packaging',
'lead_time'
] ]

View File

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # Generated by Django 2.2 on 2019-05-18 14:04
# Generated by Django 1.11.12 on 2018-04-22 11:53
from __future__ import unicode_literals
import InvenTree.validators
from django.conf import settings
import django.core.validators import django.core.validators
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
@ -13,7 +13,7 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('company', '0001_initial'), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
@ -21,7 +21,9 @@ class Migration(migrations.Migration):
name='BomItem', name='BomItem',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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={ options={
'verbose_name': 'BOM Item', 'verbose_name': 'BOM Item',
@ -31,18 +33,24 @@ class Migration(migrations.Migration):
name='Part', name='Part',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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)), ('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)), ('IPN', models.CharField(blank=True, help_text='Internal Part Number', max_length=100)),
('URL', models.URLField(blank=True, help_text='Link to extenal URL')), ('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)), ('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)])), ('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?')), ('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?')), ('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?')), ('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?')), ('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)), ('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={ options={
'verbose_name': 'Part', 'verbose_name': 'Part',
@ -53,8 +61,8 @@ class Migration(migrations.Migration):
name='PartAttachment', name='PartAttachment',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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)), ('attachment', models.FileField(help_text='Select file to attach', upload_to=part.models.attach_file)),
('part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='part.Part')), ('comment', models.CharField(help_text='File comment', max_length=100)),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
@ -63,7 +71,7 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True)), ('name', models.CharField(max_length=100, unique=True)),
('description', models.CharField(max_length=250)), ('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={ options={
'verbose_name': 'Part Category', 'verbose_name': 'Part Category',
@ -71,63 +79,11 @@ class Migration(migrations.Migration):
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='SupplierPart', name='PartStar',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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)), ('part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='starred_users', to='part.Part')),
('manufacturer', models.CharField(blank=True, help_text='Manufacturer', max_length=100)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='starred_parts', to=settings.AUTH_USER_MODEL)),
('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')),
], ],
), ),
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')]),
),
] ]

View File

@ -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')},
),
]

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -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),
),
]

View File

@ -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?'),
),
]

View File

@ -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)]),
),
]

View File

@ -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)]),
),
]

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -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)]),
),
]

View File

@ -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'),
),
]

View File

@ -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),
),
]

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -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)),
],
),
]

View File

@ -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')},
),
]

View File

@ -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'),
),
]

View File

@ -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')},
),
]

View File

@ -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]),
),
]

View File

@ -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),
),
]

View File

@ -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),
),
]

View File

@ -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),
),
]

View File

@ -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'),
),
]

View File

@ -7,8 +7,6 @@ from __future__ import unicode_literals
import os import os
import math
import tablib import tablib
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -32,7 +30,8 @@ import hashlib
from InvenTree import helpers from InvenTree import helpers
from InvenTree import validators from InvenTree import validators
from InvenTree.models import InvenTreeTree from InvenTree.models import InvenTreeTree
from company.models import Company
from company.models import SupplierPart
class PartCategory(InvenTreeTree): class PartCategory(InvenTreeTree):
@ -317,7 +316,7 @@ class Part(models.Model):
# Default to None if there are multiple suppliers to choose from # Default to None if there are multiple suppliers to choose from
return None return None
default_supplier = models.ForeignKey('part.SupplierPart', default_supplier = models.ForeignKey(SupplierPart,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
blank=True, null=True, blank=True, null=True,
help_text='Default supplier part', help_text='Default supplier part',
@ -562,6 +561,203 @@ class Part(models.Model):
""" Return the number of supplier parts available for this part """ """ Return the number of supplier parts available for this part """
return self.supplier_parts.count() 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): def deepCopy(self, other, **kwargs):
""" Duplicates non-field data from another part. """ Duplicates non-field data from another part.
Does not alter the normal fields of this 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, part = models.ForeignKey(Part, on_delete=models.CASCADE,
related_name='attachments') 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') 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 @property
def basename(self): def basename(self):
@ -800,164 +996,7 @@ class BomItem(models.Model):
return base_quantity + self.get_overage_quantity(base_quantity) 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 @property
def manufacturer_string(self): def price_info(self):
""" Return the price for this item in the BOM """
items = [] return self.sub_part.get_price_info(self.quantity)
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)

View File

@ -5,7 +5,7 @@ JSON serializers for Part app
from rest_framework import serializers from rest_framework import serializers
from .models import Part, PartStar from .models import Part, PartStar
from .models import SupplierPart, SupplierPriceBreak
from .models import PartCategory from .models import PartCategory
from .models import BomItem from .models import BomItem
@ -34,6 +34,7 @@ class PartBriefSerializer(serializers.ModelSerializer):
url = serializers.CharField(source='get_absolute_url', read_only=True) url = serializers.CharField(source='get_absolute_url', read_only=True)
image_url = serializers.CharField(source='get_image_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: class Meta:
model = Part model = Part
@ -43,6 +44,7 @@ class PartBriefSerializer(serializers.ModelSerializer):
'full_name', 'full_name',
'description', 'description',
'available_stock', 'available_stock',
'single_price_info',
'image_url', 'image_url',
] ]
@ -106,6 +108,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
part_detail = PartBriefSerializer(source='part', many=False, read_only=True) part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
sub_part_detail = PartBriefSerializer(source='sub_part', many=False, read_only=True) sub_part_detail = PartBriefSerializer(source='sub_part', many=False, read_only=True)
price_info = serializers.CharField(read_only=True)
class Meta: class Meta:
model = BomItem model = BomItem
@ -116,46 +119,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
'sub_part', 'sub_part',
'sub_part_detail', 'sub_part_detail',
'quantity', 'quantity',
'price_info',
'overage', 'overage',
'note', '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'
]

View File

@ -11,6 +11,14 @@
<h3>Bill of Materials</h3> <h3>Bill of Materials</h3>
{% if part.has_complete_bom_pricing == False %}
<div class='alert alert-block alert-warning'>
The BOM for <i>{{ part.full_name }}</i> does not have complete pricing information
</div>
{% endif %}
<div class='panel panel-default'>
Single BOM Price: {{ part.min_bom_price }} to {{ part.max_bom_price }}
</div>
{% if part.bom_checked_date %} {% if part.bom_checked_date %}
{% if part.is_bom_valid %} {% if part.is_bom_valid %}
<div class='alert alert-block alert-info'> <div class='alert alert-block alert-info'>

View File

@ -32,10 +32,13 @@
<p><i>{{ part.description }}</i></p> <p><i>{{ part.description }}</i></p>
<p> <p>
<div class='btn-group'> <div class='btn-group'>
{% include "qr_button.html" %}
<button type='button' class='btn btn-default btn-glyph' id='toggle-starred' title='Star this part'> <button type='button' class='btn btn-default btn-glyph' id='toggle-starred' title='Star this part'>
<span id='part-star-icon' class='starred-part glyphicon {% if starred %}glyphicon-star{% else %}glyphicon-star-empty{% endif %}'/> <span id='part-star-icon' class='starred-part glyphicon {% if starred %}glyphicon-star{% else %}glyphicon-star-empty{% endif %}'/>
</button> </button>
{% include "qr_button.html" %}
<button type='button' class='btn btn-default btn-glyph' id='price-button' title='Show pricing information'>
<span id='part-price-icon' class='part-price glyphicon glyphicon-usd'/>
</button>
</div> </div>
</p> </p>
<table class='table table-condensed'> <table class='table table-condensed'>
@ -82,6 +85,25 @@
<td>{{ part.allocation_count }}</td> <td>{{ part.allocation_count }}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if part.supplier_count > 0 %}
<tr>
<td>
Price
</td>
<td>
{% 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 %}
<span class='warning-msg'><i>No pricing data avilable</i></span>
{% endif %}
</td>
</tr>
{% endif %}
</table> </table>
</div> </div>
</div> </div>
@ -122,6 +144,15 @@
); );
}); });
$("#price-button").click(function() {
launchModalForm(
"{% url 'part-pricing' part.id %}",
{
submit_text: 'Calculate',
}
);
});
$("#toggle-starred").click(function() { $("#toggle-starred").click(function() {
toggleStar({ toggleStar({
part: {{ part.id }}, part: {{ part.id }},

View File

@ -0,0 +1,88 @@
{% extends "modal_form.html" %}
{% block pre_form_content %}
<div class='alert alert-info alert-block'>
Calculate pricing information for {{ part }}.
</div>
<h4>Quantity</h4>
<table class='table table-striped table-condensed'>
<tr>
<td><b>Part</b></td>
<td colspan='2'>{{ part }}</td>
</tr>
<tr>
<td><b>Quantity</b></td>
<td colspan='2'>{{ quantity }}</td>
</tr>
</table>
{% if part.supplier_count > 0 %}
<h4>Supplier Pricing</h4>
<table class='table table-striped table-condensed'>
{% if min_total_buy_price %}
<tr>
<td><b>Unit Cost</b></td>
<td>Min: {{ min_unit_buy_price }}</td>
<td>Max: {{ max_unit_buy_price }}</td>
</tr>
{% if quantity > 1 %}
<tr>
<td><b>Total Cost</b></td>
<td>Min: {{ min_total_buy_price }}</td>
<td>Max: {{ max_total_buy_price }}</td>
</tr>
{% endif %}
{% else %}
<tr>
<td colspan='3'>
<span class='warning-msg'><i>No supplier pricing available</i></span>
</td>
</tr>
{% endif %}
</table>
{% endif %}
{% if part.bom_count > 0 %}
<h4>BOM Pricing</h4>
<table class='table table-striped table-condensed'>
{% if min_total_bom_price %}
<tr>
<td><b>Unit Cost</b></td>
<td>Min: {{ min_unit_bom_price }}</td>
<td>Max: {{ max_unit_bom_price }}</td>
</tr>
{% if quantity > 1 %}
<tr>
<td><b>Total Cost</b></td>
<td>Min: {{ min_total_bom_price }}</td>
<td>Max: {{ max_total_bom_price }}</td>
</tr>
{% endif %}
{% if part.has_complete_bom_pricing == False %}
<tr>
<td colspan='3'>
<span class='warning-msg'><i>Note: BOM pricing is incomplete for this part</i></span>
</td>
</tr>
{% endif %}
{% else %}
<tr>
<td colspan='3'>
<span class='warning-msg'><i>No BOM pricing available</i></span>
</td>
</tr>
{% endif %}
</table>
{% endif %}
{% if min_unit_buy_price or min_unit_bom_price %}
{% else %}
<div class='alert alert-danger alert-block'>
No pricing information is available for this part.
</div>
{% endif %}
<hr>
{% endblock %}

View File

@ -12,19 +12,6 @@ from django.conf.urls import url, include
from . import views 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<pk>\d+)/', include(supplier_part_detail_urls)),
]
part_attachment_urls = [ part_attachment_urls = [
url('^new/?', views.PartAttachmentCreate.as_view(), name='part-attachment-create'), url('^new/?', views.PartAttachmentCreate.as_view(), name='part-attachment-create'),
url(r'^(?P<pk>\d+)/edit/?', views.PartAttachmentEdit.as_view(), name='part-attachment-edit'), url(r'^(?P<pk>\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'^bom-export/?', views.BomDownload.as_view(), name='bom-export'),
url(r'^validate-bom/', views.BomValidate.as_view(), name='bom-validate'), url(r'^validate-bom/', views.BomValidate.as_view(), name='bom-validate'),
url(r'^duplicate/', views.PartDuplicate.as_view(), name='part-duplicate'), 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'^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'), url(r'^attachments/?', views.PartDetail.as_view(template_name='part/attachments.html'), name='part-attachments'),

View File

@ -12,12 +12,12 @@ from django.views.generic import DetailView, ListView
from django.forms.models import model_to_dict from django.forms.models import model_to_dict
from django.forms import HiddenInput, CheckboxInput from django.forms import HiddenInput, CheckboxInput
from company.models import Company
from .models import PartCategory, Part, PartAttachment from .models import PartCategory, Part, PartAttachment
from .models import BomItem from .models import BomItem
from .models import SupplierPart
from .models import match_part_names from .models import match_part_names
from company.models import SupplierPart
from . import forms as part_forms from . import forms as part_forms
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView 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): class CategoryDetail(DetailView):
""" Detail view for PartCategory """ """ Detail view for PartCategory """
model = PartCategory model = PartCategory
@ -731,81 +805,3 @@ class BomItemDelete(AjaxDeleteView):
ajax_template_name = 'part/bom-delete.html' ajax_template_name = 'part/bom-delete.html'
context_object_name = 'item' context_object_name = 'item'
ajax_form_title = 'Confim BOM item deletion' 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'

View File

@ -3,15 +3,19 @@
} }
.glyphicon { .glyphicon {
font-size: 20px; font-size: 18px;
} }
.glyphicon-small { .glyphicon-small {
font-size: 14px; font-size: 12px;
} }
.starred-part { .starred-part {
color: #ffcc00; color: #ffbb00;
}
.part-price {
color: rgb(13, 245, 25);
} }
/* CSS overrides for treeview */ /* CSS overrides for treeview */

View File

@ -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 "<span class='warning-msg'><i>No pricing information</i></span>";
}
},
});
} }
// Part notes // Part notes

View File

@ -1,6 +1,4 @@
# -*- coding: utf-8 -*- # Generated by Django 2.2 on 2019-05-18 14:04
# Generated by Django 1.11.12 on 2018-04-22 11:53
from __future__ import unicode_literals
from django.conf import settings from django.conf import settings
import django.core.validators import django.core.validators
@ -14,8 +12,8 @@ class Migration(migrations.Migration):
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('part', '0001_initial'),
('company', '0001_initial'), ('company', '0001_initial'),
('part', '0001_initial'),
] ]
operations = [ operations = [
@ -25,30 +23,19 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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)), ('serial', models.PositiveIntegerField(blank=True, help_text='Serial number for this item', null=True)),
('URL', models.URLField(blank=True, max_length=125)), ('URL', models.URLField(blank=True, max_length=125)),
('batch', models.CharField(blank=True, help_text='Batch code for this stock item', max_length=100)), ('batch', models.CharField(blank=True, help_text='Batch code for this stock item', max_length=100, null=True)),
('quantity', models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(0)])), ('quantity', models.PositiveIntegerField(default=1, validators=[django.core.validators.MinValueValidator(0)])),
('updated', models.DateField(auto_now=True)), ('updated', models.DateField(auto_now=True, null=True)),
('stocktake_date', models.DateField(blank=True, null=True)), ('stocktake_date', models.DateField(blank=True, null=True)),
('review_needed', models.BooleanField(default=False)), ('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)])), ('delete_on_deplete', models.BooleanField(default=True, help_text='Delete this Stock Item when stock is depleted')),
('notes', models.TextField(blank=True)), ('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)), ('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')), ('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')), ('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( migrations.CreateModel(
name='StockLocation', name='StockLocation',
fields=[ fields=[
@ -59,34 +46,44 @@ class Migration(migrations.Migration):
], ],
options={ options={
'abstract': False, '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( migrations.AddField(
model_name='stockitem', model_name='stockitem',
name='location', 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( migrations.AddField(
model_name='stockitem', model_name='stockitem',
name='part', 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( migrations.AddField(
model_name='stockitem', model_name='stockitem',
name='stocktake_user', 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( migrations.AddField(
model_name='stockitem', model_name='stockitem',
name='supplier_part', name='supplier_part',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='part.SupplierPart'), 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='stocklocation',
unique_together=set([('name', 'parent')]),
), ),
migrations.AlterUniqueTogether( migrations.AlterUniqueTogether(
name='stockitem', name='stockitem',
unique_together=set([('part', 'serial')]), unique_together={('part', 'serial')},
), ),
] ]

View File

@ -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)]),
),
]

View File

@ -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),
),
]

View File

@ -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)]),
),
]

View File

@ -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)]),
),
]

View File

@ -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),
),
]

View File

@ -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),
),
]

View File

@ -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),
),
]

View File

@ -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'),
),
]

View File

@ -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),
),
]

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -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',
),
]

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -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),
),
]

View File

@ -188,7 +188,7 @@ class StockItem(models.Model):
part = models.ForeignKey('part.Part', on_delete=models.CASCADE, related_name='locations', help_text='Base part') 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') help_text='Select a matching supplier part for this stock item')
location = models.ForeignKey(StockLocation, on_delete=models.DO_NOTHING, location = models.ForeignKey(StockLocation, on_delete=models.DO_NOTHING,