Merge pull request #386 from SchrodingersGat/order

Order
This commit is contained in:
Oliver 2019-06-10 23:21:43 +10:00 committed by GitHub
commit 5f2e3dbbda
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
60 changed files with 1371 additions and 116 deletions

View File

@ -62,6 +62,7 @@ INSTALLED_APPS = [
'stock.apps.StockConfig', 'stock.apps.StockConfig',
'company.apps.CompanyConfig', 'company.apps.CompanyConfig',
'build.apps.BuildConfig', 'build.apps.BuildConfig',
'order.apps.OrderConfig',
# Third part add-ons # Third part add-ons
'django_filters', # Extended filter functionality 'django_filters', # Extended filter functionality

View File

@ -0,0 +1,91 @@
from django.utils.translation import ugettext as _
class StatusCode:
@classmethod
def items(cls):
return cls.options.items()
@classmethod
def label(cls, value):
""" Return the status code label associated with the provided value """
return cls.options.get(value, '')
class OrderStatus(StatusCode):
# Order status codes
PENDING = 10 # Order is pending (not yet placed)
PLACED = 20 # Order has been placed
COMPLETE = 30 # Order has been completed
CANCELLED = 40 # Order was cancelled
LOST = 50 # Order was lost
RETURNED = 60 # Order was returned
options = {
PENDING: _("Pending"),
PLACED: _("Placed"),
COMPLETE: _("Complete"),
CANCELLED: _("Cancelled"),
LOST: _("Lost"),
RETURNED: _("Returned"),
}
# Open orders
OPEN = [
PENDING,
PLACED,
]
# Failed orders
FAILED = [
CANCELLED,
LOST,
RETURNED
]
class StockStatus(StatusCode):
OK = 10 # Item is OK
ATTENTION = 50 # Item requires attention
DAMAGED = 55 # Item is damaged
DESTROYED = 60 # Item is destroyed
LOST = 70 # Item has been lost
options = {
OK: _("OK"),
ATTENTION: _("Attention needed"),
DAMAGED: _("Damaged"),
DESTROYED: _("Destroyed"),
LOST: _("Lost"),
}
# The following codes correspond to parts that are 'available'
AVAILABLE_CODES = [
OK,
ATTENTION,
DAMAGED
]
class BuildStatus(StatusCode):
# Build status codes
PENDING = 10 # Build is pending / active
ALLOCATED = 20 # Parts have been removed from stock
CANCELLED = 30 # Build was cancelled
COMPLETE = 40 # Build is complete
options = {
PENDING: _("Pending"),
ALLOCATED: _("Allocated"),
CANCELLED: _("Cancelled"),
COMPLETE: _("Complete"),
}
ACTIVE_CODES = [
PENDING,
ALLOCATED
]

View File

@ -15,10 +15,9 @@ from company.urls import supplier_part_urls
from company.urls import price_break_urls from company.urls import price_break_urls
from part.urls import part_urls from part.urls import part_urls
from stock.urls import stock_urls from stock.urls import stock_urls
from build.urls import build_urls from build.urls import build_urls
from order.urls import order_urls
from part.api import part_api_urls, bom_api_urls from part.api import part_api_urls, bom_api_urls
from company.api import company_api_urls from company.api import company_api_urls
@ -56,6 +55,7 @@ urlpatterns = [
url(r'^stock/', include(stock_urls)), url(r'^stock/', include(stock_urls)),
url(r'^company/', include(company_urls)), url(r'^company/', include(company_urls)),
url(r'^order/', include(order_urls)),
url(r'^build/', include(build_urls)), url(r'^build/', include(build_urls)),

View File

@ -92,6 +92,10 @@ class AjaxMixin(object):
on the client side. on the client side.
""" """
# By default, point to the modal_form template
# (this can be overridden by a child class)
ajax_template_name = 'modal_form.html'
ajax_form_action = '' ajax_form_action = ''
ajax_form_title = '' ajax_form_title = ''
@ -165,10 +169,6 @@ class AjaxView(AjaxMixin, View):
""" An 'AJAXified' View for displaying an object """ An 'AJAXified' View for displaying an object
""" """
# By default, point to the modal_form template
# (this can be overridden by a child class)
ajax_template_name = 'modal_form.html'
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
return JsonResponse('', safe=False) return JsonResponse('', safe=False)

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2 on 2019-06-04 12:17
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('build', '0004_auto_20190525_2356'),
]
operations = [
migrations.AlterField(
model_name='build',
name='part',
field=models.ForeignKey(help_text='Select part to build', limit_choices_to={'active': True, 'assembly': True, 'is_template': False}, on_delete=django.db.models.deletion.CASCADE, related_name='builds', to='part.Part'),
),
]

View File

@ -16,6 +16,8 @@ from django.db import models, transaction
from django.db.models import Sum from django.db.models import Sum
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from InvenTree.status_codes import BuildStatus
from stock.models import StockItem from stock.models import StockItem
from part.models import Part, BomItem from part.models import Part, BomItem
@ -69,21 +71,8 @@ class Build(models.Model):
help_text='Number of parts to build' help_text='Number of parts to build'
) )
# Build status codes status = models.PositiveIntegerField(default=BuildStatus.PENDING,
PENDING = 10 # Build is pending / active choices=BuildStatus.items(),
ALLOCATED = 20 # Parts have been removed from stock
CANCELLED = 30 # Build was cancelled
COMPLETE = 40 # Build is complete
#: Build status codes
BUILD_STATUS_CODES = {PENDING: _("Pending"),
ALLOCATED: _("Allocated"),
CANCELLED: _("Cancelled"),
COMPLETE: _("Complete"),
}
status = models.PositiveIntegerField(default=PENDING,
choices=BUILD_STATUS_CODES.items(),
validators=[MinValueValidator(0)], validators=[MinValueValidator(0)],
help_text='Build status') help_text='Build status')
@ -253,7 +242,7 @@ class Build(models.Model):
item.save() item.save()
# Finally, mark the build as complete # Finally, mark the build as complete
self.status = self.COMPLETE self.status = BuildStatus.COMPLETE
self.save() self.save()
def getRequiredQuantity(self, part): def getRequiredQuantity(self, part):
@ -325,14 +314,12 @@ class Build(models.Model):
- HOLDING - HOLDING
""" """
return self.status in [ return self.status in BuildStatus.ACTIVE_CODES
self.PENDING,
]
@property @property
def is_complete(self): def is_complete(self):
""" Returns True if the build status is COMPLETE """ """ Returns True if the build status is COMPLETE """
return self.status == self.COMPLETE return self.status == BuildStatus.COMPLETE
class BuildItem(models.Model): class BuildItem(models.Model):

View File

@ -6,6 +6,8 @@ from django.test import TestCase
from .models import Build from .models import Build
from part.models import Part from part.models import Part
from InvenTree.status_codes import BuildStatus
class BuildTestSimple(TestCase): class BuildTestSimple(TestCase):
@ -14,14 +16,14 @@ class BuildTestSimple(TestCase):
description='Simple description') description='Simple description')
Build.objects.create(part=part, Build.objects.create(part=part,
batch='B1', batch='B1',
status=Build.PENDING, status=BuildStatus.PENDING,
title='Building 7 parts', title='Building 7 parts',
quantity=7, quantity=7,
notes='Some simple notes') notes='Some simple notes')
Build.objects.create(part=part, Build.objects.create(part=part,
batch='B2', batch='B2',
status=Build.COMPLETE, status=BuildStatus.COMPLETE,
title='Building 21 parts', title='Building 21 parts',
quantity=21, quantity=21,
notes='Some simple notes') notes='Some simple notes')

View File

@ -15,6 +15,7 @@ from stock.models import StockLocation, StockItem
from InvenTree.views import AjaxUpdateView, AjaxCreateView, AjaxDeleteView from InvenTree.views import AjaxUpdateView, AjaxCreateView, AjaxDeleteView
from InvenTree.helpers import str2bool from InvenTree.helpers import str2bool
from InvenTree.status_codes import BuildStatus
class BuildIndex(ListView): class BuildIndex(ListView):
@ -32,10 +33,12 @@ class BuildIndex(ListView):
context = super(BuildIndex, self).get_context_data(**kwargs).copy() context = super(BuildIndex, self).get_context_data(**kwargs).copy()
context['active'] = self.get_queryset().filter(status__in=[Build.PENDING, ]) context['BuildStatus'] = BuildStatus
context['completed'] = self.get_queryset().filter(status=Build.COMPLETE) context['active'] = self.get_queryset().filter(status__in=BuildStatus.ACTIVE_CODES)
context['cancelled'] = self.get_queryset().filter(status=Build.CANCELLED)
context['completed'] = self.get_queryset().filter(status=BuildStatus.COMPLETE)
context['cancelled'] = self.get_queryset().filter(status=BuildStatus.CANCELLED)
return context return context

View File

@ -10,13 +10,16 @@ import os
import math import math
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.db import models
from django.db.models import Sum
from django.apps import apps from django.apps import apps
from django.db import models
from django.urls import reverse from django.urls import reverse
from django.conf import settings from django.conf import settings
from django.contrib.staticfiles.templatetags.staticfiles import static from django.contrib.staticfiles.templatetags.staticfiles import static
from InvenTree.status_codes import OrderStatus
def rename_company_image(instance, filename): def rename_company_image(instance, filename):
""" Function to rename a company image after upload """ Function to rename a company image after upload
@ -128,6 +131,28 @@ class Company(models.Model):
stock = apps.get_model('stock', 'StockItem') stock = apps.get_model('stock', 'StockItem')
return stock.objects.filter(supplier_part__supplier=self.id).count() return stock.objects.filter(supplier_part__supplier=self.id).count()
def outstanding_purchase_orders(self):
""" Return purchase orders which are 'outstanding' """
return self.purchase_orders.filter(status__in=OrderStatus.OPEN)
def closed_purchase_orders(self):
""" Return purchase orders which are not 'outstanding'
- Complete
- Failed / lost
- Returned
"""
return self.purchase_orders.exclude(status__in=OrderStatus.OPEN)
def complete_purchase_orders(self):
return self.purchase_orders.filter(status=OrderStatus.COMPLETE)
def failed_purchase_orders(self):
""" Return any purchase orders which were not successful """
return self.purchase_orders.filter(status__in=OrderStatus.FAILED)
class Contact(models.Model): class Contact(models.Model):
""" A Contact represents a person who works at a particular company. """ A Contact represents a person who works at a particular company.
@ -223,6 +248,9 @@ class SupplierPart(models.Model):
@property @property
def manufacturer_string(self): def manufacturer_string(self):
""" Format a MPN string for this SupplierPart.
Concatenates manufacture name and part number.
"""
items = [] items = []
@ -286,6 +314,37 @@ class SupplierPart(models.Model):
else: else:
return None return None
def open_orders(self):
""" Return a database query for PO line items for this SupplierPart,
limited to purchase orders that are open / outstanding.
"""
return self.purchase_order_line_items.prefetch_related('order').filter(order__status__in=OrderStatus.OPEN)
def on_order(self):
""" Return the total quantity of items currently on order.
Subtract partially received stock as appropriate
"""
totals = self.open_orders().aggregate(Sum('quantity'), Sum('received'))
# Quantity on order
q = totals.get('quantity__sum', 0)
# Quantity received
r = totals.get('received__sum', 0)
if q is None or r is None:
return 0
else:
return max(q - r, 0)
def purchase_orders(self):
""" Returns a list of purchase orders relating to this supplier part """
return [line.order for line in self.purchase_order_line_items.all().prefetch_related('order')]
def __str__(self): def __str__(self):
s = "{supplier} ({sku})".format( s = "{supplier} ({sku})".format(
sku=self.SKU, sku=self.SKU,

View File

@ -6,7 +6,7 @@
<div class='row'> <div class='row'>
<div class='col-sm-6'> <div class='col-sm-6'>
<h3>Company Details</h3> <h4>Company Details</h4>
</div> </div>
<div class='col-sm-6'> <div class='col-sm-6'>
<h3> <h3>

View File

@ -4,7 +4,7 @@
{% include 'company/tabs.html' with tab='parts' %} {% include 'company/tabs.html' with tab='parts' %}
<h3>Supplier Parts</h3> <h4>Supplier Parts</h4>
<div id='button-toolbar'> <div id='button-toolbar'>
<button class="btn btn-success" id='part-create'>New Supplier Part</button> <button class="btn btn-success" id='part-create'>New Supplier Part</button>

View File

@ -0,0 +1,37 @@
{% extends "company/company_base.html" %}
{% load static %}
{% block details %}
{% include 'company/tabs.html' with tab='po' %}
<h4>Open Purchase Orders</h4>
<div class='container' style='float: right;'>
<div class='btn-group'>
<button class='btn btn-primary' type='button' id='po-create' title='Create new purchase order'>New Purchase Order</button>
</div>
</div>
{% include "order/po_table.html" with orders=company.outstanding_purchase_orders.all %}
{% if company.closed_purchase_orders.count > 0 %}
{% include "order/po_table_collapse.html" with title="Closed Orders" orders=company.closed_purchase_orders.all %}
{% endif %}
{% endblock %}
{% block js_ready %}
{{ block.super }}
$("#po-create").click(function() {
launchModalForm("{% url 'purchase-order-create' %}",
{
data: {
supplier: {{ company.id }},
},
follow: true,
}
);
});
{% endblock %}

View File

@ -5,7 +5,7 @@
{% include "company/tabs.html" with tab='stock' %} {% include "company/tabs.html" with tab='stock' %}
<h3>Supplier Stock</h3> <h4>Supplier Stock</h4>
{% include "stock_table.html" %} {% include "stock_table.html" %}

View File

@ -1,13 +0,0 @@
{% if order.status == order.PENDING %}
<span class='label label-info'>
{% elif order.status == order.PLACED %}
<span class='label label-primary'>
{% elif order.status == order.RECEIVED %}
<span class='label label-success'>
{% elif order.status == order.CANCELLED %}
<span class='label label-warning'>
{% else %}
<span class='label label-danger'>
{% endif %}
{{ order.get_status_display }}
</span>

View File

@ -1,31 +0,0 @@
{% extends "supplier/supplier_base.html" %}
{% block details %}
{% include "supplier/tabs.html" with tab='order' %}
<h3>Supplier Orders</h3>
<table class="table table-striped">
<tr>
<th>Reference</th>
<th>Issued</th>
<th>Delivery</th>
<th>Status</th>
</tr>
{% for order in supplier.orders.all %}
<tr>
<td><a href="{% url 'supplier-order-detail' order.id %}">{{ order.internal_ref }}</a></td>
<td>{% if order.issued_date %}{{ order.issued_date }}{% endif %}</td>
<td>{% if order.delivery_date %}{{ order.delivery_date }}{% endif %}</td>
<td>{% include "supplier/order_status.html" with order=order %}</td>
</tr>
{% endfor %}
</table>
<div class='container-fluid'>
<a href="{% url 'supplier-order-create' %}?supplier={{ supplier.id }}">
<button class="btn btn-success">New Order</button>
</a>
{% endblock %}

View File

@ -101,7 +101,10 @@ InvenTree | {{ company.name }} - Parts
</div> </div>
</div> </div>
<br> <hr>
<h4>Purchase Orders</h4>
{% include "order/po_table.html" with orders=part.purchase_orders %}
{% endblock %} {% endblock %}

View File

@ -0,0 +1,7 @@
{% for order in orders %}
<tr>
<td>{{ order }}</td>
<td>{{ order.description }}</td>
<td>{% include "order/order_status.html" with order=order %}</td>
</tr>
{% endfor %}

View File

@ -9,12 +9,10 @@
<li{% if tab == 'stock' %} class='active'{% endif %}> <li{% if tab == 'stock' %} class='active'{% endif %}>
<a href="{% url 'company-detail-stock' company.id %}">Stock <span class='badge'>{{ company.stock_count }}</a> <a href="{% url 'company-detail-stock' company.id %}">Stock <span class='badge'>{{ company.stock_count }}</a>
</li> </li>
{% if 0 %}
<li{% if tab == 'po' %} class='active'{% endif %}> <li{% if tab == 'po' %} class='active'{% endif %}>
<a href="#">Purchase Orders</a> <a href="{% url 'company-detail-purchase-orders' company.id %}">Purchase Orders <span class='badge'>{{ company.purchase_orders.count }}</span></a>
</li> </li>
{% endif %} {% endif %}
{% endif %}
{% if company.is_customer %} {% if company.is_customer %}
{% if 0 %} {% if 0 %}
<li{% if tab == 'co' %} class='active'{% endif %}> <li{% if tab == 'co' %} class='active'{% endif %}>

View File

@ -17,6 +17,7 @@ company_detail_urls = [
url(r'parts/?', views.CompanyDetail.as_view(template_name='company/detail_part.html'), name='company-detail-parts'), url(r'parts/?', views.CompanyDetail.as_view(template_name='company/detail_part.html'), name='company-detail-parts'),
url(r'stock/?', views.CompanyDetail.as_view(template_name='company/detail_stock.html'), name='company-detail-stock'), url(r'stock/?', views.CompanyDetail.as_view(template_name='company/detail_stock.html'), name='company-detail-stock'),
url(r'purchase-orders/?', views.CompanyDetail.as_view(template_name='company/detail_purchase_orders.html'), name='company-detail-purchase-orders'),
url(r'thumbnail/?', views.CompanyImage.as_view(), name='company-image'), url(r'thumbnail/?', views.CompanyImage.as_view(), name='company-image'),

View File

@ -11,6 +11,7 @@ from django.views.generic import DetailView, ListView
from django.forms import HiddenInput from django.forms import HiddenInput
from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView
from InvenTree.status_codes import OrderStatus
from .models import Company from .models import Company
from .models import SupplierPart from .models import SupplierPart
@ -57,6 +58,12 @@ class CompanyDetail(DetailView):
queryset = Company.objects.all() queryset = Company.objects.all()
model = Company model = Company
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['OrderStatus'] = OrderStatus
return ctx
class CompanyImage(AjaxUpdateView): class CompanyImage(AjaxUpdateView):
""" View for uploading an image for the Company """ """ View for uploading an image for the Company """
@ -121,6 +128,12 @@ class SupplierPartDetail(DetailView):
context_object_name = 'part' context_object_name = 'part'
queryset = SupplierPart.objects.all() queryset = SupplierPart.objects.all()
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['OrderStatus'] = OrderStatus
return ctx
class SupplierPartEdit(AjaxUpdateView): class SupplierPartEdit(AjaxUpdateView):
""" Update view for editing SupplierPart """ """ Update view for editing SupplierPart """

View File

31
InvenTree/order/admin.py Normal file
View File

@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.contrib import admin
from .models import PurchaseOrder, PurchaseOrderLineItem
class PurchaseOrderAdmin(admin.ModelAdmin):
list_display = (
'reference',
'supplier',
'status',
'description',
'creation_date'
)
class PurchaseOrderLineItemAdmin(admin.ModelAdmin):
list_display = (
'order',
'part',
'quantity',
'reference'
)
admin.site.register(PurchaseOrder, PurchaseOrderAdmin)
admin.site.register(PurchaseOrderLineItem, PurchaseOrderLineItemAdmin)

5
InvenTree/order/apps.py Normal file
View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class OrderConfig(AppConfig):
name = 'order'

51
InvenTree/order/forms.py Normal file
View File

@ -0,0 +1,51 @@
"""
Django Forms for interacting with Order objects
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django import forms
from InvenTree.forms import HelperForm
from .models import PurchaseOrder, PurchaseOrderLineItem
class IssuePurchaseOrderForm(HelperForm):
confirm = forms.BooleanField(required=False, help_text='Place order')
class Meta:
model = PurchaseOrder
fields = [
'confirm',
]
class EditPurchaseOrderForm(HelperForm):
""" Form for editing a PurchaseOrder object """
class Meta:
model = PurchaseOrder
fields = [
'reference',
'supplier',
'description',
'URL',
'notes'
]
class EditPurchaseOrderLineItemForm(HelperForm):
""" Form for editing a PurchaseOrderLineItem object """
class Meta:
model = PurchaseOrderLineItem
fields = [
'order',
'part',
'quantity',
'reference',
'received'
]

View File

@ -0,0 +1,48 @@
# Generated by Django 2.2 on 2019-06-04 12:17
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('company', '0005_auto_20190525_2356'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='PurchaseOrder',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('reference', models.CharField(help_text='Order reference', max_length=64, unique=True)),
('description', models.CharField(help_text='Order description', max_length=250)),
('creation_date', models.DateField(auto_now=True)),
('issue_date', models.DateField(blank=True, null=True)),
('notes', models.TextField(blank=True, help_text='Order notes')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('supplier', models.ForeignKey(help_text='Company', on_delete=django.db.models.deletion.CASCADE, related_name='Orders', to='company.Company')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='PurchaseOrderLineItem',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(default=1, help_text='Item quantity', validators=[django.core.validators.MinValueValidator(0)])),
('reference', models.CharField(blank=True, help_text='Line item reference', max_length=100)),
('received', models.PositiveIntegerField(default=0, help_text='Number of items received')),
('order', models.ForeignKey(help_text='Purchase Order', on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='order.PurchaseOrder')),
],
options={
'abstract': False,
},
),
]

View File

@ -0,0 +1,29 @@
# Generated by Django 2.2 on 2019-06-04 12:24
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('order', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='purchaseorder',
name='URL',
field=models.URLField(blank=True, help_text='Link to external page'),
),
migrations.AlterField(
model_name='purchaseorder',
name='description',
field=models.CharField(blank=True, help_text='Order description', max_length=250),
),
migrations.AlterField(
model_name='purchaseorder',
name='supplier',
field=models.ForeignKey(help_text='Company', limit_choices_to={'is_supplier': True}, on_delete=django.db.models.deletion.CASCADE, related_name='Orders', to='company.Company'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2 on 2019-06-04 12:26
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('order', '0002_auto_20190604_2224'),
]
operations = [
migrations.AlterField(
model_name='purchaseorder',
name='supplier',
field=models.ForeignKey(help_text='Company', limit_choices_to={'is_supplier': True}, on_delete=django.db.models.deletion.CASCADE, related_name='purchase_orders', to='company.Company'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2 on 2019-06-04 12:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('order', '0003_auto_20190604_2226'),
]
operations = [
migrations.AddField(
model_name='purchaseorder',
name='status',
field=models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Placed'), (30, 'Complete'), (40, 'Cancelled'), (50, 'Lost'), (60, 'Returned')], default=10, help_text='Order status'),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 2.2 on 2019-06-05 10:36
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('company', '0005_auto_20190525_2356'),
('order', '0004_purchaseorder_status'),
]
operations = [
migrations.AddField(
model_name='purchaseorderlineitem',
name='part',
field=models.ForeignKey(blank=True, help_text='Supplier part', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='company.SupplierPart'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2 on 2019-06-05 10:56
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('company', '0005_auto_20190525_2356'),
('order', '0005_purchaseorderlineitem_part'),
]
operations = [
migrations.AlterUniqueTogether(
name='purchaseorderlineitem',
unique_together={('order', 'part')},
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2 on 2019-06-05 11:38
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('order', '0006_auto_20190605_2056'),
]
operations = [
migrations.AlterField(
model_name='purchaseorderlineitem',
name='part',
field=models.ForeignKey(blank=True, help_text='Supplier part', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_line_items', to='company.SupplierPart'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2 on 2019-06-05 11:40
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('order', '0007_auto_20190605_2138'),
]
operations = [
migrations.AlterField(
model_name='purchaseorderlineitem',
name='part',
field=models.ForeignKey(blank=True, help_text='Supplier part', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='purchase_order_line_items', to='company.SupplierPart'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2 on 2019-06-06 11:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('order', '0008_auto_20190605_2140'),
]
operations = [
migrations.AlterField(
model_name='purchaseorder',
name='description',
field=models.CharField(help_text='Order description', max_length=250),
),
]

View File

150
InvenTree/order/models.py Normal file
View File

@ -0,0 +1,150 @@
"""
Order model definitions
"""
# -*- coding: utf-8 -*-
from django.db import models
from django.core.validators import MinValueValidator
from django.contrib.auth.models import User
from django.urls import reverse
from django.utils.translation import ugettext as _
from datetime import datetime
from company.models import Company, SupplierPart
from InvenTree.status_codes import OrderStatus
class Order(models.Model):
""" Abstract model for an order.
Instances of this class:
- PuchaseOrder
Attributes:
reference: Unique order number / reference / code
description: Long form description (required)
notes: Extra note field (optional)
creation_date: Automatic date of order creation
created_by: User who created this order (automatically captured)
issue_date: Date the order was issued
"""
ORDER_PREFIX = ""
def __str__(self):
el = []
if self.ORDER_PREFIX:
el.append(self.ORDER_PREFIX)
el.append(self.reference)
return " ".join(el)
class Meta:
abstract = True
reference = models.CharField(unique=True, max_length=64, blank=False, help_text=_('Order reference'))
description = models.CharField(max_length=250, help_text=_('Order description'))
URL = models.URLField(blank=True, help_text=_('Link to external page'))
creation_date = models.DateField(auto_now=True, editable=False)
status = models.PositiveIntegerField(default=OrderStatus.PENDING, choices=OrderStatus.items(),
help_text='Order status')
created_by = models.ForeignKey(User,
on_delete=models.SET_NULL,
blank=True, null=True,
related_name='+'
)
issue_date = models.DateField(blank=True, null=True)
notes = models.TextField(blank=True, help_text=_('Order notes'))
def place_order(self):
""" Marks the order as PLACED. Order must be currently PENDING. """
if self.status == OrderStatus.PENDING:
self.status = OrderStatus.PLACED
self.issue_date = datetime.now().date()
self.save()
class PurchaseOrder(Order):
""" A PurchaseOrder represents goods shipped inwards from an external supplier.
Attributes:
supplier: Reference to the company supplying the goods in the order
"""
ORDER_PREFIX = "PO"
supplier = models.ForeignKey(
Company, on_delete=models.CASCADE,
limit_choices_to={
'is_supplier': True,
},
related_name='purchase_orders',
help_text=_('Company')
)
def get_absolute_url(self):
return reverse('purchase-order-detail', kwargs={'pk': self.id})
class OrderLineItem(models.Model):
""" Abstract model for an order line item
Attributes:
quantity: Number of items
note: Annotation for the item
"""
class Meta:
abstract = True
quantity = models.PositiveIntegerField(validators=[MinValueValidator(0)], default=1, help_text=_('Item quantity'))
reference = models.CharField(max_length=100, blank=True, help_text=_('Line item reference'))
class PurchaseOrderLineItem(OrderLineItem):
""" Model for a purchase order line item.
Attributes:
order: Reference to a PurchaseOrder object
"""
class Meta:
unique_together = (
('order', 'part')
)
order = models.ForeignKey(
PurchaseOrder, on_delete=models.CASCADE,
related_name='lines',
help_text=_('Purchase Order')
)
# TODO - Function callback for when the SupplierPart is deleted?
part = models.ForeignKey(
SupplierPart, on_delete=models.SET_NULL,
blank=True, null=True,
related_name='purchase_order_line_items',
help_text=_("Supplier part"),
)
received = models.PositiveIntegerField(default=0, help_text=_('Number of items received'))

View File

@ -0,0 +1,7 @@
{% extends "modal_form.html" %}
{% block pre_form_content %}
After placing this purchase order, line items will no longer be editable.
{% endblock %}

View File

@ -0,0 +1,13 @@
{% if order.status == OrderStatus.PENDING %}
<span class='label label-large label-info'>
{% elif order.status == OrderStatus.PLACED %}
<span class='label label-large label-primary'>
{% elif order.status == OrderStatus.COMPLETE %}
<span class='label label-large label-success'>
{% elif order.status == OrderStatus.CANCELLED or order.status == OrderStatus.RETURNED %}
<span class='label label-large label-warning'>
{% else %}
<span class='label label-large label-danger'>
{% endif %}
{{ order.get_status_display }}
</span>

View File

@ -0,0 +1,16 @@
<table class='table table-striped table-condensed' id='po-table'>
<tr>
<th data-field='company'>Company</th>
<th data-field='reference'>Order Reference</th>
<th data-field='description'>Description</th>
<th data-field='status'>Status</th>
</tr>
{% for order in orders %}
<tr>
<td>{% include "hover_image.html" with image=order.supplier.image hover=True %}<a href="{{ order.supplier.get_absolute_url }}purchase-orders/">{{ order.supplier.name }}</a></td>
<td><a href="{% url 'purchase-order-detail' order.id %}">{{ order }}</a></td>
<td>{{ order.description }}</td>
<td>{% include "order/order_status.html" %}</td>
</tr>
{% endfor %}
</table>

View File

@ -0,0 +1,11 @@
{% extends "collapse.html" %}
{% load static %}
{% block collapse_title %}
<h4>{{ title }}</h4>
{% endblock %}
{% block collapse_content %}
{% include "order/po_table.html" %}
{% endblock %}

View File

@ -0,0 +1,154 @@
{% extends "base.html" %}
{% load static %}
{% block page_title %}
InvenTree | {{ order }}
{% endblock %}
{% block content %}
<div class='row'>
<div class='col-sm-6'>
<div class='media'>
<div class='media-left'>
<img class='part-thumb'
{% if order.supplier.image %}
src="{{ order.supplier.image.url }}"
{% else %}
src="{% static 'img/blank_image.png' %}"
{% endif %}
/>
</div>
<div class='media-body'>
<h4>{{ order }}</h4>
<p>{{ order.description }}</p>
{% if order.URL %}
<a href="{{ order.URL }}">{{ order.URL }}</a>
{% endif %}
</div>
</div>
</div>
<div class='col-sm-6'>
<h4>Purchase Order Details</h4>
<table class='table'>
<tr>
<td>Supplier</td>
<td><a href="{% url 'company-detail' order.supplier.id %}">{{ order.supplier }}</a></td>
</tr>
<tr>
<td>Status</td>
<td>{% include "order/order_status.html" %}</td>
</tr>
<tr>
<td>Created</td>
<td>{{ order.creation_date }}</td>
</tr>
<tr>
<td>Created By</td>
<td>{{ order.created_by }}</td>
</tr>
{% if order.issue_date %}
<tr>
<td>Issued</td>
<td>{{ order.issue_date }}</td>
</tr>
{% endif %}
</table>
</div>
</div>
<hr>
<div class='btn-group' style='float: right;'>
<button type='button' class='btn btn-primary' id='edit-order'>Edit Order</button>
{% if order.status == OrderStatus.PENDING and order.lines.count > 0 %}
<button type='button' class='btn btn-primary' id='place-order'>Place Order</button>
{% endif %}
</div>
<h4>Order Items</h4>
{% if order.status == OrderStatus.PENDING %}
<button type='button' class='btn btn-default' id='new-po-line'>Add Line Item</button>
{% endif %}
<table class='table table-striped table-condensed' id='po-lines-table'>
<tr>
<th data-field='line'>Line</th>
<th data-field='part'>Part</th>
<th data-field='sku'>Order Code</th>
<th data-field='reference'>Reference</th>
<th data-field='quantity'>Quantity</th>
<th data-field='received'>Received</th>
</tr>
{% for line in order.lines.all %}
<tr>
<td>{{ forloop.counter }}</td>
{% if line.part %}
<td><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>
{% else %}
<td colspan='2'><strong>Warning: Part has been deleted.</strong></td>
{% endif %}
<td>{{ line.reference }}</td>
<td>{{ line.quantity }}</td>
<td>{{ line.received }}</td>
</tr>
{% endfor %}
</table>
{% if order.notes %}
<hr>
<div class='panel panel-default'>
<div class='panel-heading'><b>Notes</b></div>
<div class='panel-body'>{{ order.notes }}</div>
</div>
{% endif %}
{% endblock %}
{% block js_ready %}
$("#place-order").click(function() {
launchModalForm("{% url 'purchase-order-issue' order.id %}",
{
reload: true,
});
});
$("#edit-order").click(function() {
launchModalForm("{% url 'purchase-order-edit' order.id %}",
{
reload: true,
}
);
});
{% if order.status == OrderStatus.PENDING %}
$('#new-po-line').click(function() {
launchModalForm("{% url 'po-line-item-create' %}",
{
reload: true,
data: {
order: {{ order.id }},
},
secondary: [
{
field: 'part',
label: 'New Supplier Part',
title: 'Create new supplier part',
url: "{% url 'supplier-part-create' %}",
data: {
supplier: {{ order.supplier.id }},
},
},
],
}
);
});
{% endif %}
$("#po-lines-table").bootstrapTable({
});
{% endblock %}

View File

@ -0,0 +1,37 @@
{% extends "base.html" %}
{% load static %}
{% block page_title %}
InvenTree | Purchase Orders
{% endblock %}
{% block content %}
<div class='row'>
<div class='col-sm-6'>
<h3>Purchase Orders</h3>
</div>
<div class='col-sm-6'>
<div class='btn-group' style='float: right;'>
<button class='btn btn-primary' type='button' id='po-create' title='Create new purchase order'>New Purchase Order</button>
</div>
</div>
</div>
{% include "order/po_table.html" %}
{% endblock %}
{% block js_ready %}
{{ block.super }}
$("#po-create").click(function() {
launchModalForm("{% url 'purchase-order-create' %}",
{
reload: true,
}
);
});
{% endblock %}

1
InvenTree/order/tests.py Normal file
View File

@ -0,0 +1 @@
# TODO - Implement tests for the order app

40
InvenTree/order/urls.py Normal file
View File

@ -0,0 +1,40 @@
"""
URL lookup for the Order app. Provides URL endpoints for:
- List view of Purchase Orders
- Detail view of Purchase Orders
"""
from django.conf.urls import url, include
from . import views
purchase_order_detail_urls = [
url(r'^edit/?', views.PurchaseOrderEdit.as_view(), name='purchase-order-edit'),
url(r'^issue/?', views.PurchaseOrderIssue.as_view(), name='purchase-order-issue'),
url(r'^.*$', views.PurchaseOrderDetail.as_view(), name='purchase-order-detail'),
]
po_line_urls = [
url(r'^new/', views.POLineItemCreate.as_view(), name='po-line-item-create'),
]
purchase_order_urls = [
url(r'^new/', views.PurchaseOrderCreate.as_view(), name='purchase-order-create'),
# Display detail view for a single purchase order
url(r'^(?P<pk>\d+)/', include(purchase_order_detail_urls)),
url(r'^line/', include(po_line_urls)),
# Display complete list of purchase orders
url(r'^.*$', views.PurchaseOrderIndex.as_view(), name='purchase-order-index'),
]
order_urls = [
url(r'^purchase-order/', include(purchase_order_urls)),
]

236
InvenTree/order/views.py Normal file
View File

@ -0,0 +1,236 @@
"""
Django views for interacting with Order app
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.utils.translation import ugettext as _
from django.views.generic import DetailView, ListView
from django.forms import HiddenInput
from .models import PurchaseOrder, PurchaseOrderLineItem
from company.models import Company, SupplierPart
from . import forms as order_forms
from InvenTree.views import AjaxCreateView, AjaxUpdateView
from InvenTree.helpers import str2bool
from InvenTree.status_codes import OrderStatus
class PurchaseOrderIndex(ListView):
""" List view for all purchase orders """
model = PurchaseOrder
template_name = 'order/purchase_orders.html'
context_object_name = 'orders'
def get_queryset(self):
""" Retrieve the list of purchase orders,
ensure that the most recent ones are returned first. """
queryset = PurchaseOrder.objects.all().order_by('-creation_date')
return queryset
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['OrderStatus'] = OrderStatus
return ctx
class PurchaseOrderDetail(DetailView):
""" Detail view for a PurchaseOrder object """
context_object_name = 'order'
queryset = PurchaseOrder.objects.all().prefetch_related('lines')
template_name = 'order/purchase_order_detail.html'
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['OrderStatus'] = OrderStatus
return ctx
class PurchaseOrderCreate(AjaxCreateView):
""" View for creating a new PurchaseOrder object using a modal form """
model = PurchaseOrder
ajax_form_title = "Create Purchase Order"
form_class = order_forms.EditPurchaseOrderForm
def get_initial(self):
initials = super().get_initial().copy()
initials['status'] = OrderStatus.PENDING
supplier_id = self.request.GET.get('supplier', None)
if supplier_id:
try:
supplier = Company.objects.get(id=supplier_id)
initials['supplier'] = supplier
except Company.DoesNotExist:
pass
return initials
class PurchaseOrderEdit(AjaxUpdateView):
""" View for editing a PurchaseOrder using a modal form """
model = PurchaseOrder
ajax_form_title = 'Edit Purchase Order'
form_class = order_forms.EditPurchaseOrderForm
def get_form(self):
form = super(AjaxUpdateView, self).get_form()
order = self.get_object()
# Prevent user from editing supplier if there are already lines in the order
if order.lines.count() > 0 or not order.status == OrderStatus.PENDING:
form.fields['supplier'].widget = HiddenInput()
return form
class PurchaseOrderIssue(AjaxUpdateView):
""" View for changing a purchase order from 'PENDING' to 'ISSUED' """
model = PurchaseOrder
ajax_form_title = 'Issue Order'
ajax_template_name = "order/order_issue.html"
form_class = order_forms.IssuePurchaseOrderForm
def post(self, request, *args, **kwargs):
""" Mark the purchase order as 'PLACED' """
order = self.get_object()
form = self.get_form()
confirm = str2bool(request.POST.get('confirm', False))
valid = False
if not confirm:
form.errors['confirm'] = [_('Confirm order placement')]
else:
valid = True
data = {
'form_valid': valid,
}
if valid:
order.place_order()
return self.renderJsonResponse(request, form, data)
class POLineItemCreate(AjaxCreateView):
""" AJAX view for creating a new PurchaseOrderLineItem object
"""
model = PurchaseOrderLineItem
context_object_name = 'line'
form_class = order_forms.EditPurchaseOrderLineItemForm
ajax_form_title = 'Add Line Item'
def post(self, request, *arg, **kwargs):
self.request = request
form = self.get_form()
valid = form.is_valid()
part_id = form['part'].value()
try:
SupplierPart.objects.get(id=part_id)
except (SupplierPart.DoesNotExist, ValueError):
valid = False
form.errors['part'] = [_('This field is required')]
data = {
'form_valid': valid,
}
if valid:
self.object = form.save()
data['pk'] = self.object.pk
data['text'] = str(self.object)
else:
self.object = None
return self.renderJsonResponse(request, form, data,)
def get_form(self):
""" Limit choice options based on the selected order, etc
"""
form = super().get_form()
order_id = form['order'].value()
try:
order = PurchaseOrder.objects.get(id=order_id)
query = form.fields['part'].queryset
# Only allow parts from the selected supplier
query = query.filter(supplier=order.supplier.id)
exclude = []
for line in order.lines.all():
if line.part and line.part.id not in exclude:
exclude.append(line.part.id)
# Remove parts that are already in the order
query = query.exclude(id__in=exclude)
form.fields['part'].queryset = query
form.fields['order'].widget = HiddenInput()
except PurchaseOrder.DoesNotExist:
pass
return form
def get_initial(self):
""" Extract initial data for the line item.
- The 'order' will be passed as a query parameter
- Use this to set the 'order' field and limit the options for 'part'
"""
initials = super().get_initial().copy()
order_id = self.request.GET.get('order', None)
if order_id:
try:
order = PurchaseOrder.objects.get(id=order_id)
initials['order'] = order
except PurchaseOrder.DoesNotExist:
pass
return initials
class POLineItemEdit(AjaxUpdateView):
model = PurchaseOrderLineItem
form_class = order_forms.EditPurchaseOrderLineItemForm
ajax_template_name = 'modal_form.html'
ajax_form_action = 'Edit Line Item'

View File

@ -33,6 +33,8 @@ from InvenTree import helpers
from InvenTree import validators from InvenTree import validators
from InvenTree.models import InvenTreeTree from InvenTree.models import InvenTreeTree
from InvenTree.status_codes import BuildStatus, StockStatus, OrderStatus
from company.models import SupplierPart from company.models import SupplierPart
@ -425,7 +427,7 @@ class Part(models.Model):
then we need to restock. then we need to restock.
""" """
return (self.total_stock - self.allocation_count) < self.minimum_stock return (self.total_stock + self.on_order - self.allocation_count) < self.minimum_stock
@property @property
def can_build(self): def can_build(self):
@ -454,14 +456,14 @@ class Part(models.Model):
Builds marked as 'complete' or 'cancelled' are ignored Builds marked as 'complete' or 'cancelled' are ignored
""" """
return [b for b in self.builds.all() if b.is_active] return self.builds.filter(status__in=BuildStatus.ACTIVE_CODES)
@property @property
def inactive_builds(self): def inactive_builds(self):
""" Return a list of inactive builds """ Return a list of inactive builds
""" """
return [b for b in self.builds.all() if not b.is_active] return self.builds.exclude(status__in=BuildStatus.ACTIVE_CODES)
@property @property
def quantity_being_built(self): def quantity_being_built(self):
@ -531,7 +533,7 @@ class Part(models.Model):
if self.is_template: if self.is_template:
total = sum([variant.total_stock for variant in self.variants.all()]) total = sum([variant.total_stock for variant in self.variants.all()])
else: else:
total = self.stock_entries.aggregate(total=Sum('quantity'))['total'] total = self.stock_entries.filter(status__in=StockStatus.AVAILABLE_CODES).aggregate(total=Sum('quantity'))['total']
if total: if total:
return total return total
@ -792,6 +794,34 @@ class Part(models.Model):
return n return n
def purchase_orders(self):
""" Return a list of purchase orders which reference this part """
orders = []
for part in self.supplier_parts.all().prefetch_related('purchase_order_line_items'):
for order in part.purchase_orders():
if order not in orders:
orders.append(order)
return orders
def open_purchase_orders(self):
""" Return a list of open purchase orders against this part """
return [order for order in self.purchase_orders() if order.status in OrderStatus.OPEN]
def closed_purchase_orders(self):
""" Return a list of closed purchase orders against this part """
return [order for order in self.purchase_orders() if order.status not in OrderStatus.OPEN]
@property
def on_order(self):
""" Return the total number of items on order for this part. """
return sum([part.on_order() for part in self.supplier_parts.all().prefetch_related('purchase_order_line_items')])
def attach_file(instance, filename): def attach_file(instance, filename):
""" Function for storing a file for a PartAttachment """ Function for storing a file for a PartAttachment

View File

@ -0,0 +1,23 @@
{% extends "part/part_base.html" %}
{% load static %}
{% block details %}
{% include 'part/tabs.html' with tab='orders' %}
<div class='row'>
<div class='col-sm-6'>
<h4>Open Part Orders</h4>
</div>
<div class='col-sm-6'>
</div>
</div>
{% include "order/po_table.html" with orders=part.open_purchase_orders %}
{% if part.closed_purchase_orders|length > 0 %}
<h4>Closed Orders</h4>
{% include "order/po_table.html" with orders=part.closed_purchase_orders %}
{% endif %}
{% endblock %}

View File

@ -96,6 +96,12 @@
<td>{{ part.allocation_count }}</td> <td>{{ part.allocation_count }}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if part.on_order > 0 %}
<tr>
<td>On Order</td>
<td>{{ part.on_order }}</td>
</tr>
{% endif %}
</table> </table>
</div> </div>
</div> </div>

View File

@ -25,11 +25,17 @@
<li{% ifequal tab 'used' %} class="active"{% endifequal %}> <li{% ifequal tab 'used' %} class="active"{% endifequal %}>
<a href="{% url 'part-used-in' part.id %}">Used In{% if part.used_in_count > 0 %}<span class="badge">{{ part.used_in_count }}</span>{% endif %}</a></li> <a href="{% url 'part-used-in' part.id %}">Used In{% if part.used_in_count > 0 %}<span class="badge">{{ part.used_in_count }}</span>{% endif %}</a></li>
{% endif %} {% endif %}
{% if part.purchaseable and part.is_template == False %} {% if part.purchaseable %}
{% if part.is_template == False %}
<li{% ifequal tab 'suppliers' %} class="active"{% endifequal %}> <li{% ifequal tab 'suppliers' %} class="active"{% endifequal %}>
<a href="{% url 'part-suppliers' part.id %}">Suppliers <a href="{% url 'part-suppliers' part.id %}">Suppliers
<span class="badge">{{ part.supplier_count }}</span> <span class="badge">{{ part.supplier_count }}</span>
</a></li> </a>
</li>
{% endif %}
<li{% ifequal tab 'orders' %} class='active'{% endifequal %}>
<a href="{% url 'part-orders' part.id %}">Purchase Orders <span class='badge'>{{ part.purchase_orders|length }}</span></a>
</li>
{% endif %} {% endif %}
{% if part.trackable and 0 %} {% if part.trackable and 0 %}
<li{% ifequal tab 'track' %} class="active"{% endifequal %}> <li{% ifequal tab 'track' %} class="active"{% endifequal %}>

View File

@ -34,6 +34,7 @@ part_detail_urls = [
url(r'^build/?', views.PartDetail.as_view(template_name='part/build.html'), name='part-build'), url(r'^build/?', views.PartDetail.as_view(template_name='part/build.html'), name='part-build'),
url(r'^used/?', views.PartDetail.as_view(template_name='part/used_in.html'), name='part-used-in'), url(r'^used/?', views.PartDetail.as_view(template_name='part/used_in.html'), name='part-used-in'),
url(r'^suppliers/?', views.PartDetail.as_view(template_name='part/supplier.html'), name='part-suppliers'), url(r'^suppliers/?', views.PartDetail.as_view(template_name='part/supplier.html'), name='part-suppliers'),
url(r'^orders/?', views.PartDetail.as_view(template_name='part/orders.html'), name='part-orders'),
url(r'^track/?', views.PartDetail.as_view(template_name='part/track.html'), name='part-track'), url(r'^track/?', views.PartDetail.as_view(template_name='part/track.html'), name='part-track'),
url(r'^attachments/?', views.PartDetail.as_view(template_name='part/attachments.html'), name='part-attachments'), url(r'^attachments/?', views.PartDetail.as_view(template_name='part/attachments.html'), name='part-attachments'),

View File

@ -24,6 +24,7 @@ from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDelete
from InvenTree.views import QRCodeView from InvenTree.views import QRCodeView
from InvenTree.helpers import DownloadFile, str2bool from InvenTree.helpers import DownloadFile, str2bool
from InvenTree.status_codes import OrderStatus
class PartIndex(ListView): class PartIndex(ListView):
@ -446,6 +447,8 @@ class PartDetail(DetailView):
context['starred'] = part.isStarredBy(self.request.user) context['starred'] = part.isStarredBy(self.request.user)
context['OrderStatus'] = OrderStatus
return context return context

View File

@ -38,7 +38,6 @@
/* Extra label styles */ /* Extra label styles */
.label-large { .label-large {
padding: 5px;
margin: 3px; margin: 3px;
font-size: 100%; font-size: 100%;
} }
@ -297,6 +296,10 @@
margin-bottom: 5px; margin-bottom: 5px;
} }
.panel-body {
padding: 10px;
}
.panel-group .panel { .panel-group .panel {
border-radius: 2px; border-radius: 2px;
} }

View File

@ -195,6 +195,18 @@ function loadStockTable(table, options) {
stock.push(item.pk); stock.push(item.pk);
}); });
// Buttons for launching secondary modals
var secondary = [];
if (action == 'move') {
secondary.push({
field: 'destination',
label: 'New Location',
title: 'Create new location',
url: "/stock/location/new/",
});
}
launchModalForm("/stock/adjust/", launchModalForm("/stock/adjust/",
{ {
data: { data: {
@ -204,6 +216,7 @@ function loadStockTable(table, options) {
success: function() { success: function() {
$("#stock-table").bootstrapTable('refresh'); $("#stock-table").bootstrapTable('refresh');
}, },
secondary: secondary,
} }
); );
} }

View File

@ -20,6 +20,7 @@ from .serializers import StockTrackingSerializer
from InvenTree.views import TreeSerializer from InvenTree.views import TreeSerializer
from InvenTree.helpers import str2bool from InvenTree.helpers import str2bool
from InvenTree.status_codes import StockStatus
import os import os
@ -311,7 +312,7 @@ class StockList(generics.ListCreateAPIView):
else: else:
item['location__path'] = None item['location__path'] = None
item['status_text'] = StockItem.ITEM_STATUS_CODES[item['status']] item['status_text'] = StockStatus.label(item['status'])
return Response(data) return Response(data)

View File

@ -19,6 +19,7 @@ from django.dispatch import receiver
from datetime import datetime from datetime import datetime
from InvenTree import helpers from InvenTree import helpers
from InvenTree.status_codes import StockStatus
from InvenTree.models import InvenTreeTree from InvenTree.models import InvenTreeTree
from part.models import Part from part.models import Part
@ -93,7 +94,7 @@ class StockItem(models.Model):
stocktake_user: User that performed the most recent stocktake stocktake_user: User that performed the most recent stocktake
review_needed: Flag if StockItem needs review review_needed: Flag if StockItem needs review
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: ITEM_STATUS_CODES) status: Status of this StockItem (ref: InvenTree.status_codes.StockStatus)
notes: Extra notes field notes: Extra notes field
infinite: If True this StockItem can never be exhausted infinite: If True this StockItem can never be exhausted
""" """
@ -256,23 +257,9 @@ class StockItem(models.Model):
delete_on_deplete = models.BooleanField(default=True, help_text='Delete this Stock Item when stock is depleted') delete_on_deplete = models.BooleanField(default=True, help_text='Delete this Stock Item when stock is depleted')
ITEM_OK = 10
ITEM_ATTENTION = 50
ITEM_DAMAGED = 55
ITEM_DESTROYED = 60
ITEM_LOST = 70
ITEM_STATUS_CODES = {
ITEM_OK: _("OK"),
ITEM_ATTENTION: _("Attention needed"),
ITEM_DAMAGED: _("Damaged"),
ITEM_DESTROYED: _("Destroyed"),
ITEM_LOST: _("Lost")
}
status = models.PositiveIntegerField( status = models.PositiveIntegerField(
default=ITEM_OK, default=StockStatus.OK,
choices=ITEM_STATUS_CODES.items(), choices=StockStatus.items(),
validators=[MinValueValidator(0)]) validators=[MinValueValidator(0)])
notes = models.CharField(max_length=250, blank=True, help_text='Stock Item Notes') notes = models.CharField(max_length=250, blank=True, help_text='Stock Item Notes')

View File

@ -25,6 +25,8 @@ InvenTree | Search Results
{% include "InvenTree/search_parts.html" with collapse_id='parts' %} {% include "InvenTree/search_parts.html" with collapse_id='parts' %}
{% include "InvenTree/search_company.html" with collapse_id='companies' %}
{% include "InvenTree/search_supplier_parts.html" with collapse_id='supplier_parts' %} {% include "InvenTree/search_supplier_parts.html" with collapse_id='supplier_parts' %}
{% include "InvenTree/search_stock_location.html" with collapse_id="locations" %} {% include "InvenTree/search_stock_location.html" with collapse_id="locations" %}
@ -77,6 +79,8 @@ InvenTree | Search Results
onSearchResults('#part-results-table', '#part-result-count'); onSearchResults('#part-results-table', '#part-result-count');
onSearchResults('#company-results-table', '#company-result-count');
onSearchResults('#supplier-part-results-table', '#supplier-part-result-count'); onSearchResults('#supplier-part-results-table', '#supplier-part-result-count');
$("#category-results-table").bootstrapTable({ $("#category-results-table").bootstrapTable({
@ -130,6 +134,29 @@ InvenTree | Search Results
} }
); );
$("#company-results-table").bootstrapTable({
url: "{% url 'api-company-list' %}",
queryParams: {
search: "{{ query }}",
},
pagination: true,
pageSize: 25,
search: true,
columns: [
{
field: 'name',
title: 'Name',
formatter: function(value, row, index, field) {
return imageHoverIcon(row.image) + renderLink(value, row.url);
},
},
{
field: 'description',
title: 'Description',
},
]
});
$("#supplier-part-results-table").bootstrapTable({ $("#supplier-part-results-table").bootstrapTable({
url: "{% url 'api-part-supplier-list' %}", url: "{% url 'api-part-supplier-list' %}",
queryParams: { queryParams: {

View File

@ -0,0 +1,14 @@
{% extends "collapse.html" %}
{% block collapse_title %}
<h4>Companies</h4>
{% endblock %}
{% block collapse_heading %}
<h4><span id='company-result-count'>{% include "InvenTree/searching.html" %}</span></h4>
{% endblock %}
{% block collapse_content %}
<table class='table table-striped table-condensed' data-toolbar="#button-toolbar" id='company-results-table'>
</table>
{% endblock %}

View File

@ -1,10 +1,10 @@
{% if build.status == build.PENDING %} {% if build.status == BuildStatus.PENDING %}
<span class='label label-large label-info'> <span class='label label-large label-info'>
{% elif build.status == build.ALLOCATED %} {% elif build.status == BuildStatus.ALLOCATED %}
<span class='label label-large label-primary'> <span class='label label-large label-primary'>
{% elif build.status == build.CANCELLED %} {% elif build.status == BuildStatus.CANCELLED %}
<span class='label label-large label-danger'> <span class='label label-large label-danger'>
{% elif build.status == build.COMPLETE %} {% elif build.status == BuildStatus.COMPLETE %}
<span class='label label-large label-success'> <span class='label label-large label-success'>
{% endif %} {% endif %}
{{ build.get_status_display }} {{ build.get_status_display }}

View File

@ -10,6 +10,7 @@
<li><a href="{% url 'stock-index' %}">Stock</a></li> <li><a href="{% url 'stock-index' %}">Stock</a></li>
<li><a href="{% url 'build-index' %}">Build</a></li> <li><a href="{% url 'build-index' %}">Build</a></li>
<li><a href="{% url 'company-index' %}">Suppliers</a></li> <li><a href="{% url 'company-index' %}">Suppliers</a></li>
<li><a href="{% url 'purchase-order-index' %}">Orders</a></li>
</ul> </ul>
<ul class="nav navbar-nav navbar-right"> <ul class="nav navbar-nav navbar-right">
{% include "search_form.html" %} {% include "search_form.html" %}

View File

@ -2,16 +2,18 @@
<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>Allocated</th> <th>On Order</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><a href="{% url 'part-detail' part.id %}">{{ part.full_name }}</a></td>
<td>{{ part.description }}</td> <td>{{ part.description }}</td>
<td>{{ part.total_stock }}</td>
<td>{{ part.allocation_count }}</td> <td>{{ part.allocation_count }}</td>
<td>{{ part.total_stock }}</td>
<td>{{ part.on_order }}</td>
<td>{{ part.available_stock }}</td> <td>{{ part.available_stock }}</td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@ -12,6 +12,7 @@ migrate:
python InvenTree/manage.py makemigrations part python InvenTree/manage.py makemigrations part
python InvenTree/manage.py makemigrations stock python InvenTree/manage.py makemigrations stock
python InvenTree/manage.py makemigrations build python InvenTree/manage.py makemigrations build
python InvenTree/manage.py makemigrations order
python InvenTree/manage.py migrate --run-syncdb python InvenTree/manage.py migrate --run-syncdb
python InvenTree/manage.py check python InvenTree/manage.py check
@ -27,11 +28,11 @@ style:
test: test:
python InvenTree/manage.py check python InvenTree/manage.py check
python InvenTree/manage.py test build company part stock python InvenTree/manage.py test build company part stock order
coverage: coverage:
python InvenTree/manage.py check python InvenTree/manage.py check
coverage run InvenTree/manage.py test build company part stock coverage run InvenTree/manage.py test build company part stock order
coverage html coverage html
documentation: documentation: