Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2019-06-13 22:54:10 +10:00
commit 1dea5f1624
32 changed files with 961 additions and 39 deletions

View File

@ -4,7 +4,7 @@ Provides information on the current InvenTree version
import subprocess import subprocess
INVENTREE_SW_VERSION = "0.0.1" INVENTREE_SW_VERSION = "0.0.2"
def inventreeVersion(): def inventreeVersion():
@ -14,6 +14,8 @@ def inventreeVersion():
def inventreeCommitHash(): def inventreeCommitHash():
""" Returns the git commit hash for the running codebase """ """ Returns the git commit hash for the running codebase """
# TODO - This doesn't seem to work when running under gunicorn. Why is this?!
commit = str(subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8').strip() commit = str(subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8').strip()
return commit return commit

View File

@ -170,7 +170,7 @@ class AjaxView(AjaxMixin, View):
""" """
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
return JsonResponse('', safe=False) return self.renderJsonResponse(request)
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):

View File

@ -62,12 +62,23 @@ InvenTree | Allocate Parts
{% else %} {% else %}
$("#build-list").bootstrapTable({}); $("#build-list").bootstrapTable({
search: true,
sortable: true,
});
$("#btn-allocate").click(function() { $("#btn-allocate").click(function() {
location.href = "{% url 'build-allocate' build.id %}?edit=1"; location.href = "{% url 'build-allocate' build.id %}?edit=1";
}); });
$("#btn-order-parts").click(function() {
launchModalForm("/order/purchase-order/order-parts/", {
data: {
build: {{ build.id }},
},
});
});
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@ -3,17 +3,19 @@
<div id='#build-item-toolbar'> <div id='#build-item-toolbar'>
<div class='btn-group'> <div class='btn-group'>
<button class='btn btn-primary' type='button' id='btn-allocate' title='Allocate Stock'>Allocate</button> <button class='btn btn-primary' type='button' id='btn-allocate' title='Allocate Stock'>Allocate</button>
<button class='btn btn-primary' type='button' id='btn-order-parts' title='Order Parts'>Order Parts</button>
</div> </div>
</div> </div>
<table class='table table-striped table-condensed' id='build-list' data-sorting='true' data-toolbar='#build-item-toolbar'> <table class='table table-striped table-condensed' id='build-list' data-sorting='true' data-toolbar='#build-item-toolbar'>
<thead> <thead>
<tr> <tr>
<th>Part</th> <th data-sortable='true'>Part</th>
<th>Description</th> <th>Description</th>
<th>Available</th> <th data-sortable='true'>Available</th>
<th>Required</th> <th data-sortable='true'>Required</th>
<th>Allocated</th> <th data-sortable='true'>Allocated</th>
<th data-sortable='true'>On Order</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -27,6 +29,7 @@
<td>{{ item.part.total_stock }}</td> <td>{{ item.part.total_stock }}</td>
<td>{{ item.quantity }}</td> <td>{{ item.quantity }}</td>
<td>{{ item.allocated }}</td> <td>{{ item.allocated }}</td>
<td>{{ item.part.on_order }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@ -275,6 +275,7 @@ class BuildDetail(DetailView):
build = self.get_object() build = self.get_object()
ctx['bom_price'] = build.part.get_price_info(build.quantity, buy=False) ctx['bom_price'] = build.part.get_price_info(build.quantity, buy=False)
ctx['BuildStatus'] = BuildStatus
return ctx return ctx
@ -296,6 +297,7 @@ class BuildAllocate(DetailView):
context['part'] = part context['part'] = part
context['bom_items'] = bom_items context['bom_items'] = bom_items
context['BuildStatus'] = BuildStatus
context['bom_price'] = build.part.get_price_info(build.quantity, buy=False) context['bom_price'] = build.part.get_price_info(build.quantity, buy=False)

View File

@ -12,6 +12,7 @@
<button class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">Options <button class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">Options
<span class="caret"></span></button> <span class="caret"></span></button>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li><a href='#' id='multi-part-order' title='Order parts'>Order Parts</a></li>
</ul> </ul>
</div> </div>
</div> </div>
@ -101,4 +102,20 @@
url: "{% url 'api-part-supplier-list' %}" 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 %} {% endblock %}

View File

@ -47,5 +47,5 @@ class EditPurchaseOrderLineItemForm(HelperForm):
'part', 'part',
'quantity', 'quantity',
'reference', 'reference',
'received' 'notes',
] ]

View File

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

View File

@ -6,10 +6,12 @@ Order model definitions
from django.db import models from django.db import models
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.core.exceptions import ValidationError
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.urls import reverse from django.urls import reverse
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
import tablib
from datetime import datetime from datetime import datetime
from company.models import Company, SupplierPart from company.models import Company, SupplierPart
@ -98,9 +100,101 @@ class PurchaseOrder(Order):
help_text=_('Company') 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): def get_absolute_url(self):
return reverse('purchase-order-detail', kwargs={'pk': self.id}) 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): class OrderLineItem(models.Model):
""" Abstract model for an order line item """ Abstract model for an order line item
@ -118,6 +212,8 @@ class OrderLineItem(models.Model):
reference = models.CharField(max_length=100, blank=True, help_text=_('Line item reference')) 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): class PurchaseOrderLineItem(OrderLineItem):
""" Model for a purchase order line item. """ Model for a purchase order line item.
@ -132,6 +228,13 @@ class PurchaseOrderLineItem(OrderLineItem):
('order', 'part') ('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( order = models.ForeignKey(
PurchaseOrder, on_delete=models.CASCADE, PurchaseOrder, on_delete=models.CASCADE,
related_name='lines', related_name='lines',

View File

@ -0,0 +1,74 @@
{% extends "modal_form.html" %}
{% block form %}
<h4>
Step 1 of 2 - Select Parts
</h4>
{% if parts|length > 0 %}
<div class='alert alert-info alert-block' role='alert'>
Select suppliers for {{ parts|length }} parts.
</div>
{% else %}
<div class='alert alert-warning alert-block' role='alert'>
No purchaseable parts selected.
</div>
{% endif %}
<form method="post" action='' class='js-modal-form' enctype="multipart/form-data">
{% csrf_token %}
{% load crispy_forms_tags %}
<input type='hidden' name='form_step' value='select_parts'/>
<table class='table table-condensed table-striped' id='order-wizard-part-table'>
<tr>
<th>Part</th>
<th colspan='2'>Select Supplier</th>
<th>Quantity</th>
<th></th>
</tr>
{% for part in parts %}
<tr id='part_row_{{ part.id }}'>
<td>
{% include "hover_image.html" with image=part.image hover=False %}
{{ part.full_name }} <small><i>{{ part.description }}</i></small>
</td>
<td>
<button class='btn btn-default btn-create' id='new_supplier_part_{{ part.id }}' title='Create new supplier part for {{ part }}' type='button'>
<span part-id='{{ part.id }}' onClick='newSupplierPartFromOrderWizard()' class='glyphicon glyphicon-small glyphicon-plus'></span>
</button>
</td>
<td>
<div class='control-group'>
<div class='controls'>
<select class='select' id='id_supplier_part_{{ part.id }}' name="part-supplier-{{ part.id }}">
<option value=''>---------</option>
{% for supplier in part.supplier_parts.all %}
<option value="{{ supplier.id }}"{% if part.order_supplier == supplier.id %} selected="selected"{% endif %}>{{ supplier }}</option>
{% endfor %}
</select>
</div>
{% if not part.order_supplier %}
<span class='help-inline'>Select a supplier for <i>{{ part.name }}</i></span>
{% endif %}
</div>
</td>
<td>
<div class='control-group'>
<div class='controls'>
<input class='numberinput' type='number' min='0' value='{{ part.order_quantity }}' name='part-quantity-{{ part.id }}'/>
</div>
</div>
</td>
<td>
<button class='btn btn-default btn-remove' id='del_item_{{ part.id }}' title='Remove part' type='button'>
<span row='part_row_{{ part.id }}' onclick='removeOrderRowFromOrderWizard()' class='glyphicon glyphicon-small glyphicon-remove'></span>
</button>
</td>
</tr>
{% endfor %}
</table>
</form>
{% endblock %}

View File

@ -0,0 +1,73 @@
{% extends "modal_form.html" %}
{% block form %}
<h4>
Step 2 of 2 - Select Purchase Orders
</h4>
<div class='alert alert-info alert-block' role='alert'>
Select existing purchase orders, or create new orders.
</div>
<form method='post' action='' class='js-modal-form' enctype='multipart/form-data'>
{% csrf_token %}
{% load crispy_forms_tags %}
<input type='hidden' name='form_step' value='select_purchase_orders'/>
{% for supplier in suppliers %}
{% for item in supplier.order_items %}
<input type='hidden' name='part-supplier-{{ item.id }}' value='{{ item.order_supplier }}'/>
<input type='hidden' name='part-quantity-{{ item.id }}' value='{{ item.order_quantity }}'/>
{% endfor %}
{% endfor %}
<table class='table table-condensed table-striped' id='order-wizard-po-table'>
<tr>
<th>Supplier</th>
<th>Items</th>
<th colspan='2'>Select Purchase Order</th>
</tr>
{% for supplier in suppliers %}
<tr id='suppier_row_{{ supplier.id }}'>
<td>
{% include 'hover_image.html' with image=supplier.image hover=False %}
{{ supplier.name }}
</td>
<td>{{ supplier.order_items|length }}</td>
<td>
<button
class='btn btn-default btn-create'
id='new_po_{{ supplier.id }}'
title='Create new purchase order for {{ supplier.name }}'
type='button'>
<span supplier-id='{{ supplier.id }}' onclick='newPurchaseOrderFromOrderWizard()' class='glyphicon glyphicon-small glyphicon-plus'></span>
</button>
</td>
<td>
<div class='control-group'>
<div class='controls'>
<select
class='select'
id='id-purchase-order-{{ supplier.id }}'
name='purchase-order-{{ supplier.id }}'>
<option value=''>---------</option>
{% for order in supplier.outstanding_purchase_orders %}
<option value='{{ order.id }}'{% if supplier.selected_purchase_order == order.id %} selected='selected'{% endif %}>
{{ order }}
</option>
{% endfor %}
</select>
</div>
{% if not supplier.selected_purchase_order %}
<span class='help-inline'>Select a purchase order for {{ supplier.name }}</span>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</table>
</form>
{% endblock %}

View File

@ -0,0 +1,5 @@
{% extends "modal_delete_form.html" %}
{% block pre_form_content %}
Are you sure you wish to delete this line item?
{% endblock %}

View File

@ -4,6 +4,7 @@
<th data-field='reference'>Order Reference</th> <th data-field='reference'>Order Reference</th>
<th data-field='description'>Description</th> <th data-field='description'>Description</th>
<th data-field='status'>Status</th> <th data-field='status'>Status</th>
<th data-field='items'>Items</th>
</tr> </tr>
{% for order in orders %} {% for order in orders %}
<tr> <tr>
@ -11,6 +12,7 @@
<td><a href="{% url 'purchase-order-detail' order.id %}">{{ order }}</a></td> <td><a href="{% url 'purchase-order-detail' order.id %}">{{ order }}</a></td>
<td>{{ order.description }}</td> <td>{{ order.description }}</td>
<td>{% include "order/order_status.html" %}</td> <td>{% include "order/order_status.html" %}</td>
<td>{{ order.lines.count }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>

View File

@ -64,6 +64,7 @@ InvenTree | {{ order }}
{% 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>
{% endif %} {% endif %}
<button type='button' class='btn btn-primary' id='export-order' title='Export order to file'>Export</button>
</div> </div>
<h4>Order Items</h4> <h4>Order Items</h4>
@ -79,20 +80,48 @@ InvenTree | {{ order }}
<th data-field='sku'>Order Code</th> <th data-field='sku'>Order Code</th>
<th data-field='reference'>Reference</th> <th data-field='reference'>Reference</th>
<th data-field='quantity'>Quantity</th> <th data-field='quantity'>Quantity</th>
{% if not order.status == OrderStatus.PENDING %}
<th data-field='received'>Received</th> <th data-field='received'>Received</th>
{% endif %}
<th data-field='notes'>Note</th>
{% if order.status == OrderStatus.PENDING %}
<th data-field='buttons'></th>
{% endif %}
</tr> </tr>
{% for line in order.lines.all %} {% for line in order.lines.all %}
<tr> <tr>
<td>{{ forloop.counter }}</td> <td>
{{ forloop.counter }}
</td>
{% if line.part %} {% if line.part %}
<td><a href="{% url 'part-detail' line.part.part.id %}">{{ line.part.part.full_name }}</a></td> <td>
{% 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>
</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='2'><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>
{% if not order.status == OrderStatus.PENDING %}
<td>{{ line.received }}</td> <td>{{ line.received }}</td>
{% endif %}
<td>
{{ line.notes }}
</td>
{% if order.status == OrderStatus.PENDING %}
<td>
<div class='btn-group'>
<button class='btn btn-default btn-edit' id='edit-line-item-{{ line.id }} title='Edit line item' onclick='editPurchaseOrderLineItem()'>
<span url="{% url 'po-line-item-edit' line.id %}" line='{{ line.id }}' class='glyphicon glyphicon-small glyphicon-edit'></span>
</button>
<button class='btn btn-default btn-remove' id='remove-line-item-{{ line.id }' title='Remove line item' type='button' onclick='removePurchaseOrderLineItem()'>
<span url="{% url 'po-line-item-delete' line.id %}" line='{{ line.id }}' class='glyphicon glyphicon-small glyphicon-remove'></span>
</button>
</div>
</td>
{% endif %}
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
@ -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 %} {% if order.status == OrderStatus.PENDING %}
$('#new-po-line').click(function() { $('#new-po-line').click(function() {
launchModalForm("{% url 'po-line-item-create' %}", launchModalForm("{% url 'po-line-item-create' %}",
@ -151,4 +184,5 @@ $('#new-po-line').click(function() {
$("#po-lines-table").bootstrapTable({ $("#po-lines-table").bootstrapTable({
}); });
{% endblock %} {% endblock %}

View File

@ -14,18 +14,30 @@ 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'^export/?', views.PurchaseOrderExport.as_view(), name='purchase-order-export'),
url(r'^.*$', views.PurchaseOrderDetail.as_view(), name='purchase-order-detail'), 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 = [ po_line_urls = [
url(r'^new/', views.POLineItemCreate.as_view(), name='po-line-item-create'), url(r'^new/', views.POLineItemCreate.as_view(), name='po-line-item-create'),
url(r'^(?P<pk>\d+)/', include(po_line_item_detail_urls)),
] ]
purchase_order_urls = [ purchase_order_urls = [
url(r'^new/', views.PurchaseOrderCreate.as_view(), name='purchase-order-create'), 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 # Display detail view for a single purchase order
url(r'^(?P<pk>\d+)/', include(purchase_order_detail_urls)), url(r'^(?P<pk>\d+)/', include(purchase_order_detail_urls)),

View File

@ -5,20 +5,28 @@ Django views for interacting with Order app
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from django.shortcuts import get_object_or_404
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from django.forms import HiddenInput from django.forms import HiddenInput
import logging
from .models import PurchaseOrder, PurchaseOrderLineItem from .models import PurchaseOrder, PurchaseOrderLineItem
from build.models import Build
from company.models import Company, SupplierPart from company.models import Company, SupplierPart
from stock.models import StockItem
from part.models import Part
from . import forms as order_forms from . import forms as order_forms
from InvenTree.views import AjaxCreateView, AjaxUpdateView from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
from InvenTree.helpers import str2bool from InvenTree.helpers import DownloadFile, str2bool
from InvenTree.status_codes import OrderStatus from InvenTree.status_codes import OrderStatus
logger = logging.getLogger(__name__)
class PurchaseOrderIndex(ListView): class PurchaseOrderIndex(ListView):
""" List view for all purchase orders """ """ List view for all purchase orders """
@ -135,6 +143,331 @@ class PurchaseOrderIssue(AjaxUpdateView):
return self.renderJsonResponse(request, form, data) 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-<pk> : The ID of the selected supplier
- quantity-<pk> : 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): class POLineItemCreate(AjaxCreateView):
""" AJAX view for creating a new PurchaseOrderLineItem object """ AJAX view for creating a new PurchaseOrderLineItem object
""" """
@ -229,8 +562,32 @@ class POLineItemCreate(AjaxCreateView):
class POLineItemEdit(AjaxUpdateView): class POLineItemEdit(AjaxUpdateView):
""" View for editing a PurchaseOrderLineItem object in a modal form.
"""
model = PurchaseOrderLineItem model = PurchaseOrderLineItem
form_class = order_forms.EditPurchaseOrderLineItemForm form_class = order_forms.EditPurchaseOrderLineItemForm
ajax_template_name = 'modal_form.html' 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',
}

View File

@ -339,7 +339,7 @@ class Part(models.Model):
""" """
if self.default_supplier: if self.default_supplier:
return self.default_suppliers return self.default_supplier
if self.supplier_count == 1: if self.supplier_count == 1:
return self.supplier_parts.first() return self.supplier_parts.first()
@ -410,6 +410,26 @@ class Part(models.Model):
return max(total, 0) 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): def isStarredBy(self, user):
""" Return True if this part has been starred by a particular user """ """ Return True if this part has been starred by a particular user """

View File

@ -65,6 +65,8 @@ class PartSerializer(serializers.ModelSerializer):
image_url = serializers.CharField(source='get_image_url', read_only=True) image_url = serializers.CharField(source='get_image_url', read_only=True)
category_name = serializers.CharField(source='category_path', read_only=True) category_name = serializers.CharField(source='category_path', read_only=True)
allocated_stock = serializers.IntegerField(source='allocation_count', read_only=True)
@staticmethod @staticmethod
def setup_eager_loading(queryset): def setup_eager_loading(queryset):
queryset = queryset.prefetch_related('category') queryset = queryset.prefetch_related('category')
@ -91,7 +93,8 @@ class PartSerializer(serializers.ModelSerializer):
'keywords', 'keywords',
'URL', 'URL',
'total_stock', 'total_stock',
# 'available_stock', 'allocated_stock',
'on_order',
'units', 'units',
'trackable', 'trackable',
'assembly', 'assembly',

View File

@ -49,6 +49,7 @@
<div class='dropdown' style='float: right;'> <div class='dropdown' style='float: right;'>
<button id='part-options' class='btn btn-primary dropdown-toggle' type='button' data-toggle="dropdown">Options<span class='caret'></span></button> <button id='part-options' class='btn btn-primary dropdown-toggle' type='button' data-toggle="dropdown">Options<span class='caret'></span></button>
<ul class='dropdown-menu'> <ul class='dropdown-menu'>
<li><a href='#' id='multi-part-order' title='Order parts'>Order Parts</a></li>
<li><a href='#' id='multi-part-category' title='Set Part Category'>Set Category</a></li> <li><a href='#' id='multi-part-category' title='Set Part Category'>Set Category</a></li>
</ul> </ul>
</div> </div>

View File

@ -15,6 +15,9 @@
<ul class="dropdown-menu"> <ul class="dropdown-menu">
{% if part.active %} {% if part.active %}
<li><a href="#" id='edit-part' title='Edit part'>Edit</a></li> <li><a href="#" id='edit-part' title='Edit part'>Edit</a></li>
{% if part.purchaseable %}
<li><a href='#' id='order-part' title='Order part'>Order</a></li>
{% endif %}
<li><a href='#' id='duplicate-part' title='Duplicate Part'>Duplicate</a></li> <li><a href='#' id='duplicate-part' title='Duplicate Part'>Duplicate</a></li>
<hr> <hr>
<li><a href="#" id='deactivate-part' title='Deactivate part'>Deactivate</a></li> <li><a href="#" id='deactivate-part' title='Deactivate part'>Deactivate</a></li>
@ -159,6 +162,14 @@
); );
}); });
$("#order-part").click(function() {
launchModalForm("/order/purchase-order/order-parts/", {
data: {
part: {{ part.id }},
},
});
});
$("#edit-part").click(function() { $("#edit-part").click(function() {
launchModalForm( launchModalForm(
"{% url 'part-edit' part.id %}", "{% url 'part-edit' part.id %}",

View File

@ -72,24 +72,16 @@
</div> </div>
</div> </div>
<div class="col-sm-6"> <div class="col-sm-6">
<h4>Stock Status - {{ part.available_stock }}{% if part.units %} {{ part.units }} {% endif%} available</h4>
<table class="table table-striped"> <table class="table table-striped">
<tr>
<td colspan='2'>
<h4>Stock Status</h4>
</td>
</tr>
<tr> <tr>
<td>In Stock</td> <td>In Stock</td>
<td>{{ part.total_stock }}</td> <td>{{ part.total_stock }}</td>
</tr> </tr>
{% if part.assembly %}
<tr>
<td>Can Build</td>
<td>{{ part.can_build }}</td>
</tr>
{% if part.quantity_being_built > 0 %}
<tr>
<td>Underway</td>
<td>{{ part.quantity_being_built }}</td>
</tr>
{% endif %}
{% endif %}
{% if part.allocation_count > 0 %} {% if part.allocation_count > 0 %}
<tr> <tr>
<td>Allocated</td> <td>Allocated</td>
@ -102,6 +94,27 @@
<td>{{ part.on_order }}</td> <td>{{ part.on_order }}</td>
</tr> </tr>
{% endif %} {% endif %}
<tr>
<td><b>Total Available</b></td>
<td><b>{{ part.net_stock }}</b></td>
</tr>
{% if part.assembly %}
<tr>
<td colspan='2'>
<h4>Build Status</h4>
</td>
</tr>
<tr>
<td>Can Build</td>
<td>{{ part.can_build }}</td>
</tr>
{% if part.quantity_being_built > 0 %}
<tr>
<td>Underway</td>
<td>{{ part.quantity_being_built }}</td>
</tr>
{% endif %}
{% endif %}
</table> </table>
</div> </div>
</div> </div>

View File

@ -585,10 +585,6 @@ class BomDownload(AjaxView):
# TODO - This should no longer extend an AjaxView! # TODO - This should no longer extend an AjaxView!
model = Part model = Part
# form_class = BomExportForm
# template_name = 'part/bom_export.html'
# ajax_form_title = 'Export Bill of Materials'
# context_object_name = 'part'
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):

View File

@ -14,6 +14,10 @@
color: #ffbb00; color: #ffbb00;
} }
.red-cell {
background-color: #ec7f7f;
}
.part-price { .part-price {
color: rgb(13, 245, 25); color: rgb(13, 245, 25);
} }
@ -288,6 +292,21 @@
color: #A11; color: #A11;
} }
.btn-create {
padding: 3px;
padding-left: 5px;
padding-right: 5px;
color: #1A1;
}
.btn-edit {
padding: 3px;
padding: 3px;
padding-left: 5px;
padding-right: 5px;
color: #55E;
}
.button-toolbar { .button-toolbar {
padding-left: 0px; padding-left: 0px;
} }

View File

@ -0,0 +1,101 @@
function removeOrderRowFromOrderWizard(e) {
/* Remove a part selection from an order form. */
e = e || window.event;
var src = e.target || e.srcElement;
var row = $(src).attr('row');
$('#' + row).remove();
}
function newSupplierPartFromOrderWizard(e) {
/* Create a new supplier part directly from an order form.
* Launches a secondary modal and (if successful),
* back-populates the selected row.
*/
e = e || window.event;
var src = e.target || e.srcElement;
var part = $(src).attr('part-id');
launchModalForm("/supplier-part/new/", {
modal: '#modal-form-secondary',
data: {
part: part,
},
success: function(response) {
/* A new supplier part has been created! */
var dropdown = '#id_supplier_part_' + part;
var option = new Option(response.text, response.pk, true, true);
$('#modal-form').find(dropdown).append(option).trigger('change');
},
});
}
function newPurchaseOrderFromOrderWizard(e) {
/* Create a new purchase order directly from an order form.
* Launches a secondary modal and (if successful),
* back-fills the newly created purchase order.
*/
e = e || window.event;
var src = e.target || e.srcElement;
var supplier = $(src).attr('supplier-id');
launchModalForm("/order/purchase-order/new/", {
modal: '#modal-form-secondary',
data: {
supplier: supplier,
},
success: function(response) {
/* A new purchase order has been created! */
var dropdown = '#id-purchase-order-' + supplier;
var option = new Option(response.text, response.pk, true, true);
$('#modal-form').find(dropdown).append(option).trigger('change');
},
});
}
function editPurchaseOrderLineItem(e) {
/* Edit a purchase order line item in a modal form.
*/
e = e || window.event;
var src = e.target || e.srcElement;
var url = $(src).attr('url');
launchModalForm(url, {
reload: true,
});
}
function removePurchaseOrderLineItem(e) {
/* Delete a purchase order line item in a modal form
*/
e = e || window.event;
var src = e.target || e.srcElement;
var url = $(src).attr('url');
launchModalForm(url, {
reload: true,
});
}

View File

@ -192,4 +192,22 @@ function loadPartTable(table, url, options={}) {
if (options.buttons) { if (options.buttons) {
linkButtonsToSelection($(table), options.buttons); linkButtonsToSelection($(table), options.buttons);
} }
/* Button callbacks for part table buttons */
$("#multi-part-order").click(function() {
var selections = $(table).bootstrapTable("getSelections");
var parts = [];
selections.forEach(function(item) {
parts.push(item.pk);
});
launchModalForm("/order/purchase-order/order-parts/", {
data: {
parts: parts,
},
});
});
} }

View File

@ -237,6 +237,22 @@ function loadStockTable(table, options) {
$("#multi-item-move").click(function() { $("#multi-item-move").click(function() {
stockAdjustment('move'); stockAdjustment('move');
}); });
$("#multi-item-order").click(function() {
var selections = $("#stock-table").bootstrapTable("getSelections");
var stock = [];
selections.forEach(function(item) {
stock.push(item.pk);
});
launchModalForm("/order/purchase-order/order-parts/", {
data: {
stock: stock,
},
});
});
} }

View File

@ -99,6 +99,7 @@ InvenTree
<script type='text/javascript' src="{% static 'script/inventree/api.js' %}"></script> <script type='text/javascript' src="{% static 'script/inventree/api.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/tables.js' %}"></script> <script type='text/javascript' src="{% static 'script/inventree/tables.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/modals.js' %}"></script> <script type='text/javascript' src="{% static 'script/inventree/modals.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/order.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script> <script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/sidenav.js' %}"></script> <script type='text/javascript' src="{% static 'script/inventree/sidenav.js' %}"></script>
{% block js_load %} {% block js_load %}

View File

@ -5,7 +5,7 @@
<a class='hover-icon'> <a class='hover-icon'>
{% endif %} {% endif %}
<img class='hover-img-thumb' {% if image %}src='{{ image.url }}'{% else %}src='{% static "img/blank_image.png" %}'{% endif %}> <img class='hover-img-thumb' {% if image %}src='{{ image.url }}'{% else %}src='{% static "img/blank_image.png" %}'{% endif %}>
{% if hover %} {% if hover and image %}
<img class='hover-img-large' {% if image %}src='{{ image.url }}'{% else %}src='{% static "img/blank_image.png" %}'{% endif %}> <img class='hover-img-large' {% if image %}src='{{ image.url }}'{% else %}src='{% static "img/blank_image.png" %}'{% endif %}>
</a> </a>
{% endif %} {% endif %}

View File

@ -21,6 +21,8 @@
{% block pre_form_content %} {% block pre_form_content %}
{% endblock %} {% endblock %}
{% block form %}
<form method="post" action='' class='js-modal-form' enctype="multipart/form-data"> <form method="post" action='' class='js-modal-form' enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
@ -32,5 +34,7 @@
{% endblock %} {% endblock %}
</form> </form>
{% endblock %}
{% block post_form_content %} {% block post_form_content %}
{% endblock %} {% endblock %}

View File

@ -2,19 +2,22 @@
<tr> <tr>
<th>Part</th> <th>Part</th>
<th>Description</th> <th>Description</th>
<th>Required</th>
<th>In Stock</th> <th>In Stock</th>
<th>On Order</th> <th>On Order</th>
<th>Allocted</th>
<th>Net Stock</th> <th>Net Stock</th>
</tr> </tr>
{% for part in parts %} {% for part in parts %}
<tr> <tr>
<td><a href="{% url 'part-detail' part.id %}">{{ part.full_name }}</a></td> <td>
{% include "hover_image.html" with image=part.image hover=True %}
<a href="{% url 'part-detail' part.id %}">{{ part.full_name }}</a>
</td>
<td>{{ part.description }}</td> <td>{{ part.description }}</td>
<td>{{ part.allocation_count }}</td>
<td>{{ part.total_stock }}</td> <td>{{ part.total_stock }}</td>
<td>{{ part.on_order }}</td> <td>{{ part.on_order }}</td>
<td>{{ part.available_stock }}</td> <td>{{ part.allocation_count }}</td>
<td{% if part.net_stock < 0 %} class='red-cell'{% endif %}>{{ part.net_stock }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>

View File

@ -10,6 +10,7 @@
<li><a href="#" id='multi-item-remove' title='Remove from selected stock items'>Remove stock</a></li> <li><a href="#" id='multi-item-remove' title='Remove from selected stock items'>Remove stock</a></li>
<li><a href="#" id='multi-item-stocktake' title='Stocktake selected stock items'>Count stock</a></li> <li><a href="#" id='multi-item-stocktake' title='Stocktake selected stock items'>Count stock</a></li>
<li><a href='#' id='multi-item-move' title='Move selected stock items'>Move stock</a></li> <li><a href='#' id='multi-item-move' title='Move selected stock items'>Move stock</a></li>
<li><a href='#' id='multi-item-order' title='Order selected items'>Order stock</a></li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@ -3,6 +3,8 @@ ignore =
# - W293 - blank lines contain whitespace # - W293 - blank lines contain whitespace
W293, W293,
# - E501 - line too long (82 characters) # - E501 - line too long (82 characters)
E501 E501,
# - C901 - function is too complex
C901,
exclude = .git,__pycache__,*/migrations/* exclude = .git,__pycache__,*/migrations/*
max-complexity = 20 max-complexity = 20