diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 74c9eac71a..d5ff5749ba 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -62,6 +62,7 @@ INSTALLED_APPS = [
'stock.apps.StockConfig',
'company.apps.CompanyConfig',
'build.apps.BuildConfig',
+ 'order.apps.OrderConfig',
# Third part add-ons
'django_filters', # Extended filter functionality
diff --git a/InvenTree/InvenTree/status_codes.py b/InvenTree/InvenTree/status_codes.py
new file mode 100644
index 0000000000..b7b50a58f7
--- /dev/null
+++ b/InvenTree/InvenTree/status_codes.py
@@ -0,0 +1,91 @@
+from django.utils.translation import ugettext as _
+
+
+class StatusCode:
+
+ @classmethod
+ def items(cls):
+ return cls.options.items()
+
+ @classmethod
+ def label(cls, value):
+ """ Return the status code label associated with the provided value """
+ return cls.options.get(value, '')
+
+
+class OrderStatus(StatusCode):
+
+ # Order status codes
+ PENDING = 10 # Order is pending (not yet placed)
+ PLACED = 20 # Order has been placed
+ COMPLETE = 30 # Order has been completed
+ CANCELLED = 40 # Order was cancelled
+ LOST = 50 # Order was lost
+ RETURNED = 60 # Order was returned
+
+ options = {
+ PENDING: _("Pending"),
+ PLACED: _("Placed"),
+ COMPLETE: _("Complete"),
+ CANCELLED: _("Cancelled"),
+ LOST: _("Lost"),
+ RETURNED: _("Returned"),
+ }
+
+ # Open orders
+ OPEN = [
+ PENDING,
+ PLACED,
+ ]
+
+ # Failed orders
+ FAILED = [
+ CANCELLED,
+ LOST,
+ RETURNED
+ ]
+
+
+class StockStatus(StatusCode):
+
+ OK = 10 # Item is OK
+ ATTENTION = 50 # Item requires attention
+ DAMAGED = 55 # Item is damaged
+ DESTROYED = 60 # Item is destroyed
+ LOST = 70 # Item has been lost
+
+ options = {
+ OK: _("OK"),
+ ATTENTION: _("Attention needed"),
+ DAMAGED: _("Damaged"),
+ DESTROYED: _("Destroyed"),
+ LOST: _("Lost"),
+ }
+
+ # The following codes correspond to parts that are 'available'
+ AVAILABLE_CODES = [
+ OK,
+ ATTENTION,
+ DAMAGED
+ ]
+
+
+class BuildStatus(StatusCode):
+
+ # Build status codes
+ PENDING = 10 # Build is pending / active
+ ALLOCATED = 20 # Parts have been removed from stock
+ CANCELLED = 30 # Build was cancelled
+ COMPLETE = 40 # Build is complete
+
+ options = {
+ PENDING: _("Pending"),
+ ALLOCATED: _("Allocated"),
+ CANCELLED: _("Cancelled"),
+ COMPLETE: _("Complete"),
+ }
+
+ ACTIVE_CODES = [
+ PENDING,
+ ALLOCATED
+ ]
diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index 1bffd90229..716ff25767 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -15,10 +15,9 @@ from company.urls import supplier_part_urls
from company.urls import price_break_urls
from part.urls import part_urls
-
from stock.urls import stock_urls
-
from build.urls import build_urls
+from order.urls import order_urls
from part.api import part_api_urls, bom_api_urls
from company.api import company_api_urls
@@ -56,6 +55,7 @@ urlpatterns = [
url(r'^stock/', include(stock_urls)),
url(r'^company/', include(company_urls)),
+ url(r'^order/', include(order_urls)),
url(r'^build/', include(build_urls)),
diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py
index 71f87ffd19..282541d19f 100644
--- a/InvenTree/InvenTree/views.py
+++ b/InvenTree/InvenTree/views.py
@@ -92,6 +92,10 @@ class AjaxMixin(object):
on the client side.
"""
+ # By default, point to the modal_form template
+ # (this can be overridden by a child class)
+ ajax_template_name = 'modal_form.html'
+
ajax_form_action = ''
ajax_form_title = ''
@@ -165,10 +169,6 @@ class AjaxView(AjaxMixin, View):
""" An 'AJAXified' View for displaying an object
"""
- # By default, point to the modal_form template
- # (this can be overridden by a child class)
- ajax_template_name = 'modal_form.html'
-
def post(self, request, *args, **kwargs):
return JsonResponse('', safe=False)
diff --git a/InvenTree/build/migrations/0005_auto_20190604_2217.py b/InvenTree/build/migrations/0005_auto_20190604_2217.py
new file mode 100644
index 0000000000..019399ef12
--- /dev/null
+++ b/InvenTree/build/migrations/0005_auto_20190604_2217.py
@@ -0,0 +1,19 @@
+# Generated by Django 2.2 on 2019-06-04 12:17
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('build', '0004_auto_20190525_2356'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='build',
+ name='part',
+ field=models.ForeignKey(help_text='Select part to build', limit_choices_to={'active': True, 'assembly': True, 'is_template': False}, on_delete=django.db.models.deletion.CASCADE, related_name='builds', to='part.Part'),
+ ),
+ ]
diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py
index f9ffc99953..28c7ecfcd3 100644
--- a/InvenTree/build/models.py
+++ b/InvenTree/build/models.py
@@ -16,6 +16,8 @@ from django.db import models, transaction
from django.db.models import Sum
from django.core.validators import MinValueValidator
+from InvenTree.status_codes import BuildStatus
+
from stock.models import StockItem
from part.models import Part, BomItem
@@ -68,22 +70,9 @@ class Build(models.Model):
validators=[MinValueValidator(1)],
help_text='Number of parts to build'
)
-
- # Build status codes
- PENDING = 10 # Build is pending / active
- ALLOCATED = 20 # Parts have been removed from stock
- CANCELLED = 30 # Build was cancelled
- COMPLETE = 40 # Build is complete
- #: Build status codes
- BUILD_STATUS_CODES = {PENDING: _("Pending"),
- ALLOCATED: _("Allocated"),
- CANCELLED: _("Cancelled"),
- COMPLETE: _("Complete"),
- }
-
- status = models.PositiveIntegerField(default=PENDING,
- choices=BUILD_STATUS_CODES.items(),
+ status = models.PositiveIntegerField(default=BuildStatus.PENDING,
+ choices=BuildStatus.items(),
validators=[MinValueValidator(0)],
help_text='Build status')
@@ -253,7 +242,7 @@ class Build(models.Model):
item.save()
# Finally, mark the build as complete
- self.status = self.COMPLETE
+ self.status = BuildStatus.COMPLETE
self.save()
def getRequiredQuantity(self, part):
@@ -325,14 +314,12 @@ class Build(models.Model):
- HOLDING
"""
- return self.status in [
- self.PENDING,
- ]
+ return self.status in BuildStatus.ACTIVE_CODES
@property
def is_complete(self):
""" Returns True if the build status is COMPLETE """
- return self.status == self.COMPLETE
+ return self.status == BuildStatus.COMPLETE
class BuildItem(models.Model):
diff --git a/InvenTree/build/tests.py b/InvenTree/build/tests.py
index 1918ce9b27..1030a4e198 100644
--- a/InvenTree/build/tests.py
+++ b/InvenTree/build/tests.py
@@ -6,6 +6,8 @@ from django.test import TestCase
from .models import Build
from part.models import Part
+from InvenTree.status_codes import BuildStatus
+
class BuildTestSimple(TestCase):
@@ -14,14 +16,14 @@ class BuildTestSimple(TestCase):
description='Simple description')
Build.objects.create(part=part,
batch='B1',
- status=Build.PENDING,
+ status=BuildStatus.PENDING,
title='Building 7 parts',
quantity=7,
notes='Some simple notes')
Build.objects.create(part=part,
batch='B2',
- status=Build.COMPLETE,
+ status=BuildStatus.COMPLETE,
title='Building 21 parts',
quantity=21,
notes='Some simple notes')
diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py
index ace53ecca6..028546a6d4 100644
--- a/InvenTree/build/views.py
+++ b/InvenTree/build/views.py
@@ -15,6 +15,7 @@ from stock.models import StockLocation, StockItem
from InvenTree.views import AjaxUpdateView, AjaxCreateView, AjaxDeleteView
from InvenTree.helpers import str2bool
+from InvenTree.status_codes import BuildStatus
class BuildIndex(ListView):
@@ -32,10 +33,12 @@ class BuildIndex(ListView):
context = super(BuildIndex, self).get_context_data(**kwargs).copy()
- context['active'] = self.get_queryset().filter(status__in=[Build.PENDING, ])
+ context['BuildStatus'] = BuildStatus
- context['completed'] = self.get_queryset().filter(status=Build.COMPLETE)
- context['cancelled'] = self.get_queryset().filter(status=Build.CANCELLED)
+ context['active'] = self.get_queryset().filter(status__in=BuildStatus.ACTIVE_CODES)
+
+ context['completed'] = self.get_queryset().filter(status=BuildStatus.COMPLETE)
+ context['cancelled'] = self.get_queryset().filter(status=BuildStatus.CANCELLED)
return context
diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py
index 245264900d..68e9cbaf9b 100644
--- a/InvenTree/company/models.py
+++ b/InvenTree/company/models.py
@@ -10,13 +10,16 @@ import os
import math
from django.core.validators import MinValueValidator
+from django.db import models
+from django.db.models import Sum
from django.apps import apps
-from django.db import models
from django.urls import reverse
from django.conf import settings
from django.contrib.staticfiles.templatetags.staticfiles import static
+from InvenTree.status_codes import OrderStatus
+
def rename_company_image(instance, filename):
""" Function to rename a company image after upload
@@ -128,6 +131,28 @@ class Company(models.Model):
stock = apps.get_model('stock', 'StockItem')
return stock.objects.filter(supplier_part__supplier=self.id).count()
+ def outstanding_purchase_orders(self):
+ """ Return purchase orders which are 'outstanding' """
+ return self.purchase_orders.filter(status__in=OrderStatus.OPEN)
+
+ def closed_purchase_orders(self):
+ """ Return purchase orders which are not 'outstanding'
+
+ - Complete
+ - Failed / lost
+ - Returned
+ """
+
+ return self.purchase_orders.exclude(status__in=OrderStatus.OPEN)
+
+ def complete_purchase_orders(self):
+ return self.purchase_orders.filter(status=OrderStatus.COMPLETE)
+
+ def failed_purchase_orders(self):
+ """ Return any purchase orders which were not successful """
+
+ return self.purchase_orders.filter(status__in=OrderStatus.FAILED)
+
class Contact(models.Model):
""" A Contact represents a person who works at a particular company.
@@ -223,6 +248,9 @@ class SupplierPart(models.Model):
@property
def manufacturer_string(self):
+ """ Format a MPN string for this SupplierPart.
+ Concatenates manufacture name and part number.
+ """
items = []
@@ -286,6 +314,37 @@ class SupplierPart(models.Model):
else:
return None
+ def open_orders(self):
+ """ Return a database query for PO line items for this SupplierPart,
+ limited to purchase orders that are open / outstanding.
+ """
+
+ return self.purchase_order_line_items.prefetch_related('order').filter(order__status__in=OrderStatus.OPEN)
+
+ def on_order(self):
+ """ Return the total quantity of items currently on order.
+
+ Subtract partially received stock as appropriate
+ """
+
+ totals = self.open_orders().aggregate(Sum('quantity'), Sum('received'))
+
+ # Quantity on order
+ q = totals.get('quantity__sum', 0)
+
+ # Quantity received
+ r = totals.get('received__sum', 0)
+
+ if q is None or r is None:
+ return 0
+ else:
+ return max(q - r, 0)
+
+ def purchase_orders(self):
+ """ Returns a list of purchase orders relating to this supplier part """
+
+ return [line.order for line in self.purchase_order_line_items.all().prefetch_related('order')]
+
def __str__(self):
s = "{supplier} ({sku})".format(
sku=self.SKU,
diff --git a/InvenTree/company/templates/company/detail.html b/InvenTree/company/templates/company/detail.html
index 8e26d85a89..fa40913f98 100644
--- a/InvenTree/company/templates/company/detail.html
+++ b/InvenTree/company/templates/company/detail.html
@@ -6,7 +6,7 @@
-
Company Details
+ Company Details
diff --git a/InvenTree/company/templates/company/detail_part.html b/InvenTree/company/templates/company/detail_part.html
index ec6f80c652..5e0a28541c 100644
--- a/InvenTree/company/templates/company/detail_part.html
+++ b/InvenTree/company/templates/company/detail_part.html
@@ -4,7 +4,7 @@
{% include 'company/tabs.html' with tab='parts' %}
-Supplier Parts
+Supplier Parts
-
+
+
+Purchase Orders
+{% include "order/po_table.html" with orders=part.purchase_orders %}
{% endblock %}
diff --git a/InvenTree/company/templates/company/po_list.html b/InvenTree/company/templates/company/po_list.html
new file mode 100644
index 0000000000..8b4204996f
--- /dev/null
+++ b/InvenTree/company/templates/company/po_list.html
@@ -0,0 +1,7 @@
+{% for order in orders %}
+
+ {{ order }}
+ {{ order.description }}
+ {% include "order/order_status.html" with order=order %}
+
+{% endfor %}
\ No newline at end of file
diff --git a/InvenTree/company/templates/company/tabs.html b/InvenTree/company/templates/company/tabs.html
index 211b56e1d1..e52f22fab2 100644
--- a/InvenTree/company/templates/company/tabs.html
+++ b/InvenTree/company/templates/company/tabs.html
@@ -9,12 +9,10 @@
Stock {{ company.stock_count }}
- {% if 0 %}
- Purchase Orders
+ Purchase Orders {{ company.purchase_orders.count }}
{% endif %}
- {% endif %}
{% if company.is_customer %}
{% if 0 %}
diff --git a/InvenTree/company/urls.py b/InvenTree/company/urls.py
index 7f617baafd..de4a5e7c5f 100644
--- a/InvenTree/company/urls.py
+++ b/InvenTree/company/urls.py
@@ -17,6 +17,7 @@ company_detail_urls = [
url(r'parts/?', views.CompanyDetail.as_view(template_name='company/detail_part.html'), name='company-detail-parts'),
url(r'stock/?', views.CompanyDetail.as_view(template_name='company/detail_stock.html'), name='company-detail-stock'),
+ url(r'purchase-orders/?', views.CompanyDetail.as_view(template_name='company/detail_purchase_orders.html'), name='company-detail-purchase-orders'),
url(r'thumbnail/?', views.CompanyImage.as_view(), name='company-image'),
diff --git a/InvenTree/company/views.py b/InvenTree/company/views.py
index cce518676a..c3cda22e39 100644
--- a/InvenTree/company/views.py
+++ b/InvenTree/company/views.py
@@ -11,6 +11,7 @@ from django.views.generic import DetailView, ListView
from django.forms import HiddenInput
from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView
+from InvenTree.status_codes import OrderStatus
from .models import Company
from .models import SupplierPart
@@ -57,6 +58,12 @@ class CompanyDetail(DetailView):
queryset = Company.objects.all()
model = Company
+ def get_context_data(self, **kwargs):
+ ctx = super().get_context_data(**kwargs)
+ ctx['OrderStatus'] = OrderStatus
+
+ return ctx
+
class CompanyImage(AjaxUpdateView):
""" View for uploading an image for the Company """
@@ -121,6 +128,12 @@ class SupplierPartDetail(DetailView):
context_object_name = 'part'
queryset = SupplierPart.objects.all()
+ def get_context_data(self, **kwargs):
+ ctx = super().get_context_data(**kwargs)
+ ctx['OrderStatus'] = OrderStatus
+
+ return ctx
+
class SupplierPartEdit(AjaxUpdateView):
""" Update view for editing SupplierPart """
diff --git a/InvenTree/order/__init__.py b/InvenTree/order/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/InvenTree/order/admin.py b/InvenTree/order/admin.py
new file mode 100644
index 0000000000..bbd95cd42a
--- /dev/null
+++ b/InvenTree/order/admin.py
@@ -0,0 +1,31 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.contrib import admin
+
+from .models import PurchaseOrder, PurchaseOrderLineItem
+
+
+class PurchaseOrderAdmin(admin.ModelAdmin):
+
+ list_display = (
+ 'reference',
+ 'supplier',
+ 'status',
+ 'description',
+ 'creation_date'
+ )
+
+
+class PurchaseOrderLineItemAdmin(admin.ModelAdmin):
+
+ list_display = (
+ 'order',
+ 'part',
+ 'quantity',
+ 'reference'
+ )
+
+
+admin.site.register(PurchaseOrder, PurchaseOrderAdmin)
+admin.site.register(PurchaseOrderLineItem, PurchaseOrderLineItemAdmin)
diff --git a/InvenTree/order/apps.py b/InvenTree/order/apps.py
new file mode 100644
index 0000000000..821e6d872c
--- /dev/null
+++ b/InvenTree/order/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class OrderConfig(AppConfig):
+ name = 'order'
diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py
new file mode 100644
index 0000000000..08e9a6f740
--- /dev/null
+++ b/InvenTree/order/forms.py
@@ -0,0 +1,51 @@
+"""
+Django Forms for interacting with Order objects
+"""
+
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django import forms
+
+from InvenTree.forms import HelperForm
+
+from .models import PurchaseOrder, PurchaseOrderLineItem
+
+
+class IssuePurchaseOrderForm(HelperForm):
+
+ confirm = forms.BooleanField(required=False, help_text='Place order')
+
+ class Meta:
+ model = PurchaseOrder
+ fields = [
+ 'confirm',
+ ]
+
+
+class EditPurchaseOrderForm(HelperForm):
+ """ Form for editing a PurchaseOrder object """
+
+ class Meta:
+ model = PurchaseOrder
+ fields = [
+ 'reference',
+ 'supplier',
+ 'description',
+ 'URL',
+ 'notes'
+ ]
+
+
+class EditPurchaseOrderLineItemForm(HelperForm):
+ """ Form for editing a PurchaseOrderLineItem object """
+
+ class Meta:
+ model = PurchaseOrderLineItem
+ fields = [
+ 'order',
+ 'part',
+ 'quantity',
+ 'reference',
+ 'received'
+ ]
diff --git a/InvenTree/order/migrations/0001_initial.py b/InvenTree/order/migrations/0001_initial.py
new file mode 100644
index 0000000000..642b321d47
--- /dev/null
+++ b/InvenTree/order/migrations/0001_initial.py
@@ -0,0 +1,48 @@
+# Generated by Django 2.2 on 2019-06-04 12:17
+
+from django.conf import settings
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('company', '0005_auto_20190525_2356'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='PurchaseOrder',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('reference', models.CharField(help_text='Order reference', max_length=64, unique=True)),
+ ('description', models.CharField(help_text='Order description', max_length=250)),
+ ('creation_date', models.DateField(auto_now=True)),
+ ('issue_date', models.DateField(blank=True, null=True)),
+ ('notes', models.TextField(blank=True, help_text='Order notes')),
+ ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
+ ('supplier', models.ForeignKey(help_text='Company', on_delete=django.db.models.deletion.CASCADE, related_name='Orders', to='company.Company')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ migrations.CreateModel(
+ name='PurchaseOrderLineItem',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('quantity', models.PositiveIntegerField(default=1, help_text='Item quantity', validators=[django.core.validators.MinValueValidator(0)])),
+ ('reference', models.CharField(blank=True, help_text='Line item reference', max_length=100)),
+ ('received', models.PositiveIntegerField(default=0, help_text='Number of items received')),
+ ('order', models.ForeignKey(help_text='Purchase Order', on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='order.PurchaseOrder')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ ]
diff --git a/InvenTree/order/migrations/0002_auto_20190604_2224.py b/InvenTree/order/migrations/0002_auto_20190604_2224.py
new file mode 100644
index 0000000000..2565eae2a2
--- /dev/null
+++ b/InvenTree/order/migrations/0002_auto_20190604_2224.py
@@ -0,0 +1,29 @@
+# Generated by Django 2.2 on 2019-06-04 12:24
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('order', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='purchaseorder',
+ name='URL',
+ field=models.URLField(blank=True, help_text='Link to external page'),
+ ),
+ migrations.AlterField(
+ model_name='purchaseorder',
+ name='description',
+ field=models.CharField(blank=True, help_text='Order description', max_length=250),
+ ),
+ migrations.AlterField(
+ model_name='purchaseorder',
+ name='supplier',
+ field=models.ForeignKey(help_text='Company', limit_choices_to={'is_supplier': True}, on_delete=django.db.models.deletion.CASCADE, related_name='Orders', to='company.Company'),
+ ),
+ ]
diff --git a/InvenTree/order/migrations/0003_auto_20190604_2226.py b/InvenTree/order/migrations/0003_auto_20190604_2226.py
new file mode 100644
index 0000000000..612166c2f7
--- /dev/null
+++ b/InvenTree/order/migrations/0003_auto_20190604_2226.py
@@ -0,0 +1,19 @@
+# Generated by Django 2.2 on 2019-06-04 12:26
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('order', '0002_auto_20190604_2224'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='purchaseorder',
+ name='supplier',
+ field=models.ForeignKey(help_text='Company', limit_choices_to={'is_supplier': True}, on_delete=django.db.models.deletion.CASCADE, related_name='purchase_orders', to='company.Company'),
+ ),
+ ]
diff --git a/InvenTree/order/migrations/0004_purchaseorder_status.py b/InvenTree/order/migrations/0004_purchaseorder_status.py
new file mode 100644
index 0000000000..d1ad90eb38
--- /dev/null
+++ b/InvenTree/order/migrations/0004_purchaseorder_status.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.2 on 2019-06-04 12:52
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('order', '0003_auto_20190604_2226'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='purchaseorder',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Placed'), (30, 'Complete'), (40, 'Cancelled'), (50, 'Lost'), (60, 'Returned')], default=10, help_text='Order status'),
+ ),
+ ]
diff --git a/InvenTree/order/migrations/0005_purchaseorderlineitem_part.py b/InvenTree/order/migrations/0005_purchaseorderlineitem_part.py
new file mode 100644
index 0000000000..61f0944156
--- /dev/null
+++ b/InvenTree/order/migrations/0005_purchaseorderlineitem_part.py
@@ -0,0 +1,20 @@
+# Generated by Django 2.2 on 2019-06-05 10:36
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('company', '0005_auto_20190525_2356'),
+ ('order', '0004_purchaseorder_status'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='purchaseorderlineitem',
+ name='part',
+ field=models.ForeignKey(blank=True, help_text='Supplier part', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='company.SupplierPart'),
+ ),
+ ]
diff --git a/InvenTree/order/migrations/0006_auto_20190605_2056.py b/InvenTree/order/migrations/0006_auto_20190605_2056.py
new file mode 100644
index 0000000000..e938343d01
--- /dev/null
+++ b/InvenTree/order/migrations/0006_auto_20190605_2056.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.2 on 2019-06-05 10:56
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('company', '0005_auto_20190525_2356'),
+ ('order', '0005_purchaseorderlineitem_part'),
+ ]
+
+ operations = [
+ migrations.AlterUniqueTogether(
+ name='purchaseorderlineitem',
+ unique_together={('order', 'part')},
+ ),
+ ]
diff --git a/InvenTree/order/migrations/0007_auto_20190605_2138.py b/InvenTree/order/migrations/0007_auto_20190605_2138.py
new file mode 100644
index 0000000000..ce2119f258
--- /dev/null
+++ b/InvenTree/order/migrations/0007_auto_20190605_2138.py
@@ -0,0 +1,19 @@
+# Generated by Django 2.2 on 2019-06-05 11:38
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('order', '0006_auto_20190605_2056'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='purchaseorderlineitem',
+ name='part',
+ field=models.ForeignKey(blank=True, help_text='Supplier part', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_line_items', to='company.SupplierPart'),
+ ),
+ ]
diff --git a/InvenTree/order/migrations/0008_auto_20190605_2140.py b/InvenTree/order/migrations/0008_auto_20190605_2140.py
new file mode 100644
index 0000000000..688c8cf15c
--- /dev/null
+++ b/InvenTree/order/migrations/0008_auto_20190605_2140.py
@@ -0,0 +1,19 @@
+# Generated by Django 2.2 on 2019-06-05 11:40
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('order', '0007_auto_20190605_2138'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='purchaseorderlineitem',
+ name='part',
+ field=models.ForeignKey(blank=True, help_text='Supplier part', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='purchase_order_line_items', to='company.SupplierPart'),
+ ),
+ ]
diff --git a/InvenTree/order/migrations/0009_auto_20190606_2133.py b/InvenTree/order/migrations/0009_auto_20190606_2133.py
new file mode 100644
index 0000000000..0cadfe87fe
--- /dev/null
+++ b/InvenTree/order/migrations/0009_auto_20190606_2133.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.2 on 2019-06-06 11:33
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('order', '0008_auto_20190605_2140'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='purchaseorder',
+ name='description',
+ field=models.CharField(help_text='Order description', max_length=250),
+ ),
+ ]
diff --git a/InvenTree/order/migrations/__init__.py b/InvenTree/order/migrations/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py
new file mode 100644
index 0000000000..7ca2e942fd
--- /dev/null
+++ b/InvenTree/order/models.py
@@ -0,0 +1,150 @@
+"""
+Order model definitions
+"""
+
+# -*- coding: utf-8 -*-
+
+from django.db import models
+from django.core.validators import MinValueValidator
+from django.contrib.auth.models import User
+from django.urls import reverse
+from django.utils.translation import ugettext as _
+
+from datetime import datetime
+
+from company.models import Company, SupplierPart
+
+from InvenTree.status_codes import OrderStatus
+
+
+class Order(models.Model):
+ """ Abstract model for an order.
+
+ Instances of this class:
+
+ - PuchaseOrder
+
+ Attributes:
+ reference: Unique order number / reference / code
+ description: Long form description (required)
+ notes: Extra note field (optional)
+ creation_date: Automatic date of order creation
+ created_by: User who created this order (automatically captured)
+ issue_date: Date the order was issued
+
+ """
+
+ ORDER_PREFIX = ""
+
+ def __str__(self):
+ el = []
+
+ if self.ORDER_PREFIX:
+ el.append(self.ORDER_PREFIX)
+
+ el.append(self.reference)
+
+ return " ".join(el)
+
+ class Meta:
+ abstract = True
+
+ reference = models.CharField(unique=True, max_length=64, blank=False, help_text=_('Order reference'))
+
+ description = models.CharField(max_length=250, help_text=_('Order description'))
+
+ URL = models.URLField(blank=True, help_text=_('Link to external page'))
+
+ creation_date = models.DateField(auto_now=True, editable=False)
+
+ status = models.PositiveIntegerField(default=OrderStatus.PENDING, choices=OrderStatus.items(),
+ help_text='Order status')
+
+ created_by = models.ForeignKey(User,
+ on_delete=models.SET_NULL,
+ blank=True, null=True,
+ related_name='+'
+ )
+
+ issue_date = models.DateField(blank=True, null=True)
+
+ notes = models.TextField(blank=True, help_text=_('Order notes'))
+
+ def place_order(self):
+ """ Marks the order as PLACED. Order must be currently PENDING. """
+
+ if self.status == OrderStatus.PENDING:
+ self.status = OrderStatus.PLACED
+ self.issue_date = datetime.now().date()
+ self.save()
+
+
+class PurchaseOrder(Order):
+ """ A PurchaseOrder represents goods shipped inwards from an external supplier.
+
+ Attributes:
+ supplier: Reference to the company supplying the goods in the order
+
+ """
+
+ ORDER_PREFIX = "PO"
+
+ supplier = models.ForeignKey(
+ Company, on_delete=models.CASCADE,
+ limit_choices_to={
+ 'is_supplier': True,
+ },
+ related_name='purchase_orders',
+ help_text=_('Company')
+ )
+
+ def get_absolute_url(self):
+ return reverse('purchase-order-detail', kwargs={'pk': self.id})
+
+
+class OrderLineItem(models.Model):
+ """ Abstract model for an order line item
+
+ Attributes:
+ quantity: Number of items
+ note: Annotation for the item
+
+ """
+
+ class Meta:
+ abstract = True
+
+ quantity = models.PositiveIntegerField(validators=[MinValueValidator(0)], default=1, help_text=_('Item quantity'))
+
+ reference = models.CharField(max_length=100, blank=True, help_text=_('Line item reference'))
+
+
+class PurchaseOrderLineItem(OrderLineItem):
+ """ Model for a purchase order line item.
+
+ Attributes:
+ order: Reference to a PurchaseOrder object
+
+ """
+
+ class Meta:
+ unique_together = (
+ ('order', 'part')
+ )
+
+ order = models.ForeignKey(
+ PurchaseOrder, on_delete=models.CASCADE,
+ related_name='lines',
+ help_text=_('Purchase Order')
+ )
+
+ # TODO - Function callback for when the SupplierPart is deleted?
+
+ part = models.ForeignKey(
+ SupplierPart, on_delete=models.SET_NULL,
+ blank=True, null=True,
+ related_name='purchase_order_line_items',
+ help_text=_("Supplier part"),
+ )
+
+ received = models.PositiveIntegerField(default=0, help_text=_('Number of items received'))
diff --git a/InvenTree/order/templates/order/order_issue.html b/InvenTree/order/templates/order/order_issue.html
new file mode 100644
index 0000000000..ad80fc5a0d
--- /dev/null
+++ b/InvenTree/order/templates/order/order_issue.html
@@ -0,0 +1,7 @@
+{% extends "modal_form.html" %}
+
+{% block pre_form_content %}
+
+After placing this purchase order, line items will no longer be editable.
+
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/order/templates/order/order_status.html b/InvenTree/order/templates/order/order_status.html
new file mode 100644
index 0000000000..c9e13cac24
--- /dev/null
+++ b/InvenTree/order/templates/order/order_status.html
@@ -0,0 +1,13 @@
+{% if order.status == OrderStatus.PENDING %}
+
+{% elif order.status == OrderStatus.PLACED %}
+
+{% elif order.status == OrderStatus.COMPLETE %}
+
+{% elif order.status == OrderStatus.CANCELLED or order.status == OrderStatus.RETURNED %}
+
+{% else %}
+
+{% endif %}
+{{ order.get_status_display }}
+
\ No newline at end of file
diff --git a/InvenTree/order/templates/order/po_table.html b/InvenTree/order/templates/order/po_table.html
new file mode 100644
index 0000000000..0c1149470f
--- /dev/null
+++ b/InvenTree/order/templates/order/po_table.html
@@ -0,0 +1,16 @@
+
+
+ Company
+ Order Reference
+ Description
+ Status
+
+ {% for order in orders %}
+
+ {% include "hover_image.html" with image=order.supplier.image hover=True %}{{ order.supplier.name }}
+ {{ order }}
+ {{ order.description }}
+ {% include "order/order_status.html" %}
+
+ {% endfor %}
+
\ No newline at end of file
diff --git a/InvenTree/order/templates/order/po_table_collapse.html b/InvenTree/order/templates/order/po_table_collapse.html
new file mode 100644
index 0000000000..886e14ce97
--- /dev/null
+++ b/InvenTree/order/templates/order/po_table_collapse.html
@@ -0,0 +1,11 @@
+{% extends "collapse.html" %}
+
+{% load static %}
+
+{% block collapse_title %}
+{{ title }}
+{% endblock %}
+
+{% block collapse_content %}
+{% include "order/po_table.html" %}
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/order/templates/order/purchase_order_detail.html b/InvenTree/order/templates/order/purchase_order_detail.html
new file mode 100644
index 0000000000..b6751e044d
--- /dev/null
+++ b/InvenTree/order/templates/order/purchase_order_detail.html
@@ -0,0 +1,154 @@
+{% extends "base.html" %}
+
+{% load static %}
+
+{% block page_title %}
+InvenTree | {{ order }}
+{% endblock %}
+
+{% block content %}
+
+
+
+
Purchase Order Details
+
+
+ Supplier
+ {{ order.supplier }}
+
+
+ Status
+ {% include "order/order_status.html" %}
+
+
+ Created
+ {{ order.creation_date }}
+
+
+ Created By
+ {{ order.created_by }}
+
+ {% if order.issue_date %}
+
+ Issued
+ {{ order.issue_date }}
+
+ {% endif %}
+
+
+
+
+
+
+
+ Edit Order
+ {% if order.status == OrderStatus.PENDING and order.lines.count > 0 %}
+ Place Order
+ {% endif %}
+
+
+Order Items
+
+{% if order.status == OrderStatus.PENDING %}
+Add Line Item
+{% endif %}
+
+
+
+ Line
+ Part
+ Order Code
+ Reference
+ Quantity
+ Received
+
+ {% for line in order.lines.all %}
+
+ {{ forloop.counter }}
+ {% if line.part %}
+ {{ line.part.part.full_name }}
+ {{ line.part.SKU }}
+ {% else %}
+ Warning: Part has been deleted.
+ {% endif %}
+ {{ line.reference }}
+ {{ line.quantity }}
+ {{ line.received }}
+
+ {% endfor %}
+
+
+{% if order.notes %}
+
+
+
Notes
+
{{ order.notes }}
+
+{% endif %}
+
+{% endblock %}
+
+{% block js_ready %}
+
+$("#place-order").click(function() {
+ launchModalForm("{% url 'purchase-order-issue' order.id %}",
+ {
+ reload: true,
+ });
+});
+
+$("#edit-order").click(function() {
+ launchModalForm("{% url 'purchase-order-edit' order.id %}",
+ {
+ reload: true,
+ }
+ );
+});
+
+{% if order.status == OrderStatus.PENDING %}
+$('#new-po-line').click(function() {
+ launchModalForm("{% url 'po-line-item-create' %}",
+ {
+ reload: true,
+ data: {
+ order: {{ order.id }},
+ },
+ secondary: [
+ {
+ field: 'part',
+ label: 'New Supplier Part',
+ title: 'Create new supplier part',
+ url: "{% url 'supplier-part-create' %}",
+ data: {
+ supplier: {{ order.supplier.id }},
+ },
+ },
+ ],
+ }
+ );
+});
+{% endif %}
+
+$("#po-lines-table").bootstrapTable({
+});
+
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/order/templates/order/purchase_orders.html b/InvenTree/order/templates/order/purchase_orders.html
new file mode 100644
index 0000000000..0c7ac717cd
--- /dev/null
+++ b/InvenTree/order/templates/order/purchase_orders.html
@@ -0,0 +1,37 @@
+{% extends "base.html" %}
+
+{% load static %}
+
+{% block page_title %}
+InvenTree | Purchase Orders
+{% endblock %}
+
+{% block content %}
+
+
+
+
Purchase Orders
+
+
+
+ New Purchase Order
+
+
+
+
+{% include "order/po_table.html" %}
+
+{% endblock %}
+
+{% block js_ready %}
+{{ block.super }}
+
+$("#po-create").click(function() {
+ launchModalForm("{% url 'purchase-order-create' %}",
+ {
+ reload: true,
+ }
+ );
+});
+
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/order/tests.py b/InvenTree/order/tests.py
new file mode 100644
index 0000000000..ce215e567d
--- /dev/null
+++ b/InvenTree/order/tests.py
@@ -0,0 +1 @@
+# TODO - Implement tests for the order app
diff --git a/InvenTree/order/urls.py b/InvenTree/order/urls.py
new file mode 100644
index 0000000000..20379edb76
--- /dev/null
+++ b/InvenTree/order/urls.py
@@ -0,0 +1,40 @@
+"""
+URL lookup for the Order app. Provides URL endpoints for:
+
+- List view of Purchase Orders
+- Detail view of Purchase Orders
+"""
+
+from django.conf.urls import url, include
+
+from . import views
+
+purchase_order_detail_urls = [
+
+ url(r'^edit/?', views.PurchaseOrderEdit.as_view(), name='purchase-order-edit'),
+ url(r'^issue/?', views.PurchaseOrderIssue.as_view(), name='purchase-order-issue'),
+
+ url(r'^.*$', views.PurchaseOrderDetail.as_view(), name='purchase-order-detail'),
+]
+
+po_line_urls = [
+
+ url(r'^new/', views.POLineItemCreate.as_view(), name='po-line-item-create'),
+]
+
+purchase_order_urls = [
+
+ url(r'^new/', views.PurchaseOrderCreate.as_view(), name='purchase-order-create'),
+
+ # Display detail view for a single purchase order
+ url(r'^(?P\d+)/', include(purchase_order_detail_urls)),
+
+ url(r'^line/', include(po_line_urls)),
+
+ # Display complete list of purchase orders
+ url(r'^.*$', views.PurchaseOrderIndex.as_view(), name='purchase-order-index'),
+]
+
+order_urls = [
+ url(r'^purchase-order/', include(purchase_order_urls)),
+]
diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py
new file mode 100644
index 0000000000..718b26c98c
--- /dev/null
+++ b/InvenTree/order/views.py
@@ -0,0 +1,236 @@
+"""
+Django views for interacting with Order app
+"""
+
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.utils.translation import ugettext as _
+from django.views.generic import DetailView, ListView
+from django.forms import HiddenInput
+
+from .models import PurchaseOrder, PurchaseOrderLineItem
+from company.models import Company, SupplierPart
+
+from . import forms as order_forms
+
+from InvenTree.views import AjaxCreateView, AjaxUpdateView
+from InvenTree.helpers import str2bool
+
+from InvenTree.status_codes import OrderStatus
+
+
+class PurchaseOrderIndex(ListView):
+ """ List view for all purchase orders """
+
+ model = PurchaseOrder
+ template_name = 'order/purchase_orders.html'
+ context_object_name = 'orders'
+
+ def get_queryset(self):
+ """ Retrieve the list of purchase orders,
+ ensure that the most recent ones are returned first. """
+
+ queryset = PurchaseOrder.objects.all().order_by('-creation_date')
+
+ return queryset
+
+ def get_context_data(self, **kwargs):
+ ctx = super().get_context_data(**kwargs)
+
+ ctx['OrderStatus'] = OrderStatus
+
+ return ctx
+
+
+class PurchaseOrderDetail(DetailView):
+ """ Detail view for a PurchaseOrder object """
+
+ context_object_name = 'order'
+ queryset = PurchaseOrder.objects.all().prefetch_related('lines')
+ template_name = 'order/purchase_order_detail.html'
+
+ def get_context_data(self, **kwargs):
+ ctx = super().get_context_data(**kwargs)
+
+ ctx['OrderStatus'] = OrderStatus
+
+ return ctx
+
+
+class PurchaseOrderCreate(AjaxCreateView):
+ """ View for creating a new PurchaseOrder object using a modal form """
+
+ model = PurchaseOrder
+ ajax_form_title = "Create Purchase Order"
+ form_class = order_forms.EditPurchaseOrderForm
+
+ def get_initial(self):
+ initials = super().get_initial().copy()
+
+ initials['status'] = OrderStatus.PENDING
+
+ supplier_id = self.request.GET.get('supplier', None)
+
+ if supplier_id:
+ try:
+ supplier = Company.objects.get(id=supplier_id)
+ initials['supplier'] = supplier
+ except Company.DoesNotExist:
+ pass
+
+ return initials
+
+
+class PurchaseOrderEdit(AjaxUpdateView):
+ """ View for editing a PurchaseOrder using a modal form """
+
+ model = PurchaseOrder
+ ajax_form_title = 'Edit Purchase Order'
+ form_class = order_forms.EditPurchaseOrderForm
+
+ def get_form(self):
+
+ form = super(AjaxUpdateView, self).get_form()
+
+ order = self.get_object()
+
+ # Prevent user from editing supplier if there are already lines in the order
+ if order.lines.count() > 0 or not order.status == OrderStatus.PENDING:
+ form.fields['supplier'].widget = HiddenInput()
+
+ return form
+
+
+class PurchaseOrderIssue(AjaxUpdateView):
+ """ View for changing a purchase order from 'PENDING' to 'ISSUED' """
+
+ model = PurchaseOrder
+ ajax_form_title = 'Issue Order'
+ ajax_template_name = "order/order_issue.html"
+ form_class = order_forms.IssuePurchaseOrderForm
+
+ def post(self, request, *args, **kwargs):
+ """ Mark the purchase order as 'PLACED' """
+
+ order = self.get_object()
+ form = self.get_form()
+
+ confirm = str2bool(request.POST.get('confirm', False))
+
+ valid = False
+
+ if not confirm:
+ form.errors['confirm'] = [_('Confirm order placement')]
+ else:
+ valid = True
+
+ data = {
+ 'form_valid': valid,
+ }
+
+ if valid:
+ order.place_order()
+
+ return self.renderJsonResponse(request, form, data)
+
+
+class POLineItemCreate(AjaxCreateView):
+ """ AJAX view for creating a new PurchaseOrderLineItem object
+ """
+
+ model = PurchaseOrderLineItem
+ context_object_name = 'line'
+ form_class = order_forms.EditPurchaseOrderLineItemForm
+ ajax_form_title = 'Add Line Item'
+
+ def post(self, request, *arg, **kwargs):
+
+ self.request = request
+
+ form = self.get_form()
+
+ valid = form.is_valid()
+
+ part_id = form['part'].value()
+
+ try:
+ SupplierPart.objects.get(id=part_id)
+ except (SupplierPart.DoesNotExist, ValueError):
+ valid = False
+ form.errors['part'] = [_('This field is required')]
+
+ data = {
+ 'form_valid': valid,
+ }
+
+ if valid:
+ self.object = form.save()
+
+ data['pk'] = self.object.pk
+ data['text'] = str(self.object)
+ else:
+ self.object = None
+
+ return self.renderJsonResponse(request, form, data,)
+
+ def get_form(self):
+ """ Limit choice options based on the selected order, etc
+ """
+
+ form = super().get_form()
+
+ order_id = form['order'].value()
+
+ try:
+ order = PurchaseOrder.objects.get(id=order_id)
+
+ query = form.fields['part'].queryset
+
+ # Only allow parts from the selected supplier
+ query = query.filter(supplier=order.supplier.id)
+
+ exclude = []
+
+ for line in order.lines.all():
+ if line.part and line.part.id not in exclude:
+ exclude.append(line.part.id)
+
+ # Remove parts that are already in the order
+ query = query.exclude(id__in=exclude)
+
+ form.fields['part'].queryset = query
+ form.fields['order'].widget = HiddenInput()
+ except PurchaseOrder.DoesNotExist:
+ pass
+
+ return form
+
+ def get_initial(self):
+ """ Extract initial data for the line item.
+
+ - The 'order' will be passed as a query parameter
+ - Use this to set the 'order' field and limit the options for 'part'
+ """
+
+ initials = super().get_initial().copy()
+
+ order_id = self.request.GET.get('order', None)
+
+ if order_id:
+ try:
+ order = PurchaseOrder.objects.get(id=order_id)
+ initials['order'] = order
+
+ except PurchaseOrder.DoesNotExist:
+ pass
+
+ return initials
+
+
+class POLineItemEdit(AjaxUpdateView):
+
+ model = PurchaseOrderLineItem
+ form_class = order_forms.EditPurchaseOrderLineItemForm
+ ajax_template_name = 'modal_form.html'
+ ajax_form_action = 'Edit Line Item'
diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index f311aea1f1..67a82a9504 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -33,6 +33,8 @@ from InvenTree import helpers
from InvenTree import validators
from InvenTree.models import InvenTreeTree
+from InvenTree.status_codes import BuildStatus, StockStatus, OrderStatus
+
from company.models import SupplierPart
@@ -425,7 +427,7 @@ class Part(models.Model):
then we need to restock.
"""
- return (self.total_stock - self.allocation_count) < self.minimum_stock
+ return (self.total_stock + self.on_order - self.allocation_count) < self.minimum_stock
@property
def can_build(self):
@@ -454,14 +456,14 @@ class Part(models.Model):
Builds marked as 'complete' or 'cancelled' are ignored
"""
- return [b for b in self.builds.all() if b.is_active]
+ return self.builds.filter(status__in=BuildStatus.ACTIVE_CODES)
@property
def inactive_builds(self):
""" Return a list of inactive builds
"""
- return [b for b in self.builds.all() if not b.is_active]
+ return self.builds.exclude(status__in=BuildStatus.ACTIVE_CODES)
@property
def quantity_being_built(self):
@@ -531,7 +533,7 @@ class Part(models.Model):
if self.is_template:
total = sum([variant.total_stock for variant in self.variants.all()])
else:
- total = self.stock_entries.aggregate(total=Sum('quantity'))['total']
+ total = self.stock_entries.filter(status__in=StockStatus.AVAILABLE_CODES).aggregate(total=Sum('quantity'))['total']
if total:
return total
@@ -792,6 +794,34 @@ class Part(models.Model):
return n
+ def purchase_orders(self):
+ """ Return a list of purchase orders which reference this part """
+
+ orders = []
+
+ for part in self.supplier_parts.all().prefetch_related('purchase_order_line_items'):
+ for order in part.purchase_orders():
+ if order not in orders:
+ orders.append(order)
+
+ return orders
+
+ def open_purchase_orders(self):
+ """ Return a list of open purchase orders against this part """
+
+ return [order for order in self.purchase_orders() if order.status in OrderStatus.OPEN]
+
+ def closed_purchase_orders(self):
+ """ Return a list of closed purchase orders against this part """
+
+ return [order for order in self.purchase_orders() if order.status not in OrderStatus.OPEN]
+
+ @property
+ def on_order(self):
+ """ Return the total number of items on order for this part. """
+
+ return sum([part.on_order() for part in self.supplier_parts.all().prefetch_related('purchase_order_line_items')])
+
def attach_file(instance, filename):
""" Function for storing a file for a PartAttachment
diff --git a/InvenTree/part/templates/part/orders.html b/InvenTree/part/templates/part/orders.html
new file mode 100644
index 0000000000..8c9f0c3cee
--- /dev/null
+++ b/InvenTree/part/templates/part/orders.html
@@ -0,0 +1,23 @@
+{% extends "part/part_base.html" %}
+{% load static %}
+
+{% block details %}
+
+{% include 'part/tabs.html' with tab='orders' %}
+
+
+
+
Open Part Orders
+
+
+
+
+
+{% include "order/po_table.html" with orders=part.open_purchase_orders %}
+
+{% if part.closed_purchase_orders|length > 0 %}
+Closed Orders
+{% include "order/po_table.html" with orders=part.closed_purchase_orders %}
+{% endif %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html
index 9c68cfe69f..2661497c74 100644
--- a/InvenTree/part/templates/part/part_base.html
+++ b/InvenTree/part/templates/part/part_base.html
@@ -96,6 +96,12 @@
{{ part.allocation_count }}
{% endif %}
+ {% if part.on_order > 0 %}
+
+ On Order
+ {{ part.on_order }}
+
+ {% endif %}
diff --git a/InvenTree/part/templates/part/tabs.html b/InvenTree/part/templates/part/tabs.html
index dd5034040d..c7dbb2418f 100644
--- a/InvenTree/part/templates/part/tabs.html
+++ b/InvenTree/part/templates/part/tabs.html
@@ -25,11 +25,17 @@
Used In{% if part.used_in_count > 0 %}{{ part.used_in_count }} {% endif %}
{% endif %}
- {% if part.purchaseable and part.is_template == False %}
+ {% if part.purchaseable %}
+ {% if part.is_template == False %}
Suppliers
{{ part.supplier_count }}
-
+
+
+ {% endif %}
+
+ Purchase Orders {{ part.purchase_orders|length }}
+
{% endif %}
{% if part.trackable and 0 %}
diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py
index 0147cd4d07..e8a0175503 100644
--- a/InvenTree/part/urls.py
+++ b/InvenTree/part/urls.py
@@ -34,6 +34,7 @@ part_detail_urls = [
url(r'^build/?', views.PartDetail.as_view(template_name='part/build.html'), name='part-build'),
url(r'^used/?', views.PartDetail.as_view(template_name='part/used_in.html'), name='part-used-in'),
url(r'^suppliers/?', views.PartDetail.as_view(template_name='part/supplier.html'), name='part-suppliers'),
+ url(r'^orders/?', views.PartDetail.as_view(template_name='part/orders.html'), name='part-orders'),
url(r'^track/?', views.PartDetail.as_view(template_name='part/track.html'), name='part-track'),
url(r'^attachments/?', views.PartDetail.as_view(template_name='part/attachments.html'), name='part-attachments'),
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index 4f61036f95..9f8b8652bd 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -24,6 +24,7 @@ from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDelete
from InvenTree.views import QRCodeView
from InvenTree.helpers import DownloadFile, str2bool
+from InvenTree.status_codes import OrderStatus
class PartIndex(ListView):
@@ -446,6 +447,8 @@ class PartDetail(DetailView):
context['starred'] = part.isStarredBy(self.request.user)
+ context['OrderStatus'] = OrderStatus
+
return context
diff --git a/InvenTree/static/css/inventree.css b/InvenTree/static/css/inventree.css
index da7d38d250..3bef992d34 100644
--- a/InvenTree/static/css/inventree.css
+++ b/InvenTree/static/css/inventree.css
@@ -38,7 +38,6 @@
/* Extra label styles */
.label-large {
- padding: 5px;
margin: 3px;
font-size: 100%;
}
@@ -297,6 +296,10 @@
margin-bottom: 5px;
}
+.panel-body {
+ padding: 10px;
+}
+
.panel-group .panel {
border-radius: 2px;
}
diff --git a/InvenTree/static/script/inventree/stock.js b/InvenTree/static/script/inventree/stock.js
index facbaa65f5..ae3ab55a7e 100644
--- a/InvenTree/static/script/inventree/stock.js
+++ b/InvenTree/static/script/inventree/stock.js
@@ -195,6 +195,18 @@ function loadStockTable(table, options) {
stock.push(item.pk);
});
+ // Buttons for launching secondary modals
+ var secondary = [];
+
+ if (action == 'move') {
+ secondary.push({
+ field: 'destination',
+ label: 'New Location',
+ title: 'Create new location',
+ url: "/stock/location/new/",
+ });
+ }
+
launchModalForm("/stock/adjust/",
{
data: {
@@ -204,6 +216,7 @@ function loadStockTable(table, options) {
success: function() {
$("#stock-table").bootstrapTable('refresh');
},
+ secondary: secondary,
}
);
}
diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py
index b1c746cdd6..092724407f 100644
--- a/InvenTree/stock/api.py
+++ b/InvenTree/stock/api.py
@@ -20,6 +20,7 @@ from .serializers import StockTrackingSerializer
from InvenTree.views import TreeSerializer
from InvenTree.helpers import str2bool
+from InvenTree.status_codes import StockStatus
import os
@@ -311,7 +312,7 @@ class StockList(generics.ListCreateAPIView):
else:
item['location__path'] = None
- item['status_text'] = StockItem.ITEM_STATUS_CODES[item['status']]
+ item['status_text'] = StockStatus.label(item['status'])
return Response(data)
diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py
index 4434741186..b9773f9d77 100644
--- a/InvenTree/stock/models.py
+++ b/InvenTree/stock/models.py
@@ -19,6 +19,7 @@ from django.dispatch import receiver
from datetime import datetime
from InvenTree import helpers
+from InvenTree.status_codes import StockStatus
from InvenTree.models import InvenTreeTree
from part.models import Part
@@ -93,7 +94,7 @@ class StockItem(models.Model):
stocktake_user: User that performed the most recent stocktake
review_needed: Flag if StockItem needs review
delete_on_deplete: If True, StockItem will be deleted when the stock level gets to zero
- status: Status of this StockItem (ref: ITEM_STATUS_CODES)
+ status: Status of this StockItem (ref: InvenTree.status_codes.StockStatus)
notes: Extra notes field
infinite: If True this StockItem can never be exhausted
"""
@@ -256,23 +257,9 @@ class StockItem(models.Model):
delete_on_deplete = models.BooleanField(default=True, help_text='Delete this Stock Item when stock is depleted')
- ITEM_OK = 10
- ITEM_ATTENTION = 50
- ITEM_DAMAGED = 55
- ITEM_DESTROYED = 60
- ITEM_LOST = 70
-
- ITEM_STATUS_CODES = {
- ITEM_OK: _("OK"),
- ITEM_ATTENTION: _("Attention needed"),
- ITEM_DAMAGED: _("Damaged"),
- ITEM_DESTROYED: _("Destroyed"),
- ITEM_LOST: _("Lost")
- }
-
status = models.PositiveIntegerField(
- default=ITEM_OK,
- choices=ITEM_STATUS_CODES.items(),
+ default=StockStatus.OK,
+ choices=StockStatus.items(),
validators=[MinValueValidator(0)])
notes = models.CharField(max_length=250, blank=True, help_text='Stock Item Notes')
diff --git a/InvenTree/templates/InvenTree/search.html b/InvenTree/templates/InvenTree/search.html
index 70915a49fd..a31e4ed1a8 100644
--- a/InvenTree/templates/InvenTree/search.html
+++ b/InvenTree/templates/InvenTree/search.html
@@ -25,6 +25,8 @@ InvenTree | Search Results
{% include "InvenTree/search_parts.html" with collapse_id='parts' %}
+{% include "InvenTree/search_company.html" with collapse_id='companies' %}
+
{% include "InvenTree/search_supplier_parts.html" with collapse_id='supplier_parts' %}
{% include "InvenTree/search_stock_location.html" with collapse_id="locations" %}
@@ -77,6 +79,8 @@ InvenTree | Search Results
onSearchResults('#part-results-table', '#part-result-count');
+ onSearchResults('#company-results-table', '#company-result-count');
+
onSearchResults('#supplier-part-results-table', '#supplier-part-result-count');
$("#category-results-table").bootstrapTable({
@@ -130,6 +134,29 @@ InvenTree | Search Results
}
);
+ $("#company-results-table").bootstrapTable({
+ url: "{% url 'api-company-list' %}",
+ queryParams: {
+ search: "{{ query }}",
+ },
+ pagination: true,
+ pageSize: 25,
+ search: true,
+ columns: [
+ {
+ field: 'name',
+ title: 'Name',
+ formatter: function(value, row, index, field) {
+ return imageHoverIcon(row.image) + renderLink(value, row.url);
+ },
+ },
+ {
+ field: 'description',
+ title: 'Description',
+ },
+ ]
+ });
+
$("#supplier-part-results-table").bootstrapTable({
url: "{% url 'api-part-supplier-list' %}",
queryParams: {
diff --git a/InvenTree/templates/InvenTree/search_company.html b/InvenTree/templates/InvenTree/search_company.html
new file mode 100644
index 0000000000..776f661819
--- /dev/null
+++ b/InvenTree/templates/InvenTree/search_company.html
@@ -0,0 +1,14 @@
+{% extends "collapse.html" %}
+
+{% block collapse_title %}
+Companies
+{% endblock %}
+
+{% block collapse_heading %}
+{% include "InvenTree/searching.html" %}
+{% endblock %}
+
+{% block collapse_content %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/templates/build_status.html b/InvenTree/templates/build_status.html
index 793e3ea231..b18b81e16f 100644
--- a/InvenTree/templates/build_status.html
+++ b/InvenTree/templates/build_status.html
@@ -1,10 +1,10 @@
-{% if build.status == build.PENDING %}
+{% if build.status == BuildStatus.PENDING %}
-{% elif build.status == build.ALLOCATED %}
+{% elif build.status == BuildStatus.ALLOCATED %}
-{% elif build.status == build.CANCELLED %}
+{% elif build.status == BuildStatus.CANCELLED %}
-{% elif build.status == build.COMPLETE %}
+{% elif build.status == BuildStatus.COMPLETE %}
{% endif %}
{{ build.get_status_display }}
diff --git a/InvenTree/templates/navbar.html b/InvenTree/templates/navbar.html
index e7785abcd5..2118bd2197 100644
--- a/InvenTree/templates/navbar.html
+++ b/InvenTree/templates/navbar.html
@@ -10,6 +10,7 @@
Stock
Build
Suppliers
+ Orders