diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py
index 68e9cbaf9b..9d09557f17 100644
--- a/InvenTree/company/models.py
+++ b/InvenTree/company/models.py
@@ -135,6 +135,10 @@ class Company(models.Model):
""" Return purchase orders which are 'outstanding' """
return self.purchase_orders.filter(status__in=OrderStatus.OPEN)
+ def pending_purchase_orders(self):
+ """ Return purchase orders which are PENDING (not yet issued) """
+ return self.purchase_orders.filter(status=OrderStatus.PENDING)
+
def closed_purchase_orders(self):
""" Return purchase orders which are not 'outstanding'
diff --git a/InvenTree/order/migrations/0011_auto_20190615_1928.py b/InvenTree/order/migrations/0011_auto_20190615_1928.py
new file mode 100644
index 0000000000..cf6f1f61dc
--- /dev/null
+++ b/InvenTree/order/migrations/0011_auto_20190615_1928.py
@@ -0,0 +1,26 @@
+# Generated by Django 2.2.2 on 2019-06-15 09:28
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('order', '0010_purchaseorderlineitem_notes'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='purchaseorder',
+ name='complete_date',
+ field=models.DateField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='purchaseorder',
+ name='received_by',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
+ ),
+ ]
diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py
index e2a0efc36d..bc58a2afa3 100644
--- a/InvenTree/order/models.py
+++ b/InvenTree/order/models.py
@@ -4,7 +4,7 @@ Order model definitions
# -*- coding: utf-8 -*-
-from django.db import models
+from django.db import models, transaction
from django.core.validators import MinValueValidator
from django.core.exceptions import ValidationError
from django.contrib.auth.models import User
@@ -14,6 +14,7 @@ from django.utils.translation import ugettext as _
import tablib
from datetime import datetime
+from stock.models import StockItem
from company.models import Company, SupplierPart
from InvenTree.status_codes import OrderStatus
@@ -33,6 +34,7 @@ class Order(models.Model):
creation_date: Automatic date of order creation
created_by: User who created this order (automatically captured)
issue_date: Date the order was issued
+ complete_date: Date the order was completed
"""
@@ -70,6 +72,8 @@ class Order(models.Model):
issue_date = models.DateField(blank=True, null=True)
+ complete_date = models.DateField(blank=True, null=True)
+
notes = models.TextField(blank=True, help_text=_('Order notes'))
def place_order(self):
@@ -80,13 +84,21 @@ class Order(models.Model):
self.issue_date = datetime.now().date()
self.save()
+ def complete_order(self):
+ """ Marks the order as COMPLETE. Order must be currently PLACED. """
+
+ if self.status == OrderStatus.PLACED:
+ self.status = OrderStatus.COMPLETE
+ self.complete_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
-
+ received_by: User that received the goods
"""
ORDER_PREFIX = "PO"
@@ -100,6 +112,13 @@ class PurchaseOrder(Order):
help_text=_('Company')
)
+ received_by = models.ForeignKey(
+ User,
+ on_delete=models.SET_NULL,
+ blank=True, null=True,
+ related_name='+'
+ )
+
def export_to_file(self, **kwargs):
""" Export order information to external file """
@@ -151,6 +170,7 @@ class PurchaseOrder(Order):
def get_absolute_url(self):
return reverse('purchase-order-detail', kwargs={'pk': self.id})
+ @transaction.atomic
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:
@@ -195,6 +215,44 @@ class PurchaseOrder(Order):
line.save()
+ def pending_line_items(self):
+ """ Return a list of pending line items for this order.
+ Any line item where 'received' < 'quantity' will be returned.
+ """
+
+ return [line for line in self.lines.all() if line.quantity > line.received]
+
+ @transaction.atomic
+ def receive_line_item(self, line, location, quantity, user):
+ """ Receive a line item (or partial line item) against this PO
+ """
+
+ # Create a new stock item
+ if line.part:
+ stock = StockItem(
+ part=line.part.part,
+ location=location,
+ quantity=quantity,
+ purchase_order=self)
+
+ stock.save()
+
+ # Add a new transaction note to the newly created stock item
+ stock.addTransactionNote("Received items", user, "Received {q} items against order '{po}'".format(
+ q=line.receive_quantity,
+ po=str(self))
+ )
+
+ # Update the number of parts received against the particular line item
+ line.received += quantity
+ line.save()
+
+ # Has this order been completed?
+ if len(self.pending_line_items()) == 0:
+
+ self.received_by = user
+ self.complete_order() # This will save the model
+
class OrderLineItem(models.Model):
""" Abstract model for an order line item
@@ -251,3 +309,8 @@ class PurchaseOrderLineItem(OrderLineItem):
)
received = models.PositiveIntegerField(default=0, help_text=_('Number of items received'))
+
+ def remaining(self):
+ """ Calculate the number of items remaining to be received """
+ r = self.quantity - self.received
+ return max(r, 0)
diff --git a/InvenTree/order/templates/order/order_wizard/select_pos.html b/InvenTree/order/templates/order/order_wizard/select_pos.html
index f97ba95ee2..4ae0a89c95 100644
--- a/InvenTree/order/templates/order/order_wizard/select_pos.html
+++ b/InvenTree/order/templates/order/order_wizard/select_pos.html
@@ -53,7 +53,7 @@
id='id-purchase-order-{{ supplier.id }}'
name='purchase-order-{{ supplier.id }}'>
- {% for order in supplier.outstanding_purchase_orders %}
+ {% for order in supplier.pending_purchase_orders %}
diff --git a/InvenTree/order/templates/order/purchase_order_detail.html b/InvenTree/order/templates/order/purchase_order_detail.html
index 185eba4cad..68370c9c22 100644
--- a/InvenTree/order/templates/order/purchase_order_detail.html
+++ b/InvenTree/order/templates/order/purchase_order_detail.html
@@ -41,11 +41,7 @@ InvenTree | {{ order }}
{% if order.notes %}
@@ -153,6 +163,12 @@ $("#edit-order").click(function() {
);
});
+$("#receive-order").click(function() {
+ launchModalForm("{% url 'purchase-order-receive' order.id %}", {
+ reload: true,
+ });
+});
+
$("#export-order").click(function() {
location.href = "{% url 'purchase-order-export' order.id %}";
});
@@ -182,6 +198,8 @@ $('#new-po-line').click(function() {
{% endif %}
$("#po-lines-table").bootstrapTable({
+ search: true,
+ sortable: true,
});
diff --git a/InvenTree/order/templates/order/receive_parts.html b/InvenTree/order/templates/order/receive_parts.html
new file mode 100644
index 0000000000..68a6533b2e
--- /dev/null
+++ b/InvenTree/order/templates/order/receive_parts.html
@@ -0,0 +1,69 @@
+{% extends "modal_form.html" %}
+
+{% block form %}
+
+Receive outstanding parts for {{ order }} - {{ order.description }}
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/order/urls.py b/InvenTree/order/urls.py
index 695628bad0..38687342c2 100644
--- a/InvenTree/order/urls.py
+++ b/InvenTree/order/urls.py
@@ -13,6 +13,7 @@ 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'^receive/?', views.PurchaseOrderReceive.as_view(), name='purchase-order-receive'),
url(r'^export/?', views.PurchaseOrderExport.as_view(), name='purchase-order-export'),
diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py
index 578021b2cb..b5b9540fed 100644
--- a/InvenTree/order/views.py
+++ b/InvenTree/order/views.py
@@ -15,7 +15,7 @@ import logging
from .models import PurchaseOrder, PurchaseOrderLineItem
from build.models import Build
from company.models import Company, SupplierPart
-from stock.models import StockItem
+from stock.models import StockItem, StockLocation
from part.models import Part
from . import forms as order_forms
@@ -165,6 +165,126 @@ class PurchaseOrderExport(AjaxView):
return DownloadFile(filedata, filename)
+class PurchaseOrderReceive(AjaxView):
+ """ View for receiving parts which are outstanding against a PurchaseOrder.
+
+ Any parts which are outstanding are listed.
+ If all parts are marked as received, the order is closed out.
+
+ """
+
+ ajax_form_title = "Receive Parts"
+ ajax_template_name = "order/receive_parts.html"
+
+ # Where the parts will be going (selected in POST request)
+ destination = None
+
+ def get_context_data(self):
+
+ ctx = {
+ 'order': self.order,
+ 'lines': self.lines,
+ 'locations': StockLocation.objects.all(),
+ 'destination': self.destination,
+ }
+
+ return ctx
+
+ def get(self, request, *args, **kwargs):
+ """ Respond to a GET request. Determines which parts are outstanding,
+ and presents a list of these parts to the user.
+ """
+
+ self.request = request
+ self.order = get_object_or_404(PurchaseOrder, pk=self.kwargs['pk'])
+
+ self.lines = self.order.pending_line_items()
+
+ for line in self.lines:
+ # Pre-fill the remaining quantity
+ line.receive_quantity = line.remaining()
+
+ return self.renderJsonResponse(request)
+
+ def post(self, request, *args, **kwargs):
+ """ Respond to a POST request. Data checking and error handling.
+ If the request is valid, new StockItem objects will be made
+ for each received item.
+ """
+
+ self.request = request
+ self.order = get_object_or_404(PurchaseOrder, pk=self.kwargs['pk'])
+
+ self.lines = []
+ self.destination = None
+
+ # Extract the destination for received parts
+ if 'receive_location' in request.POST:
+ pk = request.POST['receive_location']
+ try:
+ self.destination = StockLocation.objects.get(id=pk)
+ except (StockLocation.DoesNotExist, ValueError):
+ pass
+
+ errors = self.destination is None
+
+ # Extract information on all submitted line items
+ for item in request.POST:
+ if item.startswith('line-'):
+ pk = item.replace('line-', '')
+
+ try:
+ line = PurchaseOrderLineItem.objects.get(id=pk)
+ except (PurchaseOrderLineItem.DoesNotExist, ValueError):
+ continue
+
+ # Ignore a part that doesn't map to a SupplierPart
+ try:
+ if line.part is None:
+ continue
+ except SupplierPart.DoesNotExist:
+ continue
+
+ receive = self.request.POST[item]
+
+ try:
+ receive = int(receive)
+ except ValueError:
+ # In the case on an invalid input, reset to default
+ receive = line.remaining()
+ errors = True
+
+ if receive < 0:
+ receive = 0
+ errors = True
+
+ line.receive_quantity = receive
+ self.lines.append(line)
+
+ # No errors? Receive the submitted parts!
+ if errors is False:
+ self.receive_parts()
+
+ data = {
+ 'form_valid': errors is False,
+ 'success': 'Items marked as received',
+ }
+
+ return self.renderJsonResponse(request, data=data)
+
+ def receive_parts(self):
+ """ Called once the form has been validated.
+ Create new stockitems against received parts.
+ """
+
+ for line in self.lines:
+
+ if not line.part:
+ continue
+
+ self.order.receive_line_item(line, self.destination, line.receive_quantity, self.request.user)
+
+
class OrderParts(AjaxView):
""" View for adding various SupplierPart items to a Purchase Order.
diff --git a/InvenTree/stock/migrations/0006_stockitem_purchase_order.py b/InvenTree/stock/migrations/0006_stockitem_purchase_order.py
new file mode 100644
index 0000000000..08d7f4de01
--- /dev/null
+++ b/InvenTree/stock/migrations/0006_stockitem_purchase_order.py
@@ -0,0 +1,20 @@
+# Generated by Django 2.2.2 on 2019-06-15 09:06
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('order', '0010_purchaseorderlineitem_notes'),
+ ('stock', '0005_auto_20190602_1944'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='stockitem',
+ name='purchase_order',
+ field=models.ForeignKey(blank=True, help_text='Purchase order for this stock item', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stock_items', to='order.PurchaseOrder'),
+ ),
+ ]
diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py
index 50a0f39e72..a53e61f86d 100644
--- a/InvenTree/stock/models.py
+++ b/InvenTree/stock/models.py
@@ -96,6 +96,7 @@ class StockItem(models.Model):
delete_on_deplete: If True, StockItem will be deleted when the stock level gets to zero
status: Status of this StockItem (ref: InvenTree.status_codes.StockStatus)
notes: Extra notes field
+ purchase_order: Link to a PurchaseOrder (if this stock item was created from a PurchaseOrder)
infinite: If True this StockItem can never be exhausted
"""
@@ -249,6 +250,14 @@ class StockItem(models.Model):
updated = models.DateField(auto_now=True, null=True)
+ purchase_order = models.ForeignKey(
+ 'order.PurchaseOrder',
+ on_delete=models.SET_NULL,
+ related_name='stock_items',
+ blank=True, null=True,
+ help_text='Purchase order for this stock item'
+ )
+
# last time the stock was checked / counted
stocktake_date = models.DateField(blank=True, null=True)
diff --git a/InvenTree/stock/templates/stock/item.html b/InvenTree/stock/templates/stock/item.html
index 40d78eb7e1..7300855389 100644
--- a/InvenTree/stock/templates/stock/item.html
+++ b/InvenTree/stock/templates/stock/item.html
@@ -71,6 +71,12 @@