Merge pull request #394 from SchrodingersGat/receive-order

Receive order
This commit is contained in:
Oliver 2019-06-15 20:08:35 +10:00 committed by GitHub
commit 477ac68aa0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 356 additions and 18 deletions

View File

@ -135,6 +135,10 @@ class Company(models.Model):
""" Return purchase orders which are 'outstanding' """ """ Return purchase orders which are 'outstanding' """
return self.purchase_orders.filter(status__in=OrderStatus.OPEN) 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): def closed_purchase_orders(self):
""" Return purchase orders which are not 'outstanding' """ Return purchase orders which are not 'outstanding'

View File

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

View File

@ -4,7 +4,7 @@ Order model definitions
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from django.db import models from django.db import models, transaction
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.contrib.auth.models import User from django.contrib.auth.models import User
@ -14,6 +14,7 @@ from django.utils.translation import ugettext as _
import tablib import tablib
from datetime import datetime from datetime import datetime
from stock.models import StockItem
from company.models import Company, SupplierPart from company.models import Company, SupplierPart
from InvenTree.status_codes import OrderStatus from InvenTree.status_codes import OrderStatus
@ -33,6 +34,7 @@ class Order(models.Model):
creation_date: Automatic date of order creation creation_date: Automatic date of order creation
created_by: User who created this order (automatically captured) created_by: User who created this order (automatically captured)
issue_date: Date the order was issued 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) 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')) notes = models.TextField(blank=True, help_text=_('Order notes'))
def place_order(self): def place_order(self):
@ -80,13 +84,21 @@ class Order(models.Model):
self.issue_date = datetime.now().date() self.issue_date = datetime.now().date()
self.save() 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): class PurchaseOrder(Order):
""" A PurchaseOrder represents goods shipped inwards from an external supplier. """ A PurchaseOrder represents goods shipped inwards from an external supplier.
Attributes: Attributes:
supplier: Reference to the company supplying the goods in the order supplier: Reference to the company supplying the goods in the order
received_by: User that received the goods
""" """
ORDER_PREFIX = "PO" ORDER_PREFIX = "PO"
@ -100,6 +112,13 @@ class PurchaseOrder(Order):
help_text=_('Company') 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): def export_to_file(self, **kwargs):
""" Export order information to external file """ """ Export order information to external file """
@ -151,6 +170,7 @@ class PurchaseOrder(Order):
def get_absolute_url(self): def get_absolute_url(self):
return reverse('purchase-order-detail', kwargs={'pk': self.id}) return reverse('purchase-order-detail', kwargs={'pk': self.id})
@transaction.atomic
def add_line_item(self, supplier_part, quantity, group=True, reference=''): def add_line_item(self, supplier_part, quantity, group=True, reference=''):
""" Add a new line item to this purchase order. """ Add a new line item to this purchase order.
This function will check that: This function will check that:
@ -195,6 +215,44 @@ class PurchaseOrder(Order):
line.save() 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): class OrderLineItem(models.Model):
""" Abstract model for an order line item """ 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')) 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)

View File

@ -53,7 +53,7 @@
id='id-purchase-order-{{ supplier.id }}' id='id-purchase-order-{{ supplier.id }}'
name='purchase-order-{{ supplier.id }}'> name='purchase-order-{{ supplier.id }}'>
<option value=''>---------</option> <option value=''>---------</option>
{% for order in supplier.outstanding_purchase_orders %} {% for order in supplier.pending_purchase_orders %}
<option value='{{ order.id }}'{% if supplier.selected_purchase_order == order.id %} selected='selected'{% endif %}> <option value='{{ order.id }}'{% if supplier.selected_purchase_order == order.id %} selected='selected'{% endif %}>
{{ order }} {{ order }}
</option> </option>

View File

@ -41,11 +41,7 @@ InvenTree | {{ order }}
</tr> </tr>
<tr> <tr>
<td>Created</td> <td>Created</td>
<td>{{ order.creation_date }}</td> <td>{{ order.creation_date }}<span class='badge'>{{ order.created_by }}</span></td>
</tr>
<tr>
<td>Created By</td>
<td>{{ order.created_by }}</td>
</tr> </tr>
{% if order.issue_date %} {% if order.issue_date %}
<tr> <tr>
@ -53,6 +49,12 @@ InvenTree | {{ order }}
<td>{{ order.issue_date }}</td> <td>{{ order.issue_date }}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if order.status == OrderStatus.COMPLETE %}
<tr>
<td>Received</td>
<td>{{ order.complete_date }}<span class='badge'>{{ order.received_by }}</span></td>
</tr>
{% endif %}
</table> </table>
</div> </div>
</div> </div>
@ -63,6 +65,8 @@ InvenTree | {{ order }}
<button type='button' class='btn btn-primary' id='edit-order'>Edit Order</button> <button type='button' class='btn btn-primary' id='edit-order'>Edit Order</button>
{% if order.status == OrderStatus.PENDING and order.lines.count > 0 %} {% if order.status == OrderStatus.PENDING and order.lines.count > 0 %}
<button type='button' class='btn btn-primary' id='place-order'>Place Order</button> <button type='button' class='btn btn-primary' id='place-order'>Place Order</button>
{% elif order.status == OrderStatus.PLACED %}
<button type='button' class='btn btn-primary' id='receive-order'>Receive Order</button>
{% endif %} {% endif %}
<button type='button' class='btn btn-primary' id='export-order' title='Export order to file'>Export</button> <button type='button' class='btn btn-primary' id='export-order' title='Export order to file'>Export</button>
</div> </div>
@ -74,20 +78,24 @@ InvenTree | {{ order }}
{% endif %} {% endif %}
<table class='table table-striped table-condensed' id='po-lines-table'> <table class='table table-striped table-condensed' id='po-lines-table'>
<thead>
<tr> <tr>
<th data-field='line'>Line</th> <th data-sortable='true'>Line</th>
<th data-field='part'>Part</th> <th data-sortable='true'>Part</th>
<th data-field='sku'>Order Code</th> <th>Description</th>
<th data-field='reference'>Reference</th> <th data-sortable='true'>Order Code</th>
<th data-field='quantity'>Quantity</th> <th data-sortable='true'>Reference</th>
<th data-sortable='true'>Quantity</th>
{% if not order.status == OrderStatus.PENDING %} {% if not order.status == OrderStatus.PENDING %}
<th data-field='received'>Received</th> <th data-sortable='true'>Received</th>
{% endif %} {% endif %}
<th data-field='notes'>Note</th> <th>Note</th>
{% if order.status == OrderStatus.PENDING %} {% if order.status == OrderStatus.PENDING %}
<th data-field='buttons'></th> <th></th>
{% endif %} {% endif %}
</tr> </tr>
</thead>
<tbody>
{% for line in order.lines.all %} {% for line in order.lines.all %}
<tr> <tr>
<td> <td>
@ -98,9 +106,10 @@ InvenTree | {{ order }}
{% include "hover_image.html" with image=line.part.part.image hover=True %} {% include "hover_image.html" with image=line.part.part.image hover=True %}
<a href="{% url 'part-detail' line.part.part.id %}">{{ line.part.part.full_name }}</a> <a href="{% url 'part-detail' line.part.part.id %}">{{ line.part.part.full_name }}</a>
</td> </td>
<td>{{ line.part.part.description }}</td>
<td><a href="{% url 'supplier-part-detail' line.part.id %}">{{ line.part.SKU }}</a></td> <td><a href="{% url 'supplier-part-detail' line.part.id %}">{{ line.part.SKU }}</a></td>
{% else %} {% else %}
<td colspan='2'><strong>Warning: Part has been deleted.</strong></td> <td colspan='3'><strong>Warning: Part has been deleted.</strong></td>
{% endif %} {% endif %}
<td>{{ line.reference }}</td> <td>{{ line.reference }}</td>
<td>{{ line.quantity }}</td> <td>{{ line.quantity }}</td>
@ -124,6 +133,7 @@ InvenTree | {{ order }}
{% endif %} {% endif %}
</tr> </tr>
{% endfor %} {% endfor %}
</tbody>
</table> </table>
{% if order.notes %} {% 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() { $("#export-order").click(function() {
location.href = "{% url 'purchase-order-export' order.id %}"; location.href = "{% url 'purchase-order-export' order.id %}";
}); });
@ -182,6 +198,8 @@ $('#new-po-line').click(function() {
{% endif %} {% endif %}
$("#po-lines-table").bootstrapTable({ $("#po-lines-table").bootstrapTable({
search: true,
sortable: true,
}); });

View File

@ -0,0 +1,69 @@
{% extends "modal_form.html" %}
{% block form %}
Receive outstanding parts for <b>{{ order }}</b> - <i>{{ order.description }}</i>
<form method='post' action='' class='js-modal-form' enctype='multipart/form-data'>
{% csrf_token %}
{% load crispy_forms_tags %}
<div class='control-group'>
<label class='control-label requiredField'>Location</label>
<div class='controls'>
<select class='select' name='receive_location'>
<option value=''>---------</option>
{% for loc in locations %}
<option value='{{ loc.id }}' {% if destination.id == loc.id %}selected='selected'{% endif %}>{{ loc.pathstring }} - {{ loc.description }}</option>
{% endfor %}
</select>
{% if not destination %}
<span class='help-inline'>Select location to receive parts</span>
{% else %}
<p class='help-block'>Location of received parts</p>
{% endif %}
</div>
</div>
<label class='control-label'>Parts</label>
<p class='help-block'>Select parts to receive against this order.</p>
<table class='table table-striped'>
<tr>
<th>Part</th>
<th>Order Code</th>
<th>On Order</th>
<th>Received</th>
<th>Receive</th>
</tr>
{% for line in lines %}
<tr id='line_row_{{ line.id }}'>
{% if line.part %}
<td>
{% include "hover_image.html" with image=line.part.part.image hover=False %}
{{ line.part.part.full_name }}
</td>
<td>{{ line.part.SKU }}</td>
{% else %}
<td colspan='2'>Referenced part has been removed</td>
{% endif %}
<td>{{ line.quantity }}</td>
<td>{{ line.received }}</td>
<td>
<div class='control-group'>
<div class='controls'>
<input class='numberinput' type='number' min='0' value='{{ line.receive_quantity }}' name='line-{{ line.id }}'/>
</div>
</div>
</td>
<td>
<button class='btn btn-default btn-remove' id='del_item_{{ line.id }}' title='Remove line' type='button'>
<span row='line_row_{{ line.id }}' onClick="removeOrderRowFromOrderWizard()" class='glyphicon glyphicon-small glyphicon-remove'></span>
</button>
</td>
</tr>
{% endfor %}
</table>
</form>
{% endblock %}

View File

@ -13,6 +13,7 @@ purchase_order_detail_urls = [
url(r'^edit/?', views.PurchaseOrderEdit.as_view(), name='purchase-order-edit'), url(r'^edit/?', views.PurchaseOrderEdit.as_view(), name='purchase-order-edit'),
url(r'^issue/?', views.PurchaseOrderIssue.as_view(), name='purchase-order-issue'), 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'), url(r'^export/?', views.PurchaseOrderExport.as_view(), name='purchase-order-export'),

View File

@ -15,7 +15,7 @@ import logging
from .models import PurchaseOrder, PurchaseOrderLineItem from .models import PurchaseOrder, PurchaseOrderLineItem
from build.models import Build from build.models import Build
from company.models import Company, SupplierPart from company.models import Company, SupplierPart
from stock.models import StockItem from stock.models import StockItem, StockLocation
from part.models import Part from part.models import Part
from . import forms as order_forms from . import forms as order_forms
@ -165,6 +165,126 @@ class PurchaseOrderExport(AjaxView):
return DownloadFile(filedata, filename) 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): class OrderParts(AjaxView):
""" View for adding various SupplierPart items to a Purchase Order. """ View for adding various SupplierPart items to a Purchase Order.

View File

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

View File

@ -96,6 +96,7 @@ class StockItem(models.Model):
delete_on_deplete: If True, StockItem will be deleted when the stock level gets to zero 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) status: Status of this StockItem (ref: InvenTree.status_codes.StockStatus)
notes: Extra notes field 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 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) 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 # last time the stock was checked / counted
stocktake_date = models.DateField(blank=True, null=True) stocktake_date = models.DateField(blank=True, null=True)

View File

@ -71,6 +71,12 @@
<td>{{ item.batch }}</td> <td>{{ item.batch }}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if item.purchase_order %}
<tr>
<td>Purchase Order</td>
<td><a href="{% url 'purchase-order-detail' item.purchase_order.id %}">{{ item.purchase_order }}</a></td>
</tr>
{% endif %}
{% if item.customer %} {% if item.customer %}
<tr> <tr>
<td>Customer</td> <td>Customer</td>

View File

@ -11,6 +11,7 @@ InvenTree Modules
docs/build/index docs/build/index
docs/company/index docs/company/index
docs/part/index docs/part/index
docs/order/index
docs/stock/index docs/stock/index
The InvenTree Django ecosystem provides the following 'apps' for core functionality: The InvenTree Django ecosystem provides the following 'apps' for core functionality:
@ -19,4 +20,5 @@ The InvenTree Django ecosystem provides the following 'apps' for core functional
* `Build <docs/build/index.html>`_ - Part build projects * `Build <docs/build/index.html>`_ - Part build projects
* `Company <docs/company/index.html>`_ - Company management (suppliers / customers) * `Company <docs/company/index.html>`_ - Company management (suppliers / customers)
* `Part <docs/part/index.html>`_ - Part management * `Part <docs/part/index.html>`_ - Part management
* `Order <docs/order/index.html>`_ - Order management
* `Stock <docs/stock/index.html>`_ - Stock management * `Stock <docs/stock/index.html>`_ - Stock management