@@ -101,4 +102,20 @@
url: "{% url 'api-part-supplier-list' %}"
});
+ $("#multi-part-order").click(function() {
+ var selections = $("#part-table").bootstrapTable("getSelections");
+
+ var parts = [];
+
+ selections.forEach(function(item) {
+ parts.push(item.part);
+ });
+
+ launchModalForm("/order/purchase-order/order-parts/", {
+ data: {
+ parts: parts,
+ },
+ });
+ });
+
{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py
index 08e9a6f740..c7942e3549 100644
--- a/InvenTree/order/forms.py
+++ b/InvenTree/order/forms.py
@@ -47,5 +47,5 @@ class EditPurchaseOrderLineItemForm(HelperForm):
'part',
'quantity',
'reference',
- 'received'
+ 'notes',
]
diff --git a/InvenTree/order/migrations/0010_purchaseorderlineitem_notes.py b/InvenTree/order/migrations/0010_purchaseorderlineitem_notes.py
new file mode 100644
index 0000000000..a38a564041
--- /dev/null
+++ b/InvenTree/order/migrations/0010_purchaseorderlineitem_notes.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.2.2 on 2019-06-13 11:54
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('order', '0009_auto_20190606_2133'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='purchaseorderlineitem',
+ name='notes',
+ field=models.CharField(blank=True, help_text='Line item notes', max_length=500),
+ ),
+ ]
diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py
index 7ca2e942fd..e2a0efc36d 100644
--- a/InvenTree/order/models.py
+++ b/InvenTree/order/models.py
@@ -6,10 +6,12 @@ Order model definitions
from django.db import models
from django.core.validators import MinValueValidator
+from django.core.exceptions import ValidationError
from django.contrib.auth.models import User
from django.urls import reverse
from django.utils.translation import ugettext as _
+import tablib
from datetime import datetime
from company.models import Company, SupplierPart
@@ -98,9 +100,101 @@ class PurchaseOrder(Order):
help_text=_('Company')
)
+ def export_to_file(self, **kwargs):
+ """ Export order information to external file """
+
+ file_format = kwargs.get('format', 'csv').lower()
+
+ data = tablib.Dataset(headers=[
+ 'Line',
+ 'Part',
+ 'Description',
+ 'Manufacturer',
+ 'MPN',
+ 'Order Code',
+ 'Quantity',
+ 'Received',
+ 'Reference',
+ 'Notes',
+ ])
+
+ idx = 0
+
+ for item in self.lines.all():
+
+ line = []
+
+ line.append(idx)
+
+ if item.part:
+ line.append(item.part.part.name)
+ line.append(item.part.part.description)
+
+ line.append(item.part.manufacturer)
+ line.append(item.part.MPN)
+ line.append(item.part.SKU)
+
+ else:
+ line += [[] * 5]
+
+ line.append(item.quantity)
+ line.append(item.received)
+ line.append(item.reference)
+ line.append(item.notes)
+
+ idx += 1
+
+ data.append(line)
+
+ return data.export(file_format)
+
def get_absolute_url(self):
return reverse('purchase-order-detail', kwargs={'pk': self.id})
+ def add_line_item(self, supplier_part, quantity, group=True, reference=''):
+ """ Add a new line item to this purchase order.
+ This function will check that:
+
+ * The supplier part matches the supplier specified for this purchase order
+ * The quantity is greater than zero
+
+ Args:
+ supplier_part - The supplier_part to add
+ quantity - The number of items to add
+ group - If True, this new quantity will be added to an existing line item for the same supplier_part (if it exists)
+ """
+
+ try:
+ quantity = int(quantity)
+ if quantity <= 0:
+ raise ValidationError({
+ 'quantity': _("Quantity must be greater than zero")})
+ except ValueError:
+ raise ValidationError({'quantity': _("Invalid quantity provided")})
+
+ if not supplier_part.supplier == self.supplier:
+ raise ValidationError({'supplier': _("Part supplier must match PO supplier")})
+
+ if group:
+ # Check if there is already a matching line item
+ matches = PurchaseOrderLineItem.objects.filter(part=supplier_part)
+
+ if matches.count() > 0:
+ line = matches.first()
+
+ line.quantity += quantity
+ line.save()
+
+ return
+
+ line = PurchaseOrderLineItem(
+ order=self,
+ part=supplier_part,
+ quantity=quantity,
+ reference=reference)
+
+ line.save()
+
class OrderLineItem(models.Model):
""" Abstract model for an order line item
@@ -117,6 +211,8 @@ class OrderLineItem(models.Model):
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'))
+
+ notes = models.CharField(max_length=500, blank=True, help_text=_('Line item notes'))
class PurchaseOrderLineItem(OrderLineItem):
@@ -132,6 +228,13 @@ class PurchaseOrderLineItem(OrderLineItem):
('order', 'part')
)
+ def __str__(self):
+ return "{n} x {part} from {supplier} (for {po})".format(
+ n=self.quantity,
+ part=self.part.SKU if self.part else 'unknown part',
+ supplier=self.order.supplier.name,
+ po=self.order)
+
order = models.ForeignKey(
PurchaseOrder, on_delete=models.CASCADE,
related_name='lines',
diff --git a/InvenTree/order/templates/order/order_wizard/select_parts.html b/InvenTree/order/templates/order/order_wizard/select_parts.html
new file mode 100644
index 0000000000..f8ed6e2a93
--- /dev/null
+++ b/InvenTree/order/templates/order/order_wizard/select_parts.html
@@ -0,0 +1,74 @@
+{% extends "modal_form.html" %}
+
+{% block form %}
+
+
+ Step 1 of 2 - Select Parts
+
+
+{% if parts|length > 0 %}
+
+ Select suppliers for {{ parts|length }} parts.
+
+{% else %}
+
+ No purchaseable parts selected.
+
+{% endif %}
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/order/templates/order/order_wizard/select_pos.html b/InvenTree/order/templates/order/order_wizard/select_pos.html
new file mode 100644
index 0000000000..f97ba95ee2
--- /dev/null
+++ b/InvenTree/order/templates/order/order_wizard/select_pos.html
@@ -0,0 +1,73 @@
+{% extends "modal_form.html" %}
+
+{% block form %}
+
+
+ Step 2 of 2 - Select Purchase Orders
+
+
+
+ Select existing purchase orders, or create new orders.
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/order/templates/order/po_lineitem_delete.html b/InvenTree/order/templates/order/po_lineitem_delete.html
new file mode 100644
index 0000000000..3264fea625
--- /dev/null
+++ b/InvenTree/order/templates/order/po_lineitem_delete.html
@@ -0,0 +1,5 @@
+{% extends "modal_delete_form.html" %}
+
+{% block pre_form_content %}
+Are you sure you wish to delete this line item?
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/order/templates/order/po_table.html b/InvenTree/order/templates/order/po_table.html
index 0c1149470f..cfd4811d97 100644
--- a/InvenTree/order/templates/order/po_table.html
+++ b/InvenTree/order/templates/order/po_table.html
@@ -4,6 +4,7 @@
+ {% if not order.status == OrderStatus.PENDING %}
{{ line.received }}
+ {% endif %}
+
+ {{ line.notes }}
+
+ {% if order.status == OrderStatus.PENDING %}
+
+
+
+
+
+
+ {% endif %}
{% endfor %}
@@ -124,6 +153,10 @@ $("#edit-order").click(function() {
);
});
+$("#export-order").click(function() {
+ location.href = "{% url 'purchase-order-export' order.id %}";
+});
+
{% if order.status == OrderStatus.PENDING %}
$('#new-po-line').click(function() {
launchModalForm("{% url 'po-line-item-create' %}",
@@ -151,4 +184,5 @@ $('#new-po-line').click(function() {
$("#po-lines-table").bootstrapTable({
});
+
{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/order/urls.py b/InvenTree/order/urls.py
index 20379edb76..695628bad0 100644
--- a/InvenTree/order/urls.py
+++ b/InvenTree/order/urls.py
@@ -14,18 +14,30 @@ 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'^export/?', views.PurchaseOrderExport.as_view(), name='purchase-order-export'),
+
url(r'^.*$', views.PurchaseOrderDetail.as_view(), name='purchase-order-detail'),
]
+po_line_item_detail_urls = [
+
+ url(r'^edit/', views.POLineItemEdit.as_view(), name='po-line-item-edit'),
+ url(r'^delete/', views.POLineItemDelete.as_view(), name='po-line-item-delete'),
+]
+
po_line_urls = [
url(r'^new/', views.POLineItemCreate.as_view(), name='po-line-item-create'),
+
+ url(r'^(?P\d+)/', include(po_line_item_detail_urls)),
]
purchase_order_urls = [
url(r'^new/', views.PurchaseOrderCreate.as_view(), name='purchase-order-create'),
+ url(r'^order-parts/', views.OrderParts.as_view(), name='order-parts'),
+
# Display detail view for a single purchase order
url(r'^(?P\d+)/', include(purchase_order_detail_urls)),
diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py
index 718b26c98c..578021b2cb 100644
--- a/InvenTree/order/views.py
+++ b/InvenTree/order/views.py
@@ -5,20 +5,28 @@ Django views for interacting with Order app
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
+from django.shortcuts import get_object_or_404
from django.utils.translation import ugettext as _
from django.views.generic import DetailView, ListView
from django.forms import HiddenInput
+import logging
+
from .models import PurchaseOrder, PurchaseOrderLineItem
+from build.models import Build
from company.models import Company, SupplierPart
+from stock.models import StockItem
+from part.models import Part
from . import forms as order_forms
-from InvenTree.views import AjaxCreateView, AjaxUpdateView
-from InvenTree.helpers import str2bool
+from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
+from InvenTree.helpers import DownloadFile, str2bool
from InvenTree.status_codes import OrderStatus
+logger = logging.getLogger(__name__)
+
class PurchaseOrderIndex(ListView):
""" List view for all purchase orders """
@@ -135,6 +143,331 @@ class PurchaseOrderIssue(AjaxUpdateView):
return self.renderJsonResponse(request, form, data)
+class PurchaseOrderExport(AjaxView):
+ """ File download for a purchase order
+
+ - File format can be optionally passed as a query param e.g. ?format=CSV
+ - Default file format is CSV
+ """
+
+ model = PurchaseOrder
+
+ def get(self, request, *args, **kwargs):
+
+ order = get_object_or_404(PurchaseOrder, pk=self.kwargs['pk'])
+
+ export_format = request.GET.get('format', 'csv')
+
+ filename = str(order) + '.' + export_format
+
+ filedata = order.export_to_file(format=export_format)
+
+ return DownloadFile(filedata, filename)
+
+
+class OrderParts(AjaxView):
+ """ View for adding various SupplierPart items to a Purchase Order.
+
+ SupplierParts can be selected from a variety of 'sources':
+
+ - ?supplier_parts[]= -> Direct list of SupplierPart objects
+ - ?parts[]= -> List of base Part objects (user must then select supplier parts)
+ - ?stock[]= -> List of StockItem objects (user must select supplier parts)
+ - ?build= -> A Build object (user must select parts, then supplier parts)
+
+ """
+
+ ajax_form_title = "Order Parts"
+ ajax_template_name = 'order/order_wizard/select_parts.html'
+
+ # List of Parts we wish to order
+ parts = []
+ suppliers = []
+
+ def get_context_data(self):
+
+ ctx = {}
+
+ ctx['parts'] = sorted(self.parts, key=lambda part: int(part.order_quantity), reverse=True)
+ ctx['suppliers'] = self.suppliers
+
+ return ctx
+
+ def get_suppliers(self):
+ """ Calculates a list of suppliers which the user will need to create POs for.
+ This is calculated AFTER the user finishes selecting the parts to order.
+ Crucially, get_parts() must be called before get_suppliers()
+ """
+
+ suppliers = {}
+
+ for supplier in self.suppliers:
+ supplier.order_items = []
+ suppliers[supplier.name] = supplier
+
+ for part in self.parts:
+ supplier_part_id = part.order_supplier
+
+ try:
+ supplier = SupplierPart.objects.get(pk=supplier_part_id).supplier
+ except SupplierPart.DoesNotExist:
+ continue
+
+ if supplier.name not in suppliers:
+ supplier.order_items = []
+ supplier.selected_purchase_order = None
+ suppliers[supplier.name] = supplier
+
+ suppliers[supplier.name].order_items.append(part)
+
+ self.suppliers = [suppliers[key] for key in suppliers.keys()]
+
+ def get_parts(self):
+ """ Determine which parts the user wishes to order.
+ This is performed on the initial GET request.
+ """
+
+ self.parts = []
+
+ part_ids = set()
+
+ # User has passed a list of stock items
+ if 'stock[]' in self.request.GET:
+
+ stock_id_list = self.request.GET.getlist('stock[]')
+
+ """ Get a list of all the parts associated with the stock items.
+ - Base part must be purchaseable.
+ - Return a set of corresponding Part IDs
+ """
+ stock_items = StockItem.objects.filter(
+ part__purchaseable=True,
+ id__in=stock_id_list)
+
+ for item in stock_items:
+ part_ids.add(item.part.id)
+
+ # User has passed a single Part ID
+ elif 'part' in self.request.GET:
+ try:
+ part_id = self.request.GET.get('part')
+ part = Part.objects.get(id=part_id)
+
+ part_ids.add(part.id)
+
+ except Part.DoesNotExist:
+ pass
+
+ # User has passed a list of part ID values
+ elif 'parts[]' in self.request.GET:
+ part_id_list = self.request.GET.getlist('parts[]')
+
+ parts = Part.objects.filter(
+ purchaseable=True,
+ id__in=part_id_list)
+
+ for part in parts:
+ part_ids.add(part.id)
+
+ # User has provided a Build ID
+ elif 'build' in self.request.GET:
+ build_id = self.request.GET.get('build')
+ try:
+ build = Build.objects.get(id=build_id)
+
+ parts = build.part.required_parts()
+
+ for part in parts:
+ # If ordering from a Build page, ignore parts that we have enough of
+ if part.quantity_to_order <= 0:
+ continue
+ part_ids.add(part.id)
+ except Build.DoesNotExist:
+ pass
+
+ # Create the list of parts
+ for id in part_ids:
+ try:
+ part = Part.objects.get(id=id)
+ # Pre-fill the 'order quantity' value
+ part.order_quantity = part.quantity_to_order
+
+ default_supplier = part.get_default_supplier()
+
+ if default_supplier:
+ part.order_supplier = default_supplier.id
+ else:
+ part.order_supplier = None
+ except Part.DoesNotExist:
+ continue
+
+ self.parts.append(part)
+
+ def get(self, request, *args, **kwargs):
+
+ self.request = request
+
+ self.get_parts()
+
+ return self.renderJsonResponse(request)
+
+ def post(self, request, *args, **kwargs):
+ """ Handle the POST action for part selection.
+
+ - Validates each part / quantity / supplier / etc
+
+ Part selection form contains the following fields for each part:
+
+ - supplier- : The ID of the selected supplier
+ - quantity- : The quantity to add to the order
+ """
+
+ self.request = request
+
+ self.parts = []
+ self.suppliers = []
+
+ # Any errors for the part selection form?
+ part_errors = False
+ supplier_errors = False
+
+ # Extract part information from the form
+ for item in self.request.POST:
+
+ if item.startswith('part-supplier-'):
+
+ pk = item.replace('part-supplier-', '')
+
+ # Check that the part actually exists
+ try:
+ part = Part.objects.get(id=pk)
+ except (Part.DoesNotExist, ValueError):
+ continue
+
+ supplier_part_id = self.request.POST[item]
+
+ quantity = self.request.POST.get('part-quantity-' + str(pk), 0)
+
+ # Ensure a valid supplier has been passed
+ try:
+ supplier_part = SupplierPart.objects.get(id=supplier_part_id)
+ except (SupplierPart.DoesNotExist, ValueError):
+ supplier_part = None
+
+ # Ensure a valid quantity is passed
+ try:
+ quantity = int(quantity)
+
+ # Eliminate lines where the quantity is zero
+ if quantity == 0:
+ continue
+ except ValueError:
+ quantity = part.quantity_to_order
+
+ part.order_supplier = supplier_part.id if supplier_part else None
+ part.order_quantity = quantity
+
+ self.parts.append(part)
+
+ if supplier_part is None:
+ part_errors = True
+
+ elif quantity < 0:
+ part_errors = True
+
+ elif item.startswith('purchase-order-'):
+ # Which purchase order is selected for a given supplier?
+ pk = item.replace('purchase-order-', '')
+
+ # Check that the Supplier actually exists
+ try:
+ supplier = Company.objects.get(id=pk)
+ except Company.DoesNotExist:
+ # Skip this item
+ continue
+
+ purchase_order_id = self.request.POST[item]
+
+ # Ensure that a valid purchase order has been passed
+ try:
+ purchase_order = PurchaseOrder.objects.get(pk=purchase_order_id)
+ except (PurchaseOrder.DoesNotExist, ValueError):
+ purchase_order = None
+
+ supplier.selected_purchase_order = purchase_order.id if purchase_order else None
+
+ self.suppliers.append(supplier)
+
+ if supplier.selected_purchase_order is None:
+ supplier_errors = True
+
+ form_step = request.POST.get('form_step')
+
+ # Map parts to suppliers
+ self.get_suppliers()
+
+ valid = False
+
+ if form_step == 'select_parts':
+ # No errors? Proceed to PO selection form
+ if part_errors is False:
+ self.ajax_template_name = 'order/order_wizard/select_pos.html'
+
+ else:
+ self.ajax_template_name = 'order/order_wizard/select_parts.html'
+
+ elif form_step == 'select_purchase_orders':
+
+ self.ajax_template_name = 'order/order_wizard/select_pos.html'
+
+ valid = part_errors is False and supplier_errors is False
+
+ # Form wizard is complete! Add items to purchase orders
+ if valid:
+ self.order_items()
+
+ data = {
+ 'form_valid': valid,
+ 'success': 'Ordered {n} parts'.format(n=len(self.parts))
+ }
+
+ return self.renderJsonResponse(self.request, data=data)
+
+ def order_items(self):
+ """ Add the selected items to the purchase orders. """
+
+ for supplier in self.suppliers:
+
+ # Check that the purchase order does actually exist
+ try:
+ order = PurchaseOrder.objects.get(pk=supplier.selected_purchase_order)
+ except PurchaseOrder.DoesNotExist:
+ logger.critical('Could not add items to purchase order {po} - Order does not exist'.format(po=supplier.selected_purchase_order))
+ continue
+
+ for item in supplier.order_items:
+
+ # Ensure that the quantity is valid
+ try:
+ quantity = int(item.order_quantity)
+ if quantity <= 0:
+ continue
+ except ValueError:
+ logger.warning("Did not add part to purchase order - incorrect quantity")
+ continue
+
+ # Check that the supplier part does actually exist
+ try:
+ supplier_part = SupplierPart.objects.get(pk=item.order_supplier)
+ except SupplierPart.DoesNotExist:
+ logger.critical("Could not add part '{part}' to purchase order - selected supplier part '{sp}' does not exist.".format(
+ part=item,
+ sp=item.order_supplier))
+ continue
+
+ order.add_line_item(supplier_part, quantity)
+
+
class POLineItemCreate(AjaxCreateView):
""" AJAX view for creating a new PurchaseOrderLineItem object
"""
@@ -229,8 +562,32 @@ class POLineItemCreate(AjaxCreateView):
class POLineItemEdit(AjaxUpdateView):
+ """ View for editing a PurchaseOrderLineItem object in a modal form.
+ """
model = PurchaseOrderLineItem
form_class = order_forms.EditPurchaseOrderLineItemForm
ajax_template_name = 'modal_form.html'
- ajax_form_action = 'Edit Line Item'
+ ajax_form_title = 'Edit Line Item'
+
+ def get_form(self):
+ form = super().get_form()
+
+ # Prevent user from editing order once line item is assigned
+ form.fields.pop('order')
+
+ return form
+
+
+class POLineItemDelete(AjaxDeleteView):
+ """ View for deleting a PurchaseOrderLineItem object in a modal form
+ """
+
+ model = PurchaseOrderLineItem
+ ajax_form_title = 'Delete Line Item'
+ ajax_template_name = 'order/po_lineitem_delete.html'
+
+ def get_data(self):
+ return {
+ 'danger': 'Deleted line item',
+ }
diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index 67a82a9504..38acae3360 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -339,7 +339,7 @@ class Part(models.Model):
"""
if self.default_supplier:
- return self.default_suppliers
+ return self.default_supplier
if self.supplier_count == 1:
return self.supplier_parts.first()
@@ -410,6 +410,26 @@ class Part(models.Model):
return max(total, 0)
+ @property
+ def quantity_to_order(self):
+ """ Return the quantity needing to be ordered for this part. """
+
+ required = -1 * self.net_stock
+ return max(required, 0)
+
+ @property
+ def net_stock(self):
+ """ Return the 'net' stock. It takes into account:
+
+ - Stock on hand (total_stock)
+ - Stock on order (on_order)
+ - Stock allocated (allocation_count)
+
+ This number (unlike 'available_stock') can be negative.
+ """
+
+ return self.total_stock - self.allocation_count + self.on_order
+
def isStarredBy(self, user):
""" Return True if this part has been starred by a particular user """
diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py
index 224e41cd97..37f5f1db7a 100644
--- a/InvenTree/part/serializers.py
+++ b/InvenTree/part/serializers.py
@@ -65,6 +65,8 @@ class PartSerializer(serializers.ModelSerializer):
image_url = serializers.CharField(source='get_image_url', read_only=True)
category_name = serializers.CharField(source='category_path', read_only=True)
+ allocated_stock = serializers.IntegerField(source='allocation_count', read_only=True)
+
@staticmethod
def setup_eager_loading(queryset):
queryset = queryset.prefetch_related('category')
@@ -91,7 +93,8 @@ class PartSerializer(serializers.ModelSerializer):
'keywords',
'URL',
'total_stock',
- # 'available_stock',
+ 'allocated_stock',
+ 'on_order',
'units',
'trackable',
'assembly',
diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html
index 2d76b08ac9..bbe449d217 100644
--- a/InvenTree/part/templates/part/category.html
+++ b/InvenTree/part/templates/part/category.html
@@ -49,6 +49,7 @@
+ {% include "hover_image.html" with image=part.image hover=True %}
+ {{ part.full_name }}
+
{{ part.description }}
-
{{ part.allocation_count }}
{{ part.total_stock }}
{{ part.on_order }}
-
{{ part.available_stock }}
+
{{ part.allocation_count }}
+
{{ part.net_stock }}
{% endfor %}
\ No newline at end of file
diff --git a/InvenTree/templates/stock_table.html b/InvenTree/templates/stock_table.html
index acdd371d08..aa07f99959 100644
--- a/InvenTree/templates/stock_table.html
+++ b/InvenTree/templates/stock_table.html
@@ -9,7 +9,8 @@