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

diff --git a/InvenTree/company/templates/company/detail_purchase_orders.html b/InvenTree/company/templates/company/detail_purchase_orders.html new file mode 100644 index 0000000000..f2f3b8ceb3 --- /dev/null +++ b/InvenTree/company/templates/company/detail_purchase_orders.html @@ -0,0 +1,37 @@ +{% extends "company/company_base.html" %} +{% load static %} +{% block details %} + +{% include 'company/tabs.html' with tab='po' %} + +

Open Purchase Orders

+ +
+
+ +
+
+ +{% include "order/po_table.html" with orders=company.outstanding_purchase_orders.all %} + +{% if company.closed_purchase_orders.count > 0 %} +{% include "order/po_table_collapse.html" with title="Closed Orders" orders=company.closed_purchase_orders.all %} +{% endif %} + +{% endblock %} + +{% block js_ready %} +{{ block.super }} + +$("#po-create").click(function() { + launchModalForm("{% url 'purchase-order-create' %}", + { + data: { + supplier: {{ company.id }}, + }, + follow: true, + } + ); +}); + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/company/templates/company/detail_stock.html b/InvenTree/company/templates/company/detail_stock.html index b3682043dd..108342b2b1 100644 --- a/InvenTree/company/templates/company/detail_stock.html +++ b/InvenTree/company/templates/company/detail_stock.html @@ -5,7 +5,7 @@ {% include "company/tabs.html" with tab='stock' %} -

Supplier Stock

+

Supplier Stock

{% include "stock_table.html" %} diff --git a/InvenTree/company/templates/company/order_status.html b/InvenTree/company/templates/company/order_status.html deleted file mode 100644 index 49ccc7f170..0000000000 --- a/InvenTree/company/templates/company/order_status.html +++ /dev/null @@ -1,13 +0,0 @@ -{% if order.status == order.PENDING %} - -{% elif order.status == order.PLACED %} - -{% elif order.status == order.RECEIVED %} - -{% elif order.status == order.CANCELLED %} - -{% else %} - -{% endif %} -{{ order.get_status_display }} - \ No newline at end of file diff --git a/InvenTree/company/templates/company/orders.html b/InvenTree/company/templates/company/orders.html deleted file mode 100644 index 519035f5c6..0000000000 --- a/InvenTree/company/templates/company/orders.html +++ /dev/null @@ -1,31 +0,0 @@ -{% extends "supplier/supplier_base.html" %} - -{% block details %} - -{% include "supplier/tabs.html" with tab='order' %} - -

Supplier Orders

- - - - - - - - -{% for order in supplier.orders.all %} - - - - - - -{% endfor %} -
ReferenceIssuedDeliveryStatus
{{ order.internal_ref }}{% if order.issued_date %}{{ order.issued_date }}{% endif %}{% if order.delivery_date %}{{ order.delivery_date }}{% endif %}{% include "supplier/order_status.html" with order=order %}
- -
- - - - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/company/templates/company/partdetail.html b/InvenTree/company/templates/company/partdetail.html index dd43d0c5e4..681ba25074 100644 --- a/InvenTree/company/templates/company/partdetail.html +++ b/InvenTree/company/templates/company/partdetail.html @@ -101,7 +101,10 @@ InvenTree | {{ company.name }} - 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 @@ + + + + + + + + {% for order in orders %} + + + + + + + {% endfor %} +
CompanyOrder ReferenceDescriptionStatus
{% include "hover_image.html" with image=order.supplier.image hover=True %}{{ order.supplier.name }}{{ order }}{{ order.description }}{% include "order/order_status.html" %}
\ 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 %} +
+
+
+
+ +
+
+

{{ order }}

+

{{ order.description }}

+ {% if order.URL %} + {{ order.URL }} + {% endif %} +
+
+
+
+

Purchase Order Details

+ + + + + + + + + + + + + + + + + + {% if order.issue_date %} + + + + + {% endif %} +
Supplier{{ order.supplier }}
Status{% include "order/order_status.html" %}
Created{{ order.creation_date }}
Created By{{ order.created_by }}
Issued{{ order.issue_date }}
+
+
+ +
+ +
+ + {% if order.status == OrderStatus.PENDING and order.lines.count > 0 %} + + {% endif %} +
+ +

Order Items

+ +{% if order.status == OrderStatus.PENDING %} + +{% endif %} + + + + + + + + + + + {% for line in order.lines.all %} + + + {% if line.part %} + + + {% else %} + + {% endif %} + + + + + {% endfor %} +
LinePartOrder CodeReferenceQuantityReceived
{{ forloop.counter }}{{ line.part.part.full_name }}{{ line.part.SKU }}Warning: Part has been deleted.{{ line.reference }}{{ line.quantity }}{{ line.received }}
+ +{% 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

+
+
+
+ +
+
+
+ +{% 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