Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2019-06-17 23:28:35 +10:00
commit 1b43f151df
26 changed files with 729 additions and 155 deletions

View File

@ -4,6 +4,8 @@ omit =
# Do not run coverage on migration files # Do not run coverage on migration files
*/migrations/* */migrations/*
InvenTree/manage.py InvenTree/manage.py
InvenTree/keygen.py
Inventree/InvenTree/middleware.py Inventree/InvenTree/middleware.py
Inventree/InvenTree/utils.py Inventree/InvenTree/utils.py
Inventree/InvenTree/wsgi.py Inventree/InvenTree/wsgi.py
InvenTree/users/apps.py

View File

@ -6,7 +6,6 @@ import io
import json import json
import os.path import os.path
from PIL import Image from PIL import Image
import requests
from wsgiref.util import FileWrapper from wsgiref.util import FileWrapper
from django.http import StreamingHttpResponse from django.http import StreamingHttpResponse
@ -30,69 +29,10 @@ def TestIfImageURL(url):
'.jpg', '.jpeg', '.jpg', '.jpeg',
'.png', '.bmp', '.png', '.bmp',
'.tif', '.tiff', '.tif', '.tiff',
'.webp', '.webp', '.gif',
] ]
def DownloadExternalFile(url, **kwargs):
""" Attempt to download an external file
Args:
url - External URL
"""
result = {
'status': False,
'url': url,
'file': None,
'status_code': 200,
}
headers = {'User-Agent': 'Mozilla/5.0'}
max_size = kwargs.get('max_size', 1048576) # 1MB default limit
# Get the HEAD for the file
try:
head = requests.head(url, stream=True, headers=headers)
except:
result['error'] = 'Error retrieving HEAD data'
return result
if not head.status_code == 200:
result['error'] = 'Incorrect HEAD status code'
result['status_code'] = head.status_code
return result
try:
filesize = int(head.headers['Content-Length'])
except ValueError:
result['error'] = 'Could not decode filesize'
result['extra'] = head.headers['Content-Length']
return result
if filesize > max_size:
result['error'] = 'File size too large ({s})'.format(s=filesize)
return result
# All checks have passed - download the file
try:
request = requests.get(url, stream=True, headers=headers)
except:
result['error'] = 'Error retriving GET data'
return result
try:
dl_file = io.StringIO(request.text)
result['status'] = True
result['file'] = dl_file
return result
except:
result['error'] = 'Could not convert downloaded data to file'
return result
def str2bool(text, test=True): def str2bool(text, test=True):
""" Test if a string 'looks' like a boolean value. """ Test if a string 'looks' like a boolean value.

View File

@ -0,0 +1,102 @@
from django.test import TestCase
import django.core.exceptions as django_exceptions
from .validators import validate_overage, validate_part_name
from . import helpers
class ValidatorTest(TestCase):
""" Simple tests for custom field validators """
def test_part_name(self):
""" Test part name validator """
validate_part_name('hello world')
with self.assertRaises(django_exceptions.ValidationError):
validate_part_name('This | name is not } valid')
def test_overage(self):
""" Test overage validator """
validate_overage("100%")
validate_overage("10")
validate_overage("45.2 %")
with self.assertRaises(django_exceptions.ValidationError):
validate_overage("-1")
with self.assertRaises(django_exceptions.ValidationError):
validate_overage("-2.04 %")
with self.assertRaises(django_exceptions.ValidationError):
validate_overage("105%")
with self.assertRaises(django_exceptions.ValidationError):
validate_overage("xxx %")
with self.assertRaises(django_exceptions.ValidationError):
validate_overage("aaaa")
class TestHelpers(TestCase):
""" Tests for InvenTree helper functions """
def test_image_url(self):
""" Test if a filename looks like an image """
for name in ['ape.png', 'bat.GiF', 'apple.WeBP', 'BiTMap.Bmp']:
self.assertTrue(helpers.TestIfImageURL(name))
for name in ['no.doc', 'nah.pdf', 'whatpng']:
self.assertFalse(helpers.TestIfImageURL(name))
def test_str2bool(self):
""" Test string to boolean conversion """
for s in ['yes', 'Y', 'ok', '1', 'OK', 'Ok', 'tRuE', 'oN']:
self.assertTrue(helpers.str2bool(s))
self.assertFalse(helpers.str2bool(s, test=False))
for s in ['nO', '0', 'none', 'noNE', None, False, 'falSe', 'off']:
self.assertFalse(helpers.str2bool(s))
self.assertTrue(helpers.str2bool(s, test=False))
for s in ['wombat', '', 'xxxx']:
self.assertFalse(helpers.str2bool(s))
self.assertFalse(helpers.str2bool(s, test=False))
class TestQuoteWrap(TestCase):
""" Tests for string wrapping """
def test_single(self):
self.assertEqual(helpers.WrapWithQuotes('hello'), '"hello"')
self.assertEqual(helpers.WrapWithQuotes('hello"'), '"hello"')
class TestMakeBarcoede(TestCase):
""" Tests for barcode string creation """
def test_barcode(self):
data = {
'animal': 'cat',
'legs': 3,
'noise': 'purr'
}
bc = helpers.MakeBarcode("part", 3, "www.google.com", data)
self.assertIn('animal', bc)
self.assertIn('tool', bc)
self.assertIn('"tool": "InvenTree"', bc)
class TestDownloadFile(TestCase):
def test_download(self):
helpers.DownloadFile("hello world", "out.txt")
helpers.DownloadFile(bytes("hello world".encode("utf8")), "out.bin")

View File

@ -110,7 +110,7 @@ class Build(models.Model):
self.completion_date = datetime.now().date() self.completion_date = datetime.now().date()
self.completed_by = user self.completed_by = user
self.status = self.CANCELLED self.status = BuildStatus.CANCELLED
self.save() self.save()
def getAutoAllocations(self): def getAutoAllocations(self):

View File

@ -14,6 +14,7 @@ class BuildTestSimple(TestCase):
def setUp(self): def setUp(self):
part = Part.objects.create(name='Test part', part = Part.objects.create(name='Test part',
description='Simple description') description='Simple description')
Build.objects.create(part=part, Build.objects.create(part=part,
batch='B1', batch='B1',
status=BuildStatus.PENDING, status=BuildStatus.PENDING,
@ -35,6 +36,8 @@ class BuildTestSimple(TestCase):
self.assertEqual(b.batch, 'B2') self.assertEqual(b.batch, 'B2')
self.assertEqual(b.quantity, 21) self.assertEqual(b.quantity, 21)
self.assertEqual(str(b), 'Build 21 x Test part - Simple description')
def test_url(self): def test_url(self):
b1 = Build.objects.get(pk=1) b1 = Build.objects.get(pk=1)
self.assertEqual(b1.get_absolute_url(), '/build/1/') self.assertEqual(b1.get_absolute_url(), '/build/1/')
@ -46,6 +49,8 @@ class BuildTestSimple(TestCase):
self.assertEqual(b1.is_complete, False) self.assertEqual(b1.is_complete, False)
self.assertEqual(b2.is_complete, True) self.assertEqual(b2.is_complete, True)
self.assertEqual(b2.status, BuildStatus.COMPLETE)
def test_is_active(self): def test_is_active(self):
b1 = Build.objects.get(pk=1) b1 = Build.objects.get(pk=1)
b2 = Build.objects.get(pk=2) b2 = Build.objects.get(pk=2)
@ -56,3 +61,14 @@ class BuildTestSimple(TestCase):
def test_required_parts(self): def test_required_parts(self):
# TODO - Generate BOM for test part # TODO - Generate BOM for test part
pass pass
def cancel_build(self):
""" Test build cancellation function """
build = Build.objects.get(id=1)
self.assertEqual(build.status, BuildStatus.PENDING)
build.cancelBuild()
self.assertEqual(build.status, BuildStatus.CANCELLED)

View File

@ -1,14 +1,17 @@
# Sample company data # Sample company data
- model: company.company - model: company.company
pk: 1
fields: fields:
name: ACME name: ACME
description: A Cool Military Enterprise description: A Cool Military Enterprise
- model: company.company - model: company.company
pk: 2
fields: fields:
name: Appel Computers name: Appel Computers
description: Think more differenter description: Think more differenter
- model: company.company - model: company.company
pk: 3
fields: fields:
name: Zerg Corp name: Zerg Corp
description: We eat the competition description: We eat the competition

View File

@ -15,6 +15,22 @@
supplier: 1 supplier: 1
SKU: 'ACME0002' SKU: 'ACME0002'
# Widget purchaseable from ACME
- model: company.supplierpart
pk: 100
fields:
part: 25
supplier: 1
SKU: 'ACME-WIDGET'
# Widget purchaseable from Zerg
- model: company.supplierpart
pk: 101
fields:
part: 25
supplier: 2
SKU: 'ZERG-WIDGET'
# M2x4 LPHS from Zerg Corp # M2x4 LPHS from Zerg Corp
- model: company.supplierpart - model: company.supplierpart
pk: 3 pk: 3

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

@ -56,10 +56,10 @@ class CompanySimpleTest(TestCase):
zerg = Company.objects.get(pk=3) zerg = Company.objects.get(pk=3)
self.assertTrue(acme.has_parts) self.assertTrue(acme.has_parts)
self.assertEqual(acme.part_count, 2) self.assertEqual(acme.part_count, 3)
self.assertTrue(appel.has_parts) self.assertTrue(appel.has_parts)
self.assertEqual(appel.part_count, 1) self.assertEqual(appel.part_count, 2)
self.assertTrue(zerg.has_parts) self.assertTrue(zerg.has_parts)
self.assertEqual(zerg.part_count, 1) self.assertEqual(zerg.part_count, 1)

View File

@ -0,0 +1,3 @@
"""
The Order module is responsible for managing Orders
"""

View File

@ -0,0 +1,44 @@
# PurchaseOrder and PurchaseOrderLineItem objects for testing
# Ordering some screws from ACME
- model: order.purchaseorder
pk: 1
fields:
reference: 0001
description: "Ordering some screws"
supplier: 1
# Ordering some screws from Zerg Corp
- model: order.purchaseorder
pk: 2
fields:
reference: 0002
description: "Ordering some more screws"
supplier: 3
# Add some line items against PO 0001
# 100 x ACME0001 (M2x4 LPHS)
- model: order.purchaseorderlineitem
pk: 1
fields:
order: 1
part: 1
quantity: 100
# 250 x ACME0002 (M2x4 LPHS)
# Partially received (50)
- model: order.purchaseorderlineitem
fields:
order: 1
part: 2
quantity: 250
received: 50
# 100 x ZERGLPHS (M2x4 LPHS)
- model: order.purchaseorderlineitem
fields:
order: 2
part: 3
quantity: 100

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

@ -0,0 +1,18 @@
# Generated by Django 2.2.2 on 2019-06-17 09:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('order', '0011_auto_20190615_1928'),
]
operations = [
migrations.AlterField(
model_name='purchaseorder',
name='creation_date',
field=models.DateField(blank=True, null=True),
),
]

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
""" """
@ -48,6 +50,12 @@ class Order(models.Model):
return " ".join(el) return " ".join(el)
def save(self, *args, **kwargs):
if not self.creation_date:
self.creation_date = datetime.now().date()
super().save(*args, **kwargs)
class Meta: class Meta:
abstract = True abstract = True
@ -57,7 +65,7 @@ class Order(models.Model):
URL = models.URLField(blank=True, help_text=_('Link to external page')) URL = models.URLField(blank=True, help_text=_('Link to external page'))
creation_date = models.DateField(auto_now=True, editable=False) creation_date = models.DateField(blank=True, null=True)
status = models.PositiveIntegerField(default=OrderStatus.PENDING, choices=OrderStatus.items(), status = models.PositiveIntegerField(default=OrderStatus.PENDING, choices=OrderStatus.items(),
help_text='Order status') help_text='Order status')
@ -70,6 +78,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 +90,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 +118,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 +176,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 +221,54 @@ 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
"""
if not self.status == OrderStatus.PLACED:
raise ValidationError({"status": _("Lines can only be received against an order marked as 'Placed'")})
try:
quantity = int(quantity)
if quantity <= 0:
raise ValidationError({"quantity": _("Quantity must be greater than zero")})
except ValueError:
raise ValidationError({"quantity": _("Invalid quantity provided")})
# 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=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 +325,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

@ -1 +1,146 @@
# TODO - Implement tests for the order app from django.test import TestCase
import django.core.exceptions as django_exceptions
from part.models import Part
from .models import PurchaseOrder, PurchaseOrderLineItem
from stock.models import StockLocation
from company.models import SupplierPart
from InvenTree.status_codes import OrderStatus
class OrderTest(TestCase):
"""
Tests to ensure that the order models are functioning correctly.
"""
fixtures = [
'company',
'supplier_part',
'category',
'part',
'location',
'stock',
'order'
]
def test_basics(self):
""" Basic tests e.g. repr functions etc """
order = PurchaseOrder.objects.get(pk=1)
self.assertEqual(order.get_absolute_url(), '/order/purchase-order/1/')
self.assertEqual(str(order), 'PO 1')
line = PurchaseOrderLineItem.objects.get(pk=1)
self.assertEqual(str(line), "100 x ACME0001 from ACME (for PO 1)")
def test_on_order(self):
""" There should be 3 separate items on order for the M2x4 LPHS part """
part = Part.objects.get(name='M2x4 LPHS')
open_orders = []
for supplier in part.supplier_parts.all():
open_orders += supplier.open_orders()
self.assertEqual(len(open_orders), 3)
# Test the total on-order quantity
self.assertEqual(part.on_order, 400)
def test_add_items(self):
""" Test functions for adding line items to an order """
order = PurchaseOrder.objects.get(pk=1)
self.assertEqual(order.status, OrderStatus.PENDING)
self.assertEqual(order.lines.count(), 2)
sku = SupplierPart.objects.get(SKU='ACME-WIDGET')
part = sku.part
# Try to order some invalid things
with self.assertRaises(django_exceptions.ValidationError):
order.add_line_item(sku, -999)
with self.assertRaises(django_exceptions.ValidationError):
order.add_line_item(sku, 'not a number')
# Order the part
self.assertEqual(part.on_order, 0)
order.add_line_item(sku, 100)
self.assertEqual(part.on_order, 100)
self.assertEqual(order.lines.count(), 3)
# Order the same part again (it should be merged)
order.add_line_item(sku, 50)
self.assertEqual(order.lines.count(), 3)
self.assertEqual(part.on_order, 150)
# Try to order a supplier part from the wrong supplier
sku = SupplierPart.objects.get(SKU='ZERG-WIDGET')
with self.assertRaises(django_exceptions.ValidationError):
order.add_line_item(sku, 99)
def test_receive(self):
""" Test order receiving functions """
part = Part.objects.get(name='M2x4 LPHS')
# Receive some items
line = PurchaseOrderLineItem.objects.get(id=1)
order = line.order
loc = StockLocation.objects.get(id=1)
# There should be two lines against this order
self.assertEqual(len(order.pending_line_items()), 2)
# Should fail, as order is 'PENDING' not 'PLACED"
self.assertEqual(order.status, OrderStatus.PENDING)
with self.assertRaises(django_exceptions.ValidationError):
order.receive_line_item(line, loc, 50, user=None)
order.place_order()
self.assertEqual(order.status, OrderStatus.PLACED)
order.receive_line_item(line, loc, 50, user=None)
self.assertEqual(line.remaining(), 50)
self.assertEqual(part.on_order, 350)
# Try to order some invalid things
with self.assertRaises(django_exceptions.ValidationError):
order.receive_line_item(line, loc, -10, user=None)
with self.assertRaises(django_exceptions.ValidationError):
order.receive_line_item(line, loc, 'not a number', user=None)
# Receive the rest of the items
order.receive_line_item(line, loc, 50, user=None)
line = PurchaseOrderLineItem.objects.get(id=2)
order.receive_line_item(line, loc, 2 * line.quantity, user=None)
self.assertEqual(part.on_order, 100)
self.assertEqual(order.status, OrderStatus.COMPLETE)
def test_export(self):
""" Test order exporting """
order = PurchaseOrder.objects.get(pk=1)
output = order.export_to_file(format='csv')
self.assertIn('M2x4 LPHS', output)
self.assertIn('Line,Part,Description', output)

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

@ -32,7 +32,7 @@ test:
coverage: coverage:
python3 InvenTree/manage.py check python3 InvenTree/manage.py check
coverage run InvenTree/manage.py test build company part stock order coverage run InvenTree/manage.py test build company part stock order InvenTree
coverage html coverage html
documentation: documentation:

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

View File

@ -1,69 +0,0 @@
class SupplierOrder(models.Model):
"""
An order of parts from a supplier, made up of multiple line items
"""
def get_absolute_url(self):
return "/supplier/order/{id}/".format(id=self.id)
# Interal reference for this order
internal_ref = models.CharField(max_length=25, unique=True)
supplier = models.ForeignKey(Company, on_delete=models.CASCADE,
related_name='orders')
created_date = models.DateField(auto_now_add=True, editable=False)
issued_date = models.DateField(blank=True, null=True, help_text="Date the purchase order was issued")
notes = models.TextField(blank=True, help_text="Order notes")
def __str__(self):
return "PO {ref} ({status})".format(ref=self.internal_ref,
status=self.get_status_display)
PENDING = 10 # Order is pending (not yet placed)
PLACED = 20 # Order has been placed
RECEIVED = 30 # Order has been received
CANCELLED = 40 # Order was cancelled
LOST = 50 # Order was lost
ORDER_STATUS_CODES = {PENDING: _("Pending"),
PLACED: _("Placed"),
CANCELLED: _("Cancelled"),
RECEIVED: _("Received"),
LOST: _("Lost")
}
status = models.PositiveIntegerField(default=PENDING,
choices=ORDER_STATUS_CODES.items())
delivery_date = models.DateField(blank=True, null=True)
class SupplierOrderLineItem(models.Model):
"""
A line item in a supplier order, corresponding to some quantity of part
"""
class Meta:
unique_together = [
('order', 'line_number'),
('order', 'supplier_part'),
('order', 'internal_part'),
]
order = models.ForeignKey(SupplierOrder, on_delete=models.CASCADE)
line_number = models.PositiveIntegerField(default=1)
internal_part = models.ForeignKey(Part, null=True, blank=True, on_delete=models.SET_NULL)
supplier_part = models.ForeignKey(SupplierPart, null=True, blank=True, on_delete=models.SET_NULL)
quantity = models.PositiveIntegerField(default=1)
notes = models.TextField(blank=True)
received = models.BooleanField(default=False)