Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2020-04-27 22:18:45 +10:00
commit b338834146
135 changed files with 7725 additions and 2360 deletions

View File

@ -18,9 +18,10 @@ from .version import inventreeVersion, inventreeInstanceName
from plugins import plugins as inventree_plugins
# Load barcode plugins
print("INFO: Loading plugins")
print("Loading barcode plugins")
barcode_plugins = inventree_plugins.load_barcode_plugins()
print("Loading action plugins")
action_plugins = inventree_plugins.load_action_plugins()
@ -136,7 +137,4 @@ class BarcodePluginView(APIView):
# Include the original barcode data
response['barcode_data'] = barcode_data
print("Response:")
print(response)
return Response(response)

View File

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
"""
Provides extra global data to all templates.
"""
from InvenTree.status_codes import SalesOrderStatus, PurchaseOrderStatus
from InvenTree.status_codes import BuildStatus, StockStatus
def status_codes(request):
return {
# Expose the StatusCode classes to the templates
'SalesOrderStatus': SalesOrderStatus,
'PurchaseOrderStatus': PurchaseOrderStatus,
'BuildStatus': BuildStatus,
'StockStatus': StockStatus,
}

View File

@ -69,5 +69,7 @@ class RoundingDecimalField(models.DecimalField):
defaults = {
'form_class': RoundingDecimalFormField
}
defaults.update(kwargs)
return super(RoundingDecimalField, self).formfield(**kwargs)
return super().formfield(**kwargs)

View File

@ -145,8 +145,10 @@ TEMPLATES = [
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.template.context_processors.i18n',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'InvenTree.context.status_codes',
],
},
},
@ -203,10 +205,12 @@ When running unit tests, enforce usage of sqlite3 database,
so that the tests can be run in RAM without any setup requirements
"""
if 'test' in sys.argv:
eprint('Running tests - Using sqlite3 memory database')
eprint('InvenTree: Running tests - Using sqlite3 memory database')
DATABASES['default'] = {
# Ensure sqlite3 backend is being used
'ENGINE': 'django.db.backends.sqlite3',
'NAME': 'test_db.sqlite3'
# Doesn't matter what the database is called, it is executed in RAM
'NAME': 'ram_test_db.sqlite3',
}
# Database backend selection

View File

@ -3,6 +3,12 @@
--secondary-color: #b69c80;
--highlight-color: #f5efe8;
--basic-color: #333;
--label-red: #e35a57;
--label-blue: #4194bd;
--label-green: #50aa51;
--label-grey: #aaa;
--label-yellow: #fdc82a;
}
.markdownx .row {
@ -29,6 +35,38 @@
padding: 10px;
}
/* Progress bars */
.progress {
position: relative;
width: 100%;
margin-bottom: 0px;
background: #eeeef5;
}
.progress-bar {
opacity: 60%;
background: #2aa02a;
}
.progress-bar-under {
background: #eeaa33;
}
.progress-bar-over {
background: #337ab7;
}
.progress-value {
width: 100%;
color: #333;
position: absolute;
text-align: center;
top: 0px;
left: 0px;
font-size: 110%;
}
.qr-code {
max-width: 400px;
max-height: 400px;
@ -79,24 +117,20 @@
color: rgb(13, 245, 25);
}
.glyphicon-ok {
color: #5C5;
.icon-red {
color: #c55;
}
.glyphicon-ok-circle {
.icon-green {
color: #43bb43;
}
.icon-blue {
color: #55c;
}
.glyphicon-remove {
color: #C55;
}
.glyphicon-trash {
color: #C55;
}
.glyphicon-plus {
color: #5C5;
.icon-yellow {
color: #CC2;
}
/* CSS overrides for treeview */
@ -121,6 +155,58 @@
.label-large {
margin: 3px;
font-size: 100%;
border: 3px solid;
border-radius: 15px;
background: none;
padding-right: 10px;
padding-left: 10px;
padding-top: 5px;
padding-bottom: 5px;
}
.label-large-red {
color: var(--label-red);
border-color: var(--label-red);
}
.label-red {
background: var(--label-red);
}
.label-large-blue {
color: var(--label-blue);
border-color: var(--label-blue);
}
.label-blue {
background: var(--label-blue);
}
.label-large-green {
color: var(--label-green);
border-color: var(--label-green);
}
.label-green {
background: var(--label-green);
}
.label-large-grey {
color: var(--label-grey);
border-color: var(--label-grey);
}
.label-grey {
background: var(--label-grey);
}
.label-large-yellow {
color: var(--label-yellow);
border-color: var(--label-yellow);
}
.label-yellow {
background: var(--label-yellow);
}
.label-right {
@ -135,6 +221,15 @@
background-color: #ebf4f4;
}
.sub-table {
margin-left: 45px;
margin-right: 45px;
}
.detail-icon .glyphicon {
color: #98d296;
}
/* Force select2 elements in modal forms to be full width */
.select-full-width {
width: 100%;
@ -248,7 +343,6 @@
/* dropzone class - for Drag-n-Drop file uploads */
.dropzone {
border: 1px solid #555;
z-index: 2;
}
@ -290,6 +384,20 @@
padding-bottom: 2px;
}
.action-buttons .btn {
font-size: 175%;
align-content: center;
vertical-align: middle;
padding-left: 6px;
padding-right: 6px;
padding-top: 3px;
padding-bottom: 2px;
};
.panel-heading .badge {
float: right;
}
.badge {
float: right;
background-color: #777;
@ -308,6 +416,8 @@
margin: 2px;
padding: 3px;
object-fit: contain;
border: 1px solid #aaa;
border-radius: 3px;
}
.part-thumb-container:hover .part-thumb-overlay {

View File

@ -25,7 +25,6 @@ function inventreeGet(url, filters={}, options={}) {
dataType: 'json',
contentType: 'application/json',
success: function(response) {
console.log('Success GET data at ' + url);
if (options.success) {
options.success(response);
}
@ -64,7 +63,6 @@ function inventreeFormDataUpload(url, data, options={}) {
processData: false,
contentType: false,
success: function(data, status, xhr) {
console.log('Form data upload success');
if (options.success) {
options.success(data, status, xhr);
}
@ -97,7 +95,6 @@ function inventreePut(url, data={}, options={}) {
dataType: 'json',
contentType: 'application/json',
success: function(response, status) {
console.log(method + ' - ' + url + ' : result = ' + status);
if (options.success) {
options.success(response, status);
}
@ -114,25 +111,3 @@ function inventreePut(url, data={}, options={}) {
}
});
}
// Return list of parts with optional filters
function getParts(filters={}, options={}) {
return inventreeGet('/api/part/', filters, options);
}
// Return list of part categories with optional filters
function getPartCategories(filters={}, options={}) {
return inventreeGet('/api/part/category/', filters, options);
}
function getCompanies(filters={}, options={}) {
return inventreeGet('/api/company/', filters, options);
}
function updateStockItem(pk, data, final=false) {
return inventreePut('/api/stock/' + pk + '/', data, final);
}
function updatePart(pk, data, final=false) {
return inventreePut('/api/part/' + pk + '/', data, final);
}

View File

@ -221,7 +221,6 @@ function loadBomTable(table, options) {
}
}
});
}
// Part notes

View File

@ -1,4 +1,5 @@
function loadBuildTable(table, options) {
// Display a table of Build objects
var params = options.params || {};

View File

@ -18,6 +18,8 @@ function defaultFilters() {
build: "",
parts: "cascade=1",
company: "",
salesorder: "",
purchaseorder: "",
};
}

View File

@ -78,6 +78,59 @@ function getImageUrlFromTransfer(transfer) {
return url;
}
function makeIconButton(icon, cls, pk, title) {
// Construct an 'icon button' using the fontawesome set
var classes = `btn btn-default btn-glyph ${cls}`;
var id = `${cls}-${pk}`;
var html = '';
html += `<button pk='${pk}' id='${id}' class='${classes}' title='${title}'>`;
html += `<span class='fas ${icon}'></span>`;
html += `</button>`;
return html;
}
function makeProgressBar(value, maximum, opts) {
/*
* Render a progessbar!
*
* @param value is the current value of the progress bar
* @param maximum is the maximum value of the progress bar
*/
var options = opts || {};
value = parseFloat(value);
maximum = parseFloat(maximum);
var percent = parseInt(value / maximum * 100);
if (percent > 100) {
percent = 100;
}
var extraclass = '';
if (value > maximum) {
extraclass='progress-bar-over';
} else if (value < maximum) {
extraclass = 'progress-bar-under';
}
var id = options.id || 'progress-bar';
return `
<div id='${id}' class='progress'>
<div class='progress-bar ${extraclass}' role='progressbar' aria-valuenow='${percent}' aria-valuemin='0' aria-valuemax='100' style='width:${percent}%'></div>
<div class='progress-value'>${value} / ${maximum}</div>
</div>
`;
}
function enableDragAndDrop(element, url, options) {
/* Enable drag-and-drop file uploading for a given element.

View File

@ -108,13 +108,13 @@ function loadPurchaseOrderTable(table, options) {
options.params['supplier_detail'] = true;
var filters = loadTableFilters("order");
var filters = loadTableFilters("purchaseorder");
for (var key in options.params) {
filters[key] = options.params[key];
}
setupFilterList("order", $(table));
setupFilterList("purchaseorder", $(table));
$(table).inventreeTable({
url: options.url,
@ -145,9 +145,9 @@ function loadPurchaseOrderTable(table, options) {
}
},
{
field: 'supplier_reference',
title: 'Supplier Reference',
sortable: true,
field: 'creation_date',
title: 'Date',
},
{
sortable: true,
@ -159,9 +159,92 @@ function loadPurchaseOrderTable(table, options) {
field: 'status',
title: 'Status',
formatter: function(value, row, index, field) {
return orderStatusDisplay(row.status, row.status_text);
return purchaseOrderStatusDisplay(row.status, row.status_text);
}
},
{
sortable: true,
field: 'creation_date',
title: 'Date',
},
{
sortable: true,
field: 'line_items',
title: 'Items'
},
],
});
}
function loadSalesOrderTable(table, options) {
options.params = options.params || {};
options.params['customer_detail'] = true;
var filters = loadTableFilters("salesorder");
for (var key in options.params) {
filters[key] = options.params[key];
}
setupFilterList("salesorder", $(table));
$(table).inventreeTable({
url: options.url,
queryParams: filters,
groupBy: false,
original: options.params,
formatNoMatches: function() { return "No sales orders found"; },
columns: [
{
field: 'pk',
title: 'ID',
visible: false,
},
{
sortable: true,
field: 'reference',
title: 'Sales Order',
formatter: function(value, row, index, field) {
return renderLink(value, `/order/sales-order/${row.pk}/`);
},
},
{
sortable: true,
field: 'customer_detail',
title: 'Customer',
formatter: function(value, row, index, field) {
return imageHoverIcon(row.customer_detail.image) + renderLink(row.customer_detail.name, `/company/${row.customer}/sales-orders/`);
}
},
{
field: 'customer_reference',
title: 'Customer Reference',
sotrable: true,
},
{
sortable: true,
field: 'description',
title: 'Description',
},
{
sortable: true,
field: 'status',
title: 'Status',
formatter: function(value, row, index, field) {
return salesOrderStatusDisplay(row.status, row.status_text);
}
},
{
sortable: true,
field: 'creation_date',
title: 'Creation Date',
},
{
sortable: true,
field: 'shipment_date',
title: "Shipment Date",
},
{
sortable: true,
field: 'line_items',

View File

@ -50,7 +50,7 @@ function toggleStar(options) {
{
method: 'POST',
success: function(response, status) {
$(options.button).removeClass('glyphicon-star-empty').addClass('glyphicon-star');
$(options.button).addClass('icon-yellow');
},
}
);
@ -64,7 +64,7 @@ function toggleStar(options) {
{
method: 'DELETE',
success: function(response, status) {
$(options.button).removeClass('glyphicon-star').addClass('glyphicon-star-empty');
$(options.button).removeClass('icon-yellow');
},
}
);
@ -158,6 +158,10 @@ function loadPartTable(table, url, options={}) {
display += `<span class='fas fa-star label-right' title='Starred part'></span>`;
}
if (row.salable) {
display += `<span class='fas fa-dollar-sign label-right' title='Salable part'></span>`;
}
/*
if (row.component) {
display = display + `<span class='fas fa-cogs label-right' title='Component part'></span>`;

View File

@ -228,8 +228,10 @@ function loadStockTable(table, options) {
} else {
url = `/part/${row.part}/`;
}
html = imageHoverIcon(thumb) + renderLink(name, url);
return imageHoverIcon(thumb) + renderLink(name, url);
return html;
}
},
{
@ -255,9 +257,18 @@ function loadStockTable(table, options) {
val = +val.toFixed(5);
}
var text = renderLink(val, '/stock/item/' + row.pk + '/');
var html = renderLink(val, `/stock/item/${row.pk}/`);
return text;
if (row.allocated) {
html += `<span class='fas fa-bookmark label-right' title='StockItem has been allocated'></span>`;
}
// 70 = "LOST"
if (row.status == 70) {
html += `<span class='fas fa-question-circle label-right' title='StockItem is lost'></span>`;
}
return html;
}
},
{

View File

@ -7,12 +7,10 @@ class StatusCode:
This is used to map a set of integer values to text.
"""
labels = {}
@classmethod
def render(cls, key):
def render(cls, key, large=False):
"""
Render the value as a label.
Render the value as a HTML label.
"""
# If the key cannot be found, pass it back
@ -20,12 +18,17 @@ class StatusCode:
return key
value = cls.options.get(key, key)
label = cls.labels.get(key, None)
color = cls.colors.get(key, 'grey')
if label:
return "<span class='label label-{label}'>{value}</span>".format(label=label, value=value)
if large:
span_class = 'label label-large label-large-{c}'.format(c=color)
else:
return value
span_class = 'label label-{c}'.format(c=color)
return "<span class='{cl}'>{value}</span>".format(
cl=span_class,
value=value
)
@classmethod
def list(cls):
@ -42,10 +45,10 @@ class StatusCode:
'value': cls.options[key]
}
label = cls.labels.get(key)
color = cls.colors.get(key, None)
if label:
opt['label'] = label
if color:
opt['color'] = color
codes.append(opt)
@ -70,11 +73,14 @@ class StatusCode:
raise ValueError("Label not found")
class OrderStatus(StatusCode):
class PurchaseOrderStatus(StatusCode):
"""
Defines a set of status codes for a PurchaseOrder
"""
# Order status codes
PENDING = 10 # Order is pending (not yet placed)
PLACED = 20 # Order has been placed
PLACED = 20 # Order has been placed with supplier
COMPLETE = 30 # Order has been completed
CANCELLED = 40 # Order was cancelled
LOST = 50 # Order was lost
@ -89,13 +95,13 @@ class OrderStatus(StatusCode):
RETURNED: _("Returned"),
}
labels = {
PENDING: "primary",
PLACED: "primary",
COMPLETE: "success",
CANCELLED: "danger",
LOST: "warning",
RETURNED: "warning",
colors = {
PENDING: 'blue',
PLACED: 'blue',
COMPLETE: 'green',
CANCELLED: 'red',
LOST: 'yellow',
RETURNED: 'yellow',
}
# Open orders
@ -112,6 +118,32 @@ class OrderStatus(StatusCode):
]
class SalesOrderStatus(StatusCode):
""" Defines a set of status codes for a SalesOrder """
PENDING = 10 # Order is pending
SHIPPED = 20 # Order has been shipped to customer
CANCELLED = 40 # Order has been cancelled
LOST = 50 # Order was lost
RETURNED = 60 # Order was returned
options = {
PENDING: _("Pending"),
SHIPPED: _("Shipped"),
CANCELLED: _("Cancelled"),
LOST: _("Lost"),
RETURNED: _("Returned"),
}
colors = {
PENDING: 'blue',
SHIPPED: 'green',
CANCELLED: 'red',
LOST: 'yellow',
RETURNED: 'yellow',
}
class StockStatus(StatusCode):
OK = 10 # Item is OK
@ -119,6 +151,15 @@ class StockStatus(StatusCode):
DAMAGED = 55 # Item is damaged
DESTROYED = 60 # Item is destroyed
LOST = 70 # Item has been lost
RETURNED = 85 # Item has been returned from a customer
# Any stock code above 100 means that the stock item is not "in stock"
# This can be used as a quick check for filtering
NOT_IN_STOCK = 100
SHIPPED = 110 # Item has been shipped to a customer
ASSIGNED_TO_BUILD = 120
ASSIGNED_TO_OTHER_ITEM = 130
options = {
OK: _("OK"),
@ -126,12 +167,20 @@ class StockStatus(StatusCode):
DAMAGED: _("Damaged"),
DESTROYED: _("Destroyed"),
LOST: _("Lost"),
RETURNED: _("Returned"),
SHIPPED: _('Shipped'),
ASSIGNED_TO_BUILD: _("Used for Build"),
ASSIGNED_TO_OTHER_ITEM: _("Installed in Stock Item")
}
labels = {
OK: 'success',
ATTENTION: 'warning',
DAMAGED: 'danger',
colors = {
OK: 'green',
ATTENTION: 'yellow',
DAMAGED: 'red',
DESTROYED: 'red',
SHIPPED: 'green',
ASSIGNED_TO_BUILD: 'blue',
ASSIGNED_TO_OTHER_ITEM: 'blue',
}
# The following codes correspond to parts that are 'available' or 'in stock'
@ -139,12 +188,16 @@ class StockStatus(StatusCode):
OK,
ATTENTION,
DAMAGED,
RETURNED,
]
# The following codes correspond to parts that are 'unavailable'
UNAVAILABLE_CODES = [
DESTROYED,
LOST,
SHIPPED,
ASSIGNED_TO_BUILD,
ASSIGNED_TO_OTHER_ITEM,
]
@ -163,11 +216,11 @@ class BuildStatus(StatusCode):
COMPLETE: _("Complete"),
}
labels = {
PENDING: 'primary',
ALLOCATED: 'info',
COMPLETE: 'success',
CANCELLED: 'danger',
colors = {
PENDING: 'blue',
ALLOCATED: 'blue',
COMPLETE: 'green',
CANCELLED: 'red',
}
ACTIVE_CODES = [

View File

@ -25,7 +25,7 @@ from part.api import part_api_urls, bom_api_urls
from company.api import company_api_urls
from stock.api import stock_api_urls
from build.api import build_api_urls
from order.api import po_api_urls
from order.api import order_api_urls
from django.conf import settings
from django.conf.urls.static import static
@ -49,7 +49,7 @@ apipatterns = [
url(r'^company/', include(company_api_urls)),
url(r'^stock/', include(stock_api_urls)),
url(r'^build/', include(build_api_urls)),
url(r'^po/', include(po_api_urls)),
url(r'^order/', include(order_api_urls)),
# User URLs
url(r'^user/', include(user_urls)),
@ -73,11 +73,17 @@ settings_urls = [
url(r'^.*$', SettingsView.as_view(template_name='InvenTree/settings/user.html'), name='settings'),
]
dynamic_javascript_urls = [
]
urlpatterns = [
url(r'^part/', include(part_urls)),
url(r'^supplier-part/', include(supplier_part_urls)),
url(r'^price-break/', include(price_break_urls)),
# "Dynamic" javascript files which are rendered using InvenTree templating.
url(r'^dynamic/', include(dynamic_javascript_urls)),
url(r'^common/', include(common_urls)),
url(r'^stock/', include(stock_urls)),

View File

@ -6,7 +6,7 @@ import subprocess
from common.models import InvenTreeSetting
import django
INVENTREE_SW_VERSION = "0.0.12 pre"
INVENTREE_SW_VERSION = "0.1.0 pre"
def inventreeInstanceName():

View File

@ -38,6 +38,7 @@ class BuildList(generics.ListCreateAPIView):
]
filter_fields = [
'sales_order',
]
def get_queryset(self):
@ -46,21 +47,27 @@ class BuildList(generics.ListCreateAPIView):
as some of the fields don't natively play nicely with DRF
"""
build_list = super().get_queryset()
queryset = super().get_queryset().prefetch_related('part')
# Filter by part
part = self.request.query_params.get('part', None)
return queryset
def filter_queryset(self, queryset):
if part is not None:
build_list = build_list.filter(part=part)
queryset = super().filter_queryset(queryset)
# Filter by build status?
status = self.request.query_params.get('status', None)
if status is not None:
build_list = build_list.filter(status=status)
queryset = queryset.filter(status=status)
return build_list
# Filter by associated part?
part = self.request.query_params.get('part', None)
if part is not None:
queryset = queryset.filter(part=part)
return queryset
def get_serializer(self, *args, **kwargs):
@ -99,20 +106,25 @@ class BuildItemList(generics.ListCreateAPIView):
to allow filtering by stock_item.part
"""
# Does the user wish to filter by part?
part_pk = self.request.query_params.get('part', None)
query = BuildItem.objects.all()
query = query.select_related('stock_item')
query = query.prefetch_related('stock_item__part')
query = query.prefetch_related('stock_item__part__category')
if part_pk:
query = query.filter(stock_item__part=part_pk)
return query
def filter_queryset(self, queryset):
queryset = super().filter_queryset(queryset)
# Does the user wish to filter by part?
part_pk = self.request.query_params.get('part', None)
if part_pk:
queryset = queryset.filter(stock_item__part=part_pk)
return queryset
permission_classes = [
permissions.IsAuthenticated,
]
@ -132,7 +144,7 @@ build_item_api_urls = [
]
build_api_urls = [
url(r'^item/?', include(build_item_api_urls)),
url(r'^item/', include(build_item_api_urls)),
url(r'^(?P<pk>\d+)/', BuildDetail.as_view(), name='api-build-detail'),

View File

@ -10,6 +10,10 @@
status: 10 # PENDING
creation_date: '2019-03-16'
link: http://www.google.com
level: 0
lft: 0
rght: 0
tree_id: 0
- model: build.build
fields:
@ -19,4 +23,8 @@
status: 40 # COMPLETE
quantity: 21
notes: 'Some more simple notes'
creation_date: '2019-03-16'
creation_date: '2019-03-16'
level: 0
lft: 0
rght: 0
tree_id: 1

View File

@ -22,6 +22,8 @@ class EditBuildForm(HelperForm):
fields = [
'title',
'part',
'parent',
'sales_order',
'quantity',
'take_from',
'batch',

View File

@ -0,0 +1,20 @@
# Generated by Django 3.0.5 on 2020-04-24 22:51
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('order', '0029_auto_20200423_1042'),
('build', '0011_auto_20200406_0123'),
]
operations = [
migrations.AddField(
model_name='build',
name='sales_order',
field=models.ForeignKey(blank=True, help_text='SalesOrder to which this build is allocated', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='builds', to='order.SalesOrder'),
),
]

View File

@ -0,0 +1,55 @@
# Generated by Django 3.0.5 on 2020-04-25 05:07
from django.db import migrations, models
import django.db.models.deletion
import mptt.fields
from build.models import Build
def update_tree(apps, schema_editor):
# Update the Build MPTT model
Build.objects.rebuild()
def nupdate_tree(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('build', '0012_build_sales_order'),
]
operations = [
migrations.AddField(
model_name='build',
name='level',
field=models.PositiveIntegerField(default=0, editable=False),
preserve_default=False,
),
migrations.AddField(
model_name='build',
name='lft',
field=models.PositiveIntegerField(default=0, editable=False),
preserve_default=False,
),
migrations.AddField(
model_name='build',
name='parent',
field=mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='children', to='build.Build'),
),
migrations.AddField(
model_name='build',
name='rght',
field=models.PositiveIntegerField(default=0, editable=False),
preserve_default=False,
),
migrations.AddField(
model_name='build',
name='tree_id',
field=models.PositiveIntegerField(db_index=True, default=0, editable=False),
preserve_default=False,
),
migrations.RunPython(update_tree, reverse_code=nupdate_tree),
]

View File

@ -0,0 +1,71 @@
# Generated by Django 3.0.5 on 2020-04-25 12:43
import InvenTree.fields
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import markdownx.models
import mptt.fields
class Migration(migrations.Migration):
dependencies = [
('part', '0035_auto_20200406_0045'),
('stock', '0031_auto_20200422_0209'),
('order', '0029_auto_20200423_1042'),
('build', '0013_auto_20200425_0507'),
]
operations = [
migrations.AlterField(
model_name='build',
name='batch',
field=models.CharField(blank=True, help_text='Batch code for this build output', max_length=100, null=True, verbose_name='Batch Code'),
),
migrations.AlterField(
model_name='build',
name='link',
field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', verbose_name='External Link'),
),
migrations.AlterField(
model_name='build',
name='notes',
field=markdownx.models.MarkdownxField(blank=True, help_text='Extra build notes', verbose_name='Notes'),
),
migrations.AlterField(
model_name='build',
name='parent',
field=mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='children', to='build.Build', verbose_name='Parent Build'),
),
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, 'virtual': False}, on_delete=django.db.models.deletion.CASCADE, related_name='builds', to='part.Part', verbose_name='Part'),
),
migrations.AlterField(
model_name='build',
name='quantity',
field=models.PositiveIntegerField(default=1, help_text='Number of parts to build', validators=[django.core.validators.MinValueValidator(1)], verbose_name='Build Quantity'),
),
migrations.AlterField(
model_name='build',
name='sales_order',
field=models.ForeignKey(blank=True, help_text='SalesOrder to which this build is allocated', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='builds', to='order.SalesOrder', verbose_name='Sales Order Reference'),
),
migrations.AlterField(
model_name='build',
name='status',
field=models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Allocated'), (30, 'Cancelled'), (40, 'Complete')], default=10, help_text='Build status code', validators=[django.core.validators.MinValueValidator(0)], verbose_name='Build Status'),
),
migrations.AlterField(
model_name='build',
name='take_from',
field=models.ForeignKey(blank=True, help_text='Select location to take stock from for this build (leave blank to take from any stock location)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sourcing_builds', to='stock.StockLocation', verbose_name='Source Location'),
),
migrations.AlterField(
model_name='build',
name='title',
field=models.CharField(help_text='Brief description of the build', max_length=100, verbose_name='Build Title'),
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 3.0.5 on 2020-04-25 13:50
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import mptt.fields
class Migration(migrations.Migration):
dependencies = [
('build', '0014_auto_20200425_1243'),
]
operations = [
migrations.AlterField(
model_name='build',
name='parent',
field=mptt.fields.TreeForeignKey(blank=True, help_text='Parent build to which this build is allocated', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='children', to='build.Build', verbose_name='Parent Build'),
),
migrations.AlterField(
model_name='builditem',
name='quantity',
field=models.DecimalField(decimal_places=5, default=1, help_text='Stock quantity to allocate to build', max_digits=15, validators=[django.core.validators.MinValueValidator(0)]),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 3.0.5 on 2020-04-26 05:51
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stock', '0033_auto_20200426_0539'),
('build', '0015_auto_20200425_1350'),
]
operations = [
migrations.AlterField(
model_name='builditem',
name='stock_item',
field=models.ForeignKey(help_text='Stock Item to allocate to build', limit_choices_to={'belongs_to': None, 'build_order': None, 'customer': None}, on_delete=django.db.models.deletion.CASCADE, related_name='allocations', to='stock.StockItem'),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 3.0.5 on 2020-04-26 06:12
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stock', '0034_auto_20200426_0602'),
('build', '0016_auto_20200426_0551'),
]
operations = [
migrations.AlterField(
model_name='builditem',
name='stock_item',
field=models.ForeignKey(help_text='Stock Item to allocate to build', limit_choices_to={'belongs_to': None, 'build_order': None, 'sales_order': None}, on_delete=django.db.models.deletion.CASCADE, related_name='allocations', to='stock.StockItem'),
),
]

View File

@ -14,11 +14,14 @@ from django.core.exceptions import ValidationError
from django.urls import reverse
from django.db import models, transaction
from django.db.models import Sum
from django.db.models.functions import Coalesce
from django.core.validators import MinValueValidator
from markdownx.models import MarkdownxField
from InvenTree.status_codes import BuildStatus
from mptt.models import MPTTModel, TreeForeignKey
from InvenTree.status_codes import BuildStatus, StockStatus
from InvenTree.fields import InvenTreeURLField
from InvenTree.helpers import decimal2string
@ -26,13 +29,15 @@ from stock.models import StockItem
from part.models import Part, BomItem
class Build(models.Model):
class Build(MPTTModel):
""" A Build object organises the creation of new parts from the component parts.
Attributes:
part: The part to be built (from component BOM items)
title: Brief title describing the build (required)
quantity: Number of units to be built
parent: Reference to a Build object for which this Build is required
sales_order: References to a SalesOrder object for which this Build is required (e.g. the output of this build will be used to fulfil a sales order)
take_from: Location to take stock from to make this build (if blank, can take from anywhere)
status: Build status code
batch: Batch code transferred to build parts (optional)
@ -43,60 +48,102 @@ class Build(models.Model):
"""
def __str__(self):
return "Build {q} x {part}".format(q=decimal2string(self.quantity), part=str(self.part))
return "{q} x {part}".format(q=decimal2string(self.quantity), part=str(self.part.full_name))
def get_absolute_url(self):
return reverse('build-detail', kwargs={'pk': self.id})
title = models.CharField(
verbose_name=_('Build Title'),
blank=False,
max_length=100,
help_text=_('Brief description of the build'))
help_text=_('Brief description of the build')
)
part = models.ForeignKey('part.Part', on_delete=models.CASCADE,
related_name='builds',
limit_choices_to={
'is_template': False,
'assembly': True,
'active': True,
'virtual': False,
},
help_text=_('Select part to build'),
)
parent = TreeForeignKey(
'self',
on_delete=models.DO_NOTHING,
blank=True, null=True,
related_name='children',
verbose_name=_('Parent Build'),
help_text=_('Parent build to which this build is allocated'),
)
part = models.ForeignKey(
'part.Part',
verbose_name=_('Part'),
on_delete=models.CASCADE,
related_name='builds',
limit_choices_to={
'is_template': False,
'assembly': True,
'active': True,
'virtual': False,
},
help_text=_('Select part to build'),
)
sales_order = models.ForeignKey(
'order.SalesOrder',
verbose_name=_('Sales Order Reference'),
on_delete=models.SET_NULL,
related_name='builds',
null=True, blank=True,
help_text=_('SalesOrder to which this build is allocated')
)
take_from = models.ForeignKey('stock.StockLocation', on_delete=models.SET_NULL,
related_name='sourcing_builds',
null=True, blank=True,
help_text=_('Select location to take stock from for this build (leave blank to take from any stock location)')
)
take_from = models.ForeignKey(
'stock.StockLocation',
verbose_name=_('Source Location'),
on_delete=models.SET_NULL,
related_name='sourcing_builds',
null=True, blank=True,
help_text=_('Select location to take stock from for this build (leave blank to take from any stock location)')
)
quantity = models.PositiveIntegerField(
verbose_name=_('Build Quantity'),
default=1,
validators=[MinValueValidator(1)],
help_text=_('Number of parts to build')
)
status = models.PositiveIntegerField(default=BuildStatus.PENDING,
choices=BuildStatus.items(),
validators=[MinValueValidator(0)],
help_text=_('Build status'))
status = models.PositiveIntegerField(
verbose_name=_('Build Status'),
default=BuildStatus.PENDING,
choices=BuildStatus.items(),
validators=[MinValueValidator(0)],
help_text=_('Build status code')
)
batch = models.CharField(max_length=100, blank=True, null=True,
help_text=_('Batch code for this build output'))
batch = models.CharField(
verbose_name=_('Batch Code'),
max_length=100,
blank=True,
null=True,
help_text=_('Batch code for this build output')
)
creation_date = models.DateField(auto_now_add=True, editable=False)
completion_date = models.DateField(null=True, blank=True)
completed_by = models.ForeignKey(User,
on_delete=models.SET_NULL,
blank=True, null=True,
related_name='builds_completed'
)
completed_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
blank=True, null=True,
related_name='builds_completed'
)
link = InvenTreeURLField(blank=True, help_text=_('Link to external URL'))
link = InvenTreeURLField(
verbose_name=_('External Link'),
blank=True, help_text=_('Link to external URL')
)
notes = MarkdownxField(blank=True, help_text=_('Extra build notes'))
notes = MarkdownxField(
verbose_name=_('Notes'),
blank=True, help_text=_('Extra build notes')
)
@property
def output_count(self):
@ -214,32 +261,20 @@ class Build(models.Model):
- Delete pending BuildItem objects
"""
for item in self.allocated_stock.all().prefetch_related('stock_item'):
# Subtract stock from the item
item.stock_item.take_stock(
item.quantity,
user,
'Removed {n} items to build {m} x {part}'.format(
n=item.quantity,
m=self.quantity,
part=self.part.full_name
)
)
# Complete the build allocation for each BuildItem
for build_item in self.allocated_stock.all().prefetch_related('stock_item'):
build_item.complete_allocation(user)
# Delete the item
item.delete()
# Mark the date of completion
self.completion_date = datetime.now().date()
self.completed_by = user
# Check that the stock-item has been assigned to this build, and remove the builditem from the database
if build_item.stock_item.build_order == self:
build_item.delete()
notes = 'Built {q} on {now}'.format(
q=self.quantity,
now=str(datetime.now().date())
)
# Generate the build outputs
if self.part.trackable and serial_numbers:
# Add new serial numbers
for serial in serial_numbers:
@ -269,31 +304,54 @@ class Build(models.Model):
item.save()
# Finally, mark the build as complete
self.completion_date = datetime.now().date()
self.completed_by = user
self.status = BuildStatus.COMPLETE
self.save()
return True
def isFullyAllocated(self):
"""
Return True if this build has been fully allocated.
"""
bom_items = self.part.bom_items.all()
for item in bom_items:
part = item.sub_part
if not self.isPartFullyAllocated(part):
return False
return True
def isPartFullyAllocated(self, part):
"""
Check if a given Part is fully allocated for this Build
"""
return self.getAllocatedQuantity(part) >= self.getRequiredQuantity(part)
def getRequiredQuantity(self, part):
""" Calculate the quantity of <part> required to make this build.
"""
try:
item = BomItem.objects.get(part=self.part.id, sub_part=part.id)
return item.get_required_quantity(self.quantity)
q = item.quantity
except BomItem.DoesNotExist:
return 0
q = 0
return q * self.quantity
def getAllocatedQuantity(self, part):
""" Calculate the total number of <part> currently allocated to this build
"""
allocated = BuildItem.objects.filter(build=self.id, stock_item__part=part.id).aggregate(Sum('quantity'))
allocated = BuildItem.objects.filter(build=self.id, stock_item__part=part.id).aggregate(q=Coalesce(Sum('quantity'), 0))
q = allocated['quantity__sum']
if q:
return int(q)
else:
return 0
return allocated['q']
def getUnallocatedQuantity(self, part):
""" Calculate the quantity of <part> which still needs to be allocated to this build.
@ -313,11 +371,12 @@ class Build(models.Model):
parts = []
for item in self.part.bom_items.all().prefetch_related('sub_part'):
part = {'part': item.sub_part,
'per_build': item.quantity,
'quantity': item.quantity * self.quantity,
'allocated': self.getAllocatedQuantity(item.sub_part)
}
part = {
'part': item.sub_part,
'per_build': item.quantity,
'quantity': item.quantity * self.quantity,
'allocated': self.getAllocatedQuantity(item.sub_part)
}
parts.append(part)
@ -393,15 +452,39 @@ class BuildItem(models.Model):
q=self.stock_item.quantity
))]
except StockItem.DoesNotExist:
pass
if self.stock_item.quantity - self.stock_item.allocation_count() + self.quantity < self.quantity:
errors['quantity'] = _('StockItem is over-allocated')
except Part.DoesNotExist:
if self.quantity <= 0:
errors['quantity'] = _('Allocation quantity must be greater than zero')
if self.stock_item.serial and not self.quantity == 1:
errors['quantity'] = _('Quantity must be 1 for serialized stock')
except (StockItem.DoesNotExist, Part.DoesNotExist):
pass
if len(errors) > 0:
raise ValidationError(errors)
def complete_allocation(self, user):
item = self.stock_item
# Split the allocated stock if there are more available than allocated
if item.quantity > self.quantity:
item = item.splitStock(self.quantity, None, user)
# Update our own reference to the new item
self.stock_item = item
self.save()
# TODO - If the item__part object is not trackable, delete the stock item here
item.status = StockStatus.ASSIGNED_TO_BUILD
item.build_order = self.build
item.save()
build = models.ForeignKey(
Build,
on_delete=models.CASCADE,
@ -414,12 +497,17 @@ class BuildItem(models.Model):
on_delete=models.CASCADE,
related_name='allocations',
help_text=_('Stock Item to allocate to build'),
limit_choices_to={
'build_order': None,
'sales_order': None,
'belongs_to': None,
}
)
quantity = models.DecimalField(
decimal_places=5,
max_digits=15,
default=1,
validators=[MinValueValidator(1)],
validators=[MinValueValidator(0)],
help_text=_('Stock quantity to allocate to build')
)

View File

@ -21,6 +21,8 @@ class BuildSerializer(InvenTreeModelSerializer):
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
quantity = serializers.FloatField()
def __init__(self, *args, **kwargs):
part_detail = kwargs.pop('part_detail', False)
@ -39,6 +41,7 @@ class BuildSerializer(InvenTreeModelSerializer):
'completion_date',
'part',
'part_detail',
'sales_order',
'quantity',
'status',
'status_text',
@ -62,6 +65,8 @@ class BuildItemSerializer(InvenTreeModelSerializer):
part_image = serializers.CharField(source='stock_item.part.image', read_only=True)
stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True)
quantity = serializers.FloatField()
class Meta:
model = BuildItem
fields = [

View File

@ -1,5 +1,6 @@
{% extends "build/build_base.html" %}
{% load static %}
{% load i18n %}
{% load inventree_extras %}
{% block page_title %}
@ -10,39 +11,398 @@ InvenTree | Allocate Parts
{% include "build/tabs.html" with tab='allocate' %}
{% if editing %}
{% include "build/allocate_edit.html" %}
{% else %}
{% include "build/allocate_view.html" %}
{% endif %}
<div id='build-item-toolbar'>
{% if build.status == BuildStatus.PENDING %}
<div class='btn-group'>
<button class='btn btn-primary' type='button' id='btn-order-parts' title='Order Parts'>{% trans "Order Parts" %}</button>
<button class='btn btn-primary' type='button' id='btn-allocate' title='{% trans "Automatically allocate stock" %}'>{% trans "Auto Allocate" %}</button>
<button class='btn btn-danger' type='button' id='btn-unallocate' title='Unallocate Stock'>{% trans "Unallocate" %}</button>
</div>
{% endif %}
</div>
<table class='table table-striped table-condensed' id='build-item-list' data-toolbar='#build-item-toolbar'></table>
{% endblock %}
{% block js_load %}
{{ block.super }}
<script src="{% static 'script/inventree/part.js' %}"></script>
<script src="{% static 'script/inventree/build.js' %}"></script>
{% endblock %}
{% block js_ready %}
{{ block.super }}
{% if editing %}
var buildTable = $("#build-item-list");
{% for bom_item in bom_items.all %}
// Calculate sum of allocations for a particular table row
function sumAllocations(row) {
if (row.allocations == null) {
return 0;
}
loadAllocationTable(
$("#allocate-table-id-{{ bom_item.sub_part.id }}"),
{{ bom_item.sub_part.id }},
"{{ bom_item.sub_part.full_name }}",
"{% url 'api-build-item-list' %}?build={{ build.id }}&part={{ bom_item.sub_part.id }}",
{% multiply build.quantity bom_item.quantity %},
$("#new-item-{{ bom_item.sub_part.id }}")
);
var quantity = 0;
{% endfor %}
row.allocations.forEach(function(item) {
quantity += item.quantity;
});
$("#auto-allocate-build").on('click', function() {
return quantity;
}
function getUnallocated(row) {
// Return the number of items remaining to be allocated for a given row
return {{ build.quantity }} * row.quantity - sumAllocations(row);
}
function reloadTable() {
// Reload the build allocation table
buildTable.bootstrapTable('refresh');
}
function setupCallbacks() {
// Register button callbacks once the table data are loaded
buildTable.find(".button-add").click(function() {
var pk = $(this).attr('pk');
// Extract row data from the table
var idx = $(this).closest('tr').attr('data-index');
var row = buildTable.bootstrapTable('getData')[idx];
launchModalForm('/build/item/new/', {
success: reloadTable,
data: {
part: row.sub_part,
build: {{ build.id }},
quantity: getUnallocated(row),
},
secondary: [
{
field: 'stock_item',
label: '{% trans "New Stock Item" %}',
title: '{% trans "Create new Stock Item"',
url: '{% url "stock-item-create" %}',
data: {
part: row.sub_part,
},
},
]
});
});
buildTable.find(".button-build").click(function() {
// Start a new build for the sub_part
var pk = $(this).attr('pk');
// Extract row data from the table
var idx = $(this).closest('tr').attr('data-index');
var row = buildTable.bootstrapTable('getData')[idx];
launchModalForm('/build/new/', {
follow: true,
data: {
part: row.sub_part,
parent: {{ build.id }},
quantity: getUnallocated(row),
},
});
});
buildTable.find(".button-buy").click(function() {
var pk = $(this).attr('pk');
// Extract row data from the table
var idx = $(this).closest('tr').attr('data-index');
var row = buildTable.bootstrapTable('getData')[idx];
launchModalForm("{% url 'order-parts' %}", {
data: {
parts: [row.sub_part],
},
});
});
}
buildTable.inventreeTable({
uniqueId: 'sub_part',
url: "{% url 'api-bom-list' %}",
onPostBody: setupCallbacks,
detailViewByClick: true,
detailView: true,
detailFilter: function(index, row) {
return row.allocations != null;
},
detailFormatter: function(index, row, element) {
// Construct an 'inner table' which shows the stock allocations
var subTableId = `allocation-table-${row.pk}`;
var html = `<div class='sub-table'><table class='table table-condensed table-striped' id='${subTableId}'></table></div>`;
element.html(html);
var lineItem = row;
var subTable = $(`#${subTableId}`);
subTable.bootstrapTable({
data: row.allocations,
showHeader: false,
columns: [
{
width: '50%',
field: 'quantity',
title: 'Quantity',
formatter: function(value, row, index, field) {
var text = '';
var url = '';
if (row.serial && row.quantity == 1) {
text = `{% trans "Serial Number" %}: ${row.serial}`;
} else {
text = `{% trans "Quantity" %}: ${row.quantity}`;
}
{% if build.status == BuildStatus.COMPLETE %}
url = `/stock/item/${row.pk}/`;
{% else %}
url = `/stock/item/${row.stock_item}/`;
{% endif %}
return renderLink(text, url);
},
},
{
field: 'location',
title: '{% trans "Location" %}',
formatter: function(value, row, index, field) {
{% if build.status == BuildStatus.COMPLETE %}
var text = row.location_detail.pathstring;
var url = `/stock/location/${row.location}/`;
{% else %}
var text = row.stock_item_detail.location_name;
var url = `/stock/location/${row.stock_item_detail.location}/`;
{% endif %}
return renderLink(text, url);
}
},
{% if build.status == BuildStatus.PENDING %}
{
field: 'buttons',
title: 'Actions',
formatter: function(value, row) {
var pk = row.pk;
var html = `<div class='btn-group float-right' role='group'>`;
{% if build.status == BuildStatus.PENDING %}
html += makeIconButton('fa-edit', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}');
html += makeIconButton('fa-trash-alt', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}');
{% endif %}
html += `</div>`;
return html;
},
},
{% endif %}
]
});
// Assign button callbacks to the newly created allocation buttons
subTable.find(".button-allocation-edit").click(function() {
var pk = $(this).attr('pk');
launchModalForm(`/build/item/${pk}/edit/`, {
success: reloadTable,
});
});
subTable.find('.button-allocation-delete').click(function() {
var pk = $(this).attr('pk');
launchModalForm(`/build/item/${pk}/delete/`, {
success: reloadTable,
});
});
},
formatNoMatches: function() { return "{% trans 'No BOM items found' %}"; },
onLoadSuccess: function(tableData) {
// Once the BOM data are loaded, request allocation data for the build
{% if build.status == BuildStatus.COMPLETE %}
// Request StockItem which have been assigned to this build
inventreeGet('/api/stock/',
{
build_order: {{ build.id }},
location_detail: true,
},
{
success: function(data) {
// Iterate through the returned data, group by "part",
var allocations = {};
data.forEach(function(item) {
// Group allocations by referenced 'part'
var key = parseInt(item.part);
if (!(key in allocations)) {
allocations[key] = new Array();
}
allocations[key].push(item);
});
for (var key in allocations) {
var tableRow = buildTable.bootstrapTable('getRowByUniqueId', key);
tableRow.allocations = allocations[key];
buildTable.bootstrapTable('updateByUniqueId', key, tableRow, true);
}
},
},
);
{% else %}
inventreeGet('/api/build/item/',
{
build: {{ build.id }},
},
{
success: function(data) {
// Iterate through the returned data, and group by "part"
var allocations = {};
data.forEach(function(item) {
// Group allocations by referenced 'part'
var part = item.part;
var key = parseInt(part);
if (!(key in allocations)) {
allocations[key] = new Array();
}
// Add the allocation to the list
allocations[key].push(item);
});
for (var key in allocations) {
// Select the associated row in the table
var tableRow = buildTable.bootstrapTable('getRowByUniqueId', key);
// Set the allocations for the row
tableRow.allocations = allocations[key];
// And push the updated row back into the main table
buildTable.bootstrapTable('updateByUniqueId', key, tableRow, true);
}
}
},
);
{% endif %}
},
queryParams: {
part: {{ build.part.id }},
sub_part_detail: 1,
},
columns: [
{
field: 'id',
visible: false,
},
{
sortable: true,
field: 'sub_part',
title: '{% trans "Part" %}',
formatter: function(value, row, index, field) {
return imageHoverIcon(row.sub_part_detail.thumbnail) + renderLink(row.sub_part_detail.full_name, `/part/${row.sub_part}/`);
},
},
{
sortable: true,
field: 'sub_part_detail.description',
title: '{% trans "Description" %}',
},
{
sortable: true,
field: 'reference',
title: '{% trans "Reference" %}',
},
{
sortable: true,
field: 'quantity',
title: '{% trans "Required" %}',
formatter: function(value, row) {
return value * {{ build.quantity }};
},
},
{
sortable: true,
field: 'allocated',
{% if build.status == BuildStatus.COMPLETE %}
title: '{% trans "Assigned" %}',
{% else %}
title: '{% trans "Allocated" %}',
{% endif %}
formatter: function(value, row) {
var allocated = sumAllocations(row);
return makeProgressBar(allocated, row.quantity * {{ build.quantity }});
},
sorter: function(valA, valB, rowA, rowB) {
var aA = sumAllocations(rowA);
var aB = sumAllocations(rowB);
var qA = rowA.quantity * {{ build.quantity }};
var qB = rowB.quantity * {{ build.quantity }};
if (aA == 0 && aB == 0) {
return (qA > qB) ? 1 : -1;
}
var progressA = parseFloat(aA) / qA;
var progressB = parseFloat(aB) / qB;
return (progressA < progressB) ? 1 : -1;
}
},
{% if build.status == BuildStatus.PENDING %}
{
field: 'buttons',
formatter: function(value, row, index, field) {
var html = `<div class='btn-group float-right' role='group'>`;
var pk = row.sub_part;
{% if build.status == BuildStatus.PENDING %}
if (row.sub_part_detail.purchaseable) {
html += makeIconButton('fa-shopping-cart', 'button-buy', pk, '{% trans "Buy parts" %}');
}
if (row.sub_part_detail.assembly) {
html += makeIconButton('fa-tools', 'button-build', pk, '{% trans "Build parts" %}');
}
html += makeIconButton('fa-plus', 'button-add', pk, '{% trans "Allocate stock" %}');
{% endif %}
html += '</div>';
return html;
},
}
{% endif %}
],
});
{% if build.status == BuildStatus.PENDING %}
$("#btn-allocate").on('click', function() {
launchModalForm(
"{% url 'build-auto-allocate' build.id %}",
{
@ -50,8 +410,8 @@ InvenTree | Allocate Parts
}
);
});
$('#unallocate-build').on('click', function() {
$('#btn-unallocate').on('click', function() {
launchModalForm(
"{% url 'build-unallocate' build.id %}",
{
@ -59,16 +419,7 @@ InvenTree | Allocate Parts
}
);
});
{% else %}
$("#build-list").inventreeTable({
});
$("#btn-allocate").click(function() {
location.href = "{% url 'build-allocate' build.id %}?edit=1";
});
$("#btn-order-parts").click(function() {
launchModalForm("/order/purchase-order/order-parts/", {
data: {
@ -76,7 +427,8 @@ InvenTree | Allocate Parts
},
});
});
{% endif %}
{% endblock %}

View File

@ -1,34 +0,0 @@
{% load i18n %}
{% load inventree_extras %}
<div class='row'>
<h4>{% trans "Allocate Stock to Build" %}</h4>
<div class='col-sm-6'>
</div>
<div class='col-sm-6'>
<div class='btn-group' style='float: right;'>
<button class='btn btn-primary' type='button' title='Automatic allocation' id='auto-allocate-build'>{% trans "Auto Allocate" %}</button>
<button class='btn btn-warning' type='button' title='Unallocate build stock' id='unallocate-build'>{% trans "Unallocate" %}</button>
</div>
</div>
</div>
<hr>
<div class='row'>
<div class='col-sm-6'>
<h4>{% trans "Part" %}</h4>
</div>
<div class='col-sm-2'>
<h4>{% trans "Available" %}</h4>
</div>
<div class='col-sm-2'>
<h4>{% trans "Required" %}</h4>
</div>
<div class='col-sm-2'>
<h4>{% trans "Allocated" %}</h4>
</div>
</div>
{% for bom_item in bom_items.all %}
{% include "build/allocation_item.html" with item=bom_item build=build collapse_id=bom_item.id %}
{% endfor %}

View File

@ -1,40 +0,0 @@
{% load i18n %}
{% load inventree_extras %}
<h4>{% trans "Required Parts" %}</h4>
<hr>
<div id='build-item-toolbar'>
<div class='btn-group'>
<button class='btn btn-primary' type='button' id='btn-allocate' title='Allocate Stock'>{% trans "Allocate" %}</button>
<button class='btn btn-primary' type='button' id='btn-order-parts' title='Order Parts'>{% trans "Order Parts" %}</button>
</div>
</div>
<table class='table table-striped table-condensed' id='build-list' data-sorting='true' data-toolbar='#build-item-toolbar'>
<thead>
<tr>
<th data-sortable='true'>{% trans "Part" %}</th>
<th>{% trans "Description" %}</th>
<th data-sortable='true'>{% trans "Available" %}</th>
<th data-sortable='true'>{% trans "Required" %}</th>
<th data-sortable='true'>{% trans "Allocated" %}</th>
<th data-sortable='true'>{% trans "On Order" %}</th>
</tr>
</thead>
<tbody>
{% for item in build.required_parts %}
<tr {% if build.status == BuildStatus.PENDING %}class='{% if item.part.total_stock > item.quantity %}rowvalid{% else %}rowinvalid{% endif %}'{% endif %}>
<td>
{% include "hover_image.html" with image=item.part.image hover=True %}
<a class='hover-icon'a href="{% url 'part-detail' item.part.id %}">{{ item.part.full_name }}</a>
</td>
<td>{{ item.part.description }}</td>
<td>{% decimal item.part.total_stock %}</td>
<td>{% decimal item.quantity %}</td>
<td>{{ item.allocated }}</td>
<td>{% decimal item.part.on_order %}</td>
</tr>
{% endfor %}
</tbody>
</table>

View File

@ -1,34 +0,0 @@
{% extends "collapse.html" %}
{% load static %}
{% load inventree_extras %}
{% block collapse_panel_setup %}class='panel part-allocation' id='allocation-panel-{{ item.sub_part.id }}'{% endblock %}
{% block collapse_title %}
{% include "hover_image.html" with image=item.sub_part.image hover=false %}
<div>
{{ item.sub_part.full_name }}
<small><i>{{ item.sub_part.description }}</i></small>
</div>
{% endblock %}
{% block collapse_heading %}
<div class='col-sm-2'>
<b>{% decimal item.sub_part.total_stock %}</b>
</div>
<div class='col-sm-2'>
<b>{% multiply build.quantity item.quantity %}{% if item.overage %} (+ {{ item.overage }}){% endif %}</b>
</div>
<div class='col-sm-2'>
<b><span id='allocation-total-{{ item.sub_part.id }}'>{% part_allocation_count build item.sub_part %}</span></b>
<div class='btn-group' style='float: right;'>
<button class='btn btn-success btn-sm' title='Allocate stock for {{ item.sub_part}}' id='new-item-{{ item.sub_part.id }}' url="{% url 'build-item-create' %}?part={{ item.sub_part.id }}&build={{ build.id }}">Allocate</button>
</div>
</div>
{% endblock %}
{% block collapse_content %}
<table class='table table-striped table-condensed' id='allocate-table-id-{{ item.sub_part.id }}'>
</table>
{% endblock %}

View File

@ -1,22 +1,23 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
{{ block.super }}
<b>Build: {{ build.title }}</b> - {{ build.quantity }} x {{ build.part.full_name }}
<br><br>
Automatically allocate stock to this build?
<hr>
<div class='alert alert-block alert-info'>
<b>{% trans "Automatically Allocate Stock" %}</b><br>
{% trans "Stock Items are selected for automatic allocation if there is only a single stock item available." %}<br>
{% trans "The following stock items will be allocated to the build:" %}<br>
</div>
{% if allocations %}
<table class='table table-striped table-condensed'>
<tr>
<th></th>
<th>Part</th>
<th>Quantity</th>
<th>Location</th>
<th>{% trans "Part" %}</th>
<th>{% trans "Quantity" %}</th>
<th>{% trans "Location" %}</th>
</tr>
{% for item in allocations %}
<tr>
@ -34,7 +35,9 @@ Automatically allocate stock to this build?
</table>
{% else %}
<i>No stock could be selected for automatic build allocation.</i>
<div class='alert alert-block alert-warning'>
{% trans "No stock items found that can be allocated to this build" %}
</div>
{% endif %}
{% endblock %}

View File

@ -1,104 +1,114 @@
{% extends "base.html" %}
{% extends "two_column.html" %}
{% load static %}
{% load i18n %}
{% load status_codes %}
{% block page_title %}
InvenTree | Build - {{ build }}
InvenTree | {% trans "Build" %} - {{ build }}
{% endblock %}
{% block content %}
<div class='row'>
<div class='col-sm-6'>
<div class="media">
<div class="media-left">
<div class='dropzone' id='part-thumb'>
<img class="part-thumb"
{% if build.part.image %}
src="{{ build.part.image.url }}"
{% else %}
src="{% static 'img/blank_image.png' %}"
{% endif %}/>
</div>
</div>
<div class='media-body'>
<h4>{% trans "Build" %}</h4>
<div class='btn-row'>
<div class='btn-group'>
<button type='button' class='btn btn-default btn-glyph' id='build-edit' title='Edit Build'>
<span class='glyphicon glyphicon-edit'/>
</button>
{% if build.is_active %}
<button type='button' class='btn btn-default btn-glyph' id='build-complete' title="Complete Build">
<span class='glyphicon glyphicon-send'/>
</button>
<button type='button' class='btn btn-default btn-glyph' id='build-cancel' title='Cancel Build'>
<span class='glyphicon glyphicon-remove'/>
</button>
{% endif %}
{% if build.status == BuildStatus.CANCELLED %}
<button type='button' class='btn btn-default btn-glyph' id='build-delete' title='Delete Build'>
<span class='glyphicon glyphicon-trash'/>
</button>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<div class='col-sm-6'>
<h4>{% trans "Build Details" %}</h4>
<table class='table table-striped table-condensed'>
<tr>
<td></td>
<td>{% trans "Build Title" %}</td>
<td>{{ build.title }}</td>
</tr>
<tr>
<td><span class='fas fa-shapes'></span></td>
<td>Part</td>
<td><a href="{% url 'part-detail' build.part.id %}">{{ build.part.full_name }}</a></td>
</tr>
<tr>
<td></td>
<td>{% trans "Quantity" %}</td>
<td>{{ build.quantity }}</td>
</tr>
<tr>
<td><span class='fas fa-info'></span></td>
<td>{% trans "Status" %}</td>
<td>{% build_status build.status %}</td>
</tr>
<tr>
<td><span class='fas fa-dollar-sign'></span></td>
<td>{% trans "BOM Price" %}</td>
<td>
{% if bom_price %}
{{ bom_price }}
{% if build.part.has_complete_bom_pricing == False %}
<br><span class='warning-msg'><i>{% trans "BOM pricing is incomplete" %}</i></span>
{% endif %}
{% else %}
<span class='warning-msg'><i>{% trans "No pricing information" %}</i></span>
{% endif %}
</td>
</tr>
</table>
</div>
</div>
{% block pre_content %}
{% if build.sales_order %}
<div class='alert alert-block alert-info'>
{% trans "This build is allocated to Sales Order" %} <b><a href="{% url 'so-detail' build.sales_order.id %}">{{ build.sales_order }}</a></b>
</div>
{% endif %}
{% if build.parent %}
<div class='alert alert-block alert-info'>
{% trans "This build is a child of Build" %} <b><a href="{% url 'build-detail' build.parent.id %}">{{ build.parent }}</a></b>
</div>
{% endif %}
{% endblock %}
{% block thumbnail %}
<img class="part-thumb"
{% if build.part.image %}
src="{{ build.part.image.url }}"
{% else %}
src="{% static 'img/blank_image.png' %}"
{% endif %}/>
{% endblock %}
{% block page_data %}
<h3>{% trans "Build" %} {% build_status_label build.status large=True %}</h3>
<hr>
<div class='container-fluid'>
{% block details %}
<h4>{{ build.quantity }} x {{ build.part.full_name }}</h4>
<div class='btn-row'>
<div class='btn-group action-buttons'>
<button type='button' class='btn btn-default' id='build-edit' title='Edit Build'>
<span class='fas fa-edit icon-green'/>
</button>
{% if build.is_active %}
<button type='button' class='btn btn-default' id='build-complete' title="Complete Build">
<span class='fas fa-tools'/>
</button>
<button type='button' class='btn btn-default btn-glyph' id='build-cancel' title='Cancel Build'>
<span class='fas fa-times-circle icon-red'/>
</button>
{% endif %}
{% if build.status == BuildStatus.CANCELLED %}
<button type='button' class='btn btn-default btn-glyph' id='build-delete' title='Delete Build'>
<span class='fas fa-trash-alt icon-red'/>
</button>
{% endif %}
</div>
</div>
{% endblock %}
</div>
{% block page_details %}
<h4>{% trans "Build Details" %}</h4>
<table class='table table-striped table-condensed'>
<tr>
<td></td>
<td>{% trans "Build Title" %}</td>
<td>{{ build.title }}</td>
</tr>
<tr>
<td><span class='fas fa-shapes'></span></td>
<td>{% trans "Part" %}</td>
<td><a href="{% url 'part-detail' build.part.id %}">{{ build.part.full_name }}</a></td>
</tr>
<tr>
<td></td>
<td>{% trans "Quantity" %}</td>
<td>{{ build.quantity }}</td>
</tr>
<tr>
<td><span class='fas fa-info'></span></td>
<td>{% trans "Status" %}</td>
<td>{% build_status_label build.status %}</td>
</tr>
{% if build.parent %}
<tr>
<td><span class='fas fa-sitemap'></span></td>
<td>{% trans "Parent Build" %}</td>
<td><a href="{% url 'build-detail' build.parent.id %}">{{ build.parent }}</a></td>
</tr>
{% endif %}
{% if build.sales_order %}
<tr>
<td><span class='fas fa-dolly'></span></td>
<td>{% trans "Sales Order" %}</td>
<td><a href="{% url 'so-detail' build.sales_order.id %}">{{ build.sales_order }}</a></td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-dollar-sign'></span></td>
<td>{% trans "BOM Price" %}</td>
<td>
{% if bom_price %}
{{ bom_price }}
{% if build.part.has_complete_bom_pricing == False %}
<br><span class='warning-msg'><i>{% trans "BOM pricing is incomplete" %}</i></span>
{% endif %}
{% else %}
<span class='warning-msg'><i>{% trans "No pricing information" %}</i></span>
{% endif %}
</td>
</tr>
</table>
{% endblock %}
{% block js_load %}

View File

@ -1,42 +1,37 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
<b>Build: {{ build.title }}</b> - {{ build.quantity }} x {{ build.part.full_name }}
<br>
Are you sure you want to mark this build as complete?
<hr>
{% if taking %}
The following items will be removed from stock:
<table class='table table-striped table-condensed'>
<tr>
<th></th>
<th>Part</th>
<th>Quantity</th>
<th>Location</th>
</tr>
{% for item in taking %}
<tr>
<td>
{% include "hover_image.html" with image=item.stock_item.part.image hover=True %}
</td>
<td>
{{ item.stock_item.part.full_name }}<br>
<i>{{ item.stock_item.part.description }}</i>
</td>
<td>{{ item.quantity }}</td>
<td>{{ item.stock_item.location }}</td>
</tr>
{% endfor %}
</table>
<h4>{% trans "Build" %} - {{ build }}</h4>
{% if build.isFullyAllocated %}
<div class='alert alert-block alert-info'>
<h4>{% trans "Build order allocation is complete" %}</h4>
</div>
{% else %}
No parts have been allocated to this build.
<div class='alert alert-block alert-danger'>
<h4>{% trans "Warning: Build order allocation is not complete" %}</h4>
{% trans "Build Order has not been fully allocated. Ensure that all Stock Items have been allocated to the Build" %}
</div>
{% endif %}
<hr>
The following items will be created:
<div class='alert alert-block alert-success'>
<h4>{% trans "The following actions will be performed:" %}</h4>
<ul>
<li>{% trans "Remove allocated items from stock" %}</li>
<li>{% trans "Add completed items to stock" %}</li>
</ul>
</div>
<div class='panel panel-default'>
{% include "hover_image.html" with image=build.part.image hover=True %}
{{ build.quantity }} x {{ build.part.full_name }}
<div class='panel-heading'>
{% trans "The following items will be created" %}
</div>
<div class='panel-content'>
{% include "hover_image.html" with image=build.part.image hover=True %}
{{ build.quantity }} x {{ build.part.full_name }}
</div>
</div>
{% endblock %}

View File

@ -40,7 +40,7 @@
<tr>
<td><span class='fas fa-info'></span></td>
<td>{% trans "Status" %}</td>
<td>{% build_status build.status %}</td>
<td>{% build_status_label build.status %}</td>
</tr>
{% if build.batch %}
<tr>

View File

@ -4,13 +4,13 @@
<li{% if tab == 'details' %} class='active'{% endif %}>
<a href="{% url 'build-detail' build.id %}">{% trans "Details" %}</a>
</li>
<li{% if tab == 'allocate' %} class='active'{% endif %}>
<a href="{% url 'build-allocate' build.id %}">{% trans "Allocated Parts" %}</a>
</li>
<li{% if tab == 'output' %} class='active'{% endif %}>
<a href="{% url 'build-output' build.id %}">{% trans "Outputs" %}{% if build.output_count > 0%}<span class='badge'>{{ build.output_count }}</span>{% endif %}</a>
<a href="{% url 'build-output' build.id %}">{% trans "Build Outputs" %}{% if build.output_count > 0%}<span class='badge'>{{ build.output_count }}</span>{% endif %}</a>
</li>
<li{% if tab == 'notes' %} class='active'{% endif %}>
<a href="{% url 'build-notes' build.id %}">{% trans "Notes" %}{% if build.notes %} <span class='glyphicon glyphicon-small glyphicon-info-sign'></span>{% endif %}</a>
</li>
<li{% if tab == 'allocate' %} class='active'{% endif %}>
<a href="{% url 'build-allocate' build.id %}">{% trans "Assign Parts" %}</a>
</li>
</ul>

View File

@ -0,0 +1,227 @@
# -*- coding: utf-8 -*-
from django.test import TestCase
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.utils import IntegrityError
from build.models import Build, BuildItem
from stock.models import StockItem
from part.models import Part, BomItem
from InvenTree import status_codes as status
from InvenTree.helpers import ExtractSerialNumbers
class BuildTest(TestCase):
"""
Run some tests to ensure that the Build model is working properly.
"""
def setUp(self):
"""
Initialize data to use for these tests.
"""
# Create a base "Part"
self.assembly = Part.objects.create(
name="An assembled part",
description="Why does it matter what my description is?",
assembly=True,
trackable=True,
)
self.sub_part_1 = Part.objects.create(
name="Widget A",
description="A widget",
component=True
)
self.sub_part_2 = Part.objects.create(
name="Widget B",
description="A widget",
component=True
)
# Create BOM item links for the parts
BomItem.objects.create(
part=self.assembly,
sub_part=self.sub_part_1,
quantity=10
)
BomItem.objects.create(
part=self.assembly,
sub_part=self.sub_part_2,
quantity=25
)
# Create a "Build" object to make 10x objects
self.build = Build.objects.create(
title="This is a build",
part=self.assembly,
quantity=10
)
# Create some stock items to assign to the build
self.stock_1_1 = StockItem.objects.create(part=self.sub_part_1, quantity=1000)
self.stock_1_2 = StockItem.objects.create(part=self.sub_part_1, quantity=100)
self.stock_2_1 = StockItem.objects.create(part=self.sub_part_2, quantity=5000)
def test_init(self):
# Perform some basic tests before we start the ball rolling
self.assertEqual(StockItem.objects.count(), 3)
self.assertEqual(self.build.status, status.BuildStatus.PENDING)
self.assertFalse(self.build.isFullyAllocated())
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1))
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2))
self.assertEqual(self.build.getRequiredQuantity(self.sub_part_1), 100)
self.assertEqual(self.build.getRequiredQuantity(self.sub_part_2), 250)
self.assertTrue(self.build.can_build)
self.assertFalse(self.build.is_complete)
# Delete some stock and see if the build can still be completed
self.stock_2_1.delete()
self.assertFalse(self.build.can_build)
def test_build_item_clean(self):
# Ensure that dodgy BuildItem objects cannot be created
stock = StockItem.objects.create(part=self.assembly, quantity=99)
# Create a BuiltItem which points to an invalid StockItem
b = BuildItem(stock_item=stock, build=self.build, quantity=10)
with self.assertRaises(ValidationError):
b.clean()
# Create a BuildItem which has too much stock assigned
b = BuildItem(stock_item=self.stock_1_1, build=self.build, quantity=9999999)
with self.assertRaises(ValidationError):
b.clean()
# Negative stock? Not on my watch!
b = BuildItem(stock_item=self.stock_1_1, build=self.build, quantity=-99)
with self.assertRaises(ValidationError):
b.clean()
def test_duplicate_bom_line(self):
# Try to add a duplicate BOM item - it should fail!
with self.assertRaises(IntegrityError):
BomItem.objects.create(
part=self.assembly,
sub_part=self.sub_part_1,
quantity=99
)
def allocate_stock(self, q11, q12, q21):
# Assign stock to this build
BuildItem.objects.create(
build=self.build,
stock_item=self.stock_1_1,
quantity=q11
)
BuildItem.objects.create(
build=self.build,
stock_item=self.stock_1_2,
quantity=q12
)
BuildItem.objects.create(
build=self.build,
stock_item=self.stock_2_1,
quantity=q21
)
with transaction.atomic():
with self.assertRaises(IntegrityError):
BuildItem.objects.create(
build=self.build,
stock_item=self.stock_2_1,
quantity=99
)
self.assertEqual(BuildItem.objects.count(), 3)
def test_partial_allocation(self):
self.allocate_stock(50, 50, 200)
self.assertFalse(self.build.isFullyAllocated())
self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_1))
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2))
self.build.unallocateStock()
self.assertEqual(BuildItem.objects.count(), 0)
def test_auto_allocate(self):
allocations = self.build.getAutoAllocations()
self.assertEqual(len(allocations), 1)
self.build.autoAllocate()
self.assertEqual(BuildItem.objects.count(), 1)
self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_2))
def test_cancel(self):
self.allocate_stock(50, 50, 200)
self.build.cancelBuild(None)
self.assertEqual(BuildItem.objects.count(), 0)
def test_complete(self):
self.allocate_stock(50, 50, 250)
self.assertTrue(self.build.isFullyAllocated())
# Generate some serial numbers!
serials = ExtractSerialNumbers("1-10", 10)
self.build.completeBuild(None, serials, None)
self.assertEqual(self.build.status, status.BuildStatus.COMPLETE)
# the original BuildItem objects should have been deleted!
self.assertEqual(BuildItem.objects.count(), 0)
# New stock items should have been created!
# - Ten for the build output (as the part was serialized)
# - Three for the split items assigned to the build
self.assertEqual(StockItem.objects.count(), 16)
# Stock should have been subtracted from the original items
self.assertEqual(StockItem.objects.get(pk=1).quantity, 950)
self.assertEqual(StockItem.objects.get(pk=2).quantity, 50)
self.assertEqual(StockItem.objects.get(pk=3).quantity, 4750)
# New stock items created and assigned to the build
self.assertEqual(StockItem.objects.get(pk=4).quantity, 50)
self.assertEqual(StockItem.objects.get(pk=4).build_order, self.build)
self.assertEqual(StockItem.objects.get(pk=4).status, status.StockStatus.ASSIGNED_TO_BUILD)
self.assertEqual(StockItem.objects.get(pk=5).quantity, 50)
self.assertEqual(StockItem.objects.get(pk=5).build_order, self.build)
self.assertEqual(StockItem.objects.get(pk=5).status, status.StockStatus.ASSIGNED_TO_BUILD)
self.assertEqual(StockItem.objects.get(pk=6).quantity, 250)
self.assertEqual(StockItem.objects.get(pk=6).build_order, self.build)
self.assertEqual(StockItem.objects.get(pk=6).status, status.StockStatus.ASSIGNED_TO_BUILD)
# And a new stock item created for the build output
self.assertEqual(StockItem.objects.get(pk=7).quantity, 1)
self.assertEqual(StockItem.objects.get(pk=7).serial, 1)
self.assertEqual(StockItem.objects.get(pk=7).build, self.build)

View File

@ -39,7 +39,7 @@ class BuildTestSimple(TestCase):
self.assertEqual(b.batch, 'B2')
self.assertEqual(b.quantity, 21)
self.assertEqual(str(b), 'Build 21 x Orphan - A part without a category')
self.assertEqual(str(b), '21 x Orphan')
def test_url(self):
b1 = Build.objects.get(pk=1)

View File

@ -6,16 +6,6 @@ from django.conf.urls import url, include
from . import views
build_item_detail_urls = [
url('^edit/?', views.BuildItemEdit.as_view(), name='build-item-edit'),
url('^delete/?', views.BuildItemDelete.as_view(), name='build-item-delete'),
]
build_item_urls = [
url(r'^(?P<pk>\d+)/', include(build_item_detail_urls)),
url('^new/', views.BuildItemCreate.as_view(), name='build-item-create'),
]
build_detail_urls = [
url(r'^edit/', views.BuildUpdate.as_view(), name='build-edit'),
url(r'^allocate/', views.BuildAllocate.as_view(), name='build-allocate'),
@ -33,7 +23,13 @@ build_detail_urls = [
]
build_urls = [
url(r'item/', include(build_item_urls)),
url(r'item/', include([
url(r'^(?P<pk>\d+)/', include([
url('^edit/?', views.BuildItemEdit.as_view(), name='build-item-edit'),
url('^delete/?', views.BuildItemDelete.as_view(), name='build-item-delete'),
])),
url('^new/', views.BuildItemCreate.as_view(), name='build-item-create'),
])),
url(r'new/', views.BuildCreate.as_view(), name='build-create'),

View File

@ -125,7 +125,7 @@ class BuildAutoAllocate(AjaxUpdateView):
if confirm is False:
form.errors['confirm'] = [_('Confirm stock allocation')]
form.non_field_errors = _('Check the confirmation box at the bottom of the list')
form.non_field_errors = [_('Check the confirmation box at the bottom of the list')]
else:
build.autoAllocate()
valid = True
@ -159,7 +159,7 @@ class BuildUnallocate(AjaxUpdateView):
if confirm is False:
form.errors['confirm'] = [_('Confirm unallocation of build stock')]
form.non_field_errors = _('Check the confirmation box')
form.non_field_errors = [_('Check the confirmation box')]
else:
build.unallocateStock()
valid = True
@ -261,13 +261,13 @@ class BuildComplete(AjaxUpdateView):
try:
location = StockLocation.objects.get(id=loc_id)
valid = True
except StockLocation.DoesNotExist:
except (ValueError, StockLocation.DoesNotExist):
form.errors['location'] = [_('Invalid location selected')]
serials = []
if build.part.trackable:
# A build for a trackable part must specify serial numbers
# A build for a trackable part may optionally specify serial numbers.
sn = request.POST.get('serial_numbers', '')
@ -295,7 +295,9 @@ class BuildComplete(AjaxUpdateView):
valid = False
if valid:
build.completeBuild(location, serials, request.user)
if not build.completeBuild(location, serials, request.user):
form.non_field_errors = [('Build could not be completed')]
valid = False
data = {
'form_valid': valid,
@ -393,13 +395,15 @@ class BuildCreate(AjaxCreateView):
initials = super(BuildCreate, self).get_initial().copy()
part_id = self.request.GET.get('part', None)
# User has provided a Part ID
initials['part'] = self.request.GET.get('part', None)
if part_id:
try:
initials['part'] = Part.objects.get(pk=part_id)
except Part.DoesNotExist:
pass
initials['parent'] = self.request.GET.get('parent', None)
# User has provided a SalesOrder ID
initials['sales_order'] = self.request.GET.get('sales_order', None)
initials['quantity'] = self.request.GET.get('quantity', 1)
return initials
@ -540,27 +544,64 @@ class BuildItemCreate(AjaxCreateView):
build_id = self.get_param('build')
part_id = self.get_param('part')
# Reference to a Part object
part = None
# Reference to a StockItem object
item = None
# Reference to a Build object
build = None
if part_id:
try:
part = Part.objects.get(pk=part_id)
initials['part'] = part
except Part.DoesNotExist:
part = None
else:
part = None
pass
if build_id:
try:
build = Build.objects.get(pk=build_id)
initials['build'] = build
# Try to work out how many parts to allocate
if part:
unallocated = build.getUnallocatedQuantity(part)
initials['quantity'] = unallocated
except Build.DoesNotExist:
pass
quantity = self.request.GET.get('quantity', None)
if quantity is not None:
quantity = float(quantity)
if quantity is None:
# Work out how many parts remain to be alloacted for the build
if part:
quantity = build.getUnallocatedQuantity(part)
item_id = self.get_param('item')
# If the request specifies a particular StockItem
if item_id:
try:
item = StockItem.objects.get(pk=item_id)
except:
pass
# If a StockItem is not selected, try to auto-select one
if item is None and part is not None:
items = StockItem.objects.filter(part=part)
if items.count() == 1:
item = items.first()
# Finally, if a StockItem is selected, ensure the quantity is not too much
if item is not None:
if quantity is None:
quantity = item.unallocated_quantity()
else:
quantity = min(quantity, item.unallocated_quantity())
if quantity is not None:
initials['quantity'] = quantity
return initials

View File

@ -25,7 +25,7 @@ from stdimage.models import StdImageField
from InvenTree.helpers import getMediaUrl, getBlankImage, getBlankThumbnail
from InvenTree.helpers import normalize
from InvenTree.fields import InvenTreeURLField, RoundingDecimalField
from InvenTree.status_codes import OrderStatus
from InvenTree.status_codes import PurchaseOrderStatus
from common.models import Currency
@ -185,11 +185,11 @@ class Company(models.Model):
def outstanding_purchase_orders(self):
""" Return purchase orders which are 'outstanding' """
return self.purchase_orders.filter(status__in=OrderStatus.OPEN)
return self.purchase_orders.filter(status__in=PurchaseOrderStatus.OPEN)
def pending_purchase_orders(self):
""" Return purchase orders which are PENDING (not yet issued) """
return self.purchase_orders.filter(status=OrderStatus.PENDING)
return self.purchase_orders.filter(status=PurchaseOrderStatus.PENDING)
def closed_purchase_orders(self):
""" Return purchase orders which are not 'outstanding'
@ -199,15 +199,15 @@ class Company(models.Model):
- Returned
"""
return self.purchase_orders.exclude(status__in=OrderStatus.OPEN)
return self.purchase_orders.exclude(status__in=PurchaseOrderStatus.OPEN)
def complete_purchase_orders(self):
return self.purchase_orders.filter(status=OrderStatus.COMPLETE)
return self.purchase_orders.filter(status=PurchaseOrderStatus.COMPLETE)
def failed_purchase_orders(self):
""" Return any purchase orders which were not successful """
return self.purchase_orders.filter(status__in=OrderStatus.FAILED)
return self.purchase_orders.filter(status__in=PurchaseOrderStatus.FAILED)
class Contact(models.Model):
@ -384,7 +384,7 @@ class SupplierPart(models.Model):
limited to purchase orders that are open / outstanding.
"""
return self.purchase_order_line_items.prefetch_related('order').filter(order__status__in=OrderStatus.OPEN)
return self.purchase_order_line_items.prefetch_related('order').filter(order__status__in=PurchaseOrderStatus.OPEN)
def on_order(self):
""" Return the total quantity of items currently on order.

View File

@ -64,15 +64,11 @@ class CompanySerializer(InvenTreeModelSerializer):
class SupplierPartSerializer(InvenTreeModelSerializer):
""" Serializer for SupplierPart object """
url = serializers.CharField(source='get_absolute_url', read_only=True)
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
supplier_detail = CompanyBriefSerializer(source='supplier', many=False, read_only=True)
manufacturer_detail = CompanyBriefSerializer(source='manufacturer', many=False, read_only=True)
pricing = serializers.CharField(source='unit_pricing', read_only=True)
def __init__(self, *args, **kwargs):
part_detail = kwargs.pop('part_detail', False)
@ -94,7 +90,6 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
model = SupplierPart
fields = [
'pk',
'url',
'part',
'part_detail',
'supplier',
@ -105,7 +100,6 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
'description',
'MPN',
'link',
'pricing',
]

View File

@ -1,4 +1,4 @@
{% extends "base.html" %}
{% extends "two_column.html" %}
{% load static %}
{% load i18n %}
@ -7,100 +7,81 @@
InvenTree | {% trans "Company" %} - {{ company.name }}
{% endblock %}
{% block content %}
<div class="row">
<div class="col-sm-6">
<div class="media">
<div class='media-left'>
<div class='dropzone' id='company-thumb'>
<img class="part-thumb"
{% if company.image %}
src="{{ company.image.url }}"
{% else %}
src="{% static 'img/blank_image.png' %}"
{% endif %}/>
</div>
</div>
<div class='media-body'>
<h4>{{ company.name }}</h4>
<p>{{ company.description }}</p>
<div class='btn-group'>
{% if company.is_supplier %}
<button type='button' class='btn btn-default btn-glyph' id='company-order-2' title='Create purchase order'>
<span class='glyphicon glyphicon-shopping-cart'/>
</button>
{% endif %}
<button type='button' class='btn btn-default btn-glyph' id='company-edit' title='Edit company information'>
<span class='glyphicon glyphicon-edit'/>
</button>
<button type='button' class='btn btn-default btn-glyph' id='company-delete' title='Delete company'>
<span class='glyphicon glyphicon-trash'/>
</button>
</div>
</div>
</div>
</div>
<div class="col-sm-6">
<table class="table">
<col width='25'>
{% if company.website %}
<tr>
<td><span class='fas fa-globe'></span></td>
<td>{% trans "Website" %}</td>
<td><a href="{{ company.website }}">{{ company.website }}</a></td>
</tr>
{% endif %}
{% if company.address %}
<tr>
<td><span class='fas fa-map-marked-alt'></span></td>
<td>{% trans "Address" %}</td>
<td>{{ company.address }}</td>
</tr>
{% endif %}
{% if company.phone %}
<tr>
<td><span class='fas fa-phone'></span></td>
<td>{% trans "Phone" %}</td>
<td>{{ company.phone }}</td>
</tr>
{% endif %}
{% if company.email %}
<tr>
<td><span class='fas fa-at'></span></td>
<td>{% trans "Email" %}</td>
<td>{{ company.email }}</td>
</tr>
{% endif %}
{% if company.contact %}
<tr>
<td><span class='fas fa-user'></span></td>
<td>{% trans "Contact" %}</td>
<td>{{ company.contact }}</td>
</tr>
{% endif %}
</table>
</div>
{% block thumbnail %}
<div class='dropzone' id='company-thumb'>
<img class="part-thumb"
{% if company.image %}
src="{{ company.image.url }}"
{% else %}
src="{% static 'img/blank_image.png' %}"
{% endif %}/>
</div>
{% endblock %}
{% block page_data %}
<h3>{% trans "Company" %}</h3>
<hr>
<div class='container-fluid'>
{% block details %}
{% endblock %}
<h4>{{ company.name }}</h4>
<p>{{ company.description }}</p>
<div class='btn-group action-buttons'>
{% if company.is_supplier %}
<button type='button' class='btn btn-default' id='company-order-2' title='Create purchase order'>
<span class='fas fa-shopping-cart'/>
</button>
{% endif %}
<button type='button' class='btn btn-default' id='company-edit' title='Edit company information'>
<span class='fas fa-edit icon-green'/>
</button>
<button type='button' class='btn btn-default' id='company-delete' title='Delete company'>
<span class='fas fa-trash-alt icon-red'/>
</button>
</div>
{% endblock %}
{% block js_load %}
{{ block.super }}
<script type='text/javascript' src="{% static 'script/inventree/stock.js' %}"></script>
{% block page_details %}
<h4>{% trans "Company Details" %}</h4>
<table class="table">
<col width='25'>
{% if company.website %}
<tr>
<td><span class='fas fa-globe'></span></td>
<td>{% trans "Website" %}</td>
<td><a href="{{ company.website }}">{{ company.website }}</a></td>
</tr>
{% endif %}
{% if company.address %}
<tr>
<td><span class='fas fa-map-marked-alt'></span></td>
<td>{% trans "Address" %}</td>
<td>{{ company.address }}</td>
</tr>
{% endif %}
{% if company.phone %}
<tr>
<td><span class='fas fa-phone'></span></td>
<td>{% trans "Phone" %}</td>
<td>{{ company.phone }}</td>
</tr>
{% endif %}
{% if company.email %}
<tr>
<td><span class='fas fa-at'></span></td>
<td>{% trans "Email" %}</td>
<td>{{ company.email }}</td>
</tr>
{% endif %}
{% if company.contact %}
<tr>
<td><span class='fas fa-user'></span></td>
<td>{% trans "Contact" %}</td>
<td>{{ company.contact }}</td>
</tr>
{% endif %}
</table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
$('#company-edit').click(function() {
launchModalForm(

View File

@ -1,8 +1,9 @@
{% extends "company/company_base.html" %}
{% load static %}
{% block details %}
{% load i18n %}
{% block details %}
{% include 'company/tabs.html' with tab='po' %}
<h4>{% trans "Purchase Orders" %}</h4>
@ -10,8 +11,8 @@
<div id='button-bar'>
<div class='button-toolbar container-fluid' style='float: right;'>
<button class='btn btn-primary' type='button' id='company-order2' title='Create new purchase order'>{% trans "New Purchase Order" %}</button>
<div class='filter-list' id='filter-list-order'>
<button class='btn btn-primary' type='button' id='company-order2' title='{% trans "Create new purchase order" %}'>{% trans "New Purchase Order" %}</button>
<div class='filter-list' id='filter-list-purchaseorder'>
<!-- Empty div -->
</div>
</div>
@ -26,7 +27,10 @@
{{ block.super }}
loadPurchaseOrderTable("#purchase-order-table", {
url: "{% url 'api-po-list' %}?supplier={{ company.id }}",
url: "{% url 'api-po-list' %}",
params: {
supplier: {{ company.id }},
}
});

View File

@ -0,0 +1,41 @@
{% extends "company/company_base.html" %}
{% load static %}
{% load i18n %}
{% block details %}
{% include 'company/tabs.html' with tab='co' %}
<h4>{% trans "Sales Orders" %}</h4>
<hr>
<div id='button-bar'>
<div class='button-toolbar container-fluid' style='float: right;'>
<button class='btn btn-primary' type='button' id='new-sales-order' title='{% trans "Create new sales order" %}'>{% trans "New Sales Order" %}</button>
<div class='filter-list' id='filter-list-salesorder'>
<!-- Empty div -->
</div>
</div>
</div>
<table class='table table-striped table-condensed po-table' id='sales-order-table' data-toolbar='#button-bar'>
</table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
loadSalesOrderTable("#sales-order-table", {
url: "{% url 'api-so-list' %}",
params: {
customer: {{ company.id }},
}
});
$("#new-sales-order").click(function() {
// TODO - Create a new sales order
});
{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends "base.html" %}
{% extends "two_column.html" %}
{% load static %}
{% load i18n %}
@ -6,99 +6,86 @@
InvenTree | {% trans "Supplier Part" %}
{% endblock %}
{% block content %}
{% block thumbnail %}
<img class='part-thumb'
{% if part.part.image %}
src='{{ part.part.image.url }}'
{% else %}
src="{% static 'img/blank_image.png' %}"
{% endif %}/>
{% endblock %}
<div class='row'>
<div class='col-sm-6'>
<h3>{% trans "Supplier Part" %}</h3>
<div class='btn-row'>
<div class='btn-group'>
<button type='button' class='btn btn-default btn-glyph' id='edit-part' title='Edit supplier part'>
<span class='glyphicon glyphicon-edit'/>
</button>
<button type='button' class='btn btn-default btn-glyph' id='delete-part' title='Delete supplier part'>
<span class='glyphicon glyphicon-trash'/>
</button>
</div>
</div>
<div class='media-left'>
<img class='part-thumb'
{% if part.part.image %}
src='{{ part.part.image.url }}'
{% else %}
src="{% static 'img/blank_image.png' %}"
{% endif %}/>
</div>
</div>
<div class='col-sm-6'>
<h4>{% trans "Supplier Part Details" %}</h4>
<table class="table table-striped table-condensed">
<col width='25'>
<tr>
<td><span class='fas fa-shapes'></span></td>
<td>{% trans "Internal Part" %}</td>
<td>
{% if part.part %}
<a href="{% url 'part-suppliers' part.part.id %}">{{ part.part.full_name }}</a>
{% endif %}
</td>
</tr>
{% if part.description %}
<tr>
<td>{% trans "Description" %}</td>
<td>{{ part.description }}</td>
</tr>
{% endif %}
{% if part.link %}
<tr>
<td><span class='fas fa-link'></span></td>
<td>{% trans "External Link" %}</td>
<td><a href="{{ part.link }}">{{ part.link }}</a></td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-building'></span></td>
<td>{% trans "Supplier" %}</td>
<td><a href="{% url 'company-detail-parts' part.supplier.id %}">{{ part.supplier.name }}</a></td></tr>
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "SKU" %}</td>
<td>{{ part.SKU }}</tr>
</tr>
{% if part.manufacturer %}
<tr>
<td><span class='fas fa-industry'></span></td>
<td>{% trans "Manufacturer" %}</td>
<td><a href="{% url 'company-detail-parts' part.manufacturer.id %}">{{ part.manufacturer.name }}</a></td></tr>
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "MPN" %}</td>
<td>{{ part.MPN }}</td>
</tr>
{% endif %}
{% if part.note %}
<tr>
<td><span class='fas fa-sticky-note'></span></td>
<td>{% trans "Note" %}</td>
<td>{{ part.note }}</td>
</tr>
{% endif %}
</table>
{% block page_data %}
<h3>{% trans "Supplier Part" %}</h3>
<p>{{ part.supplier.name }} - {{ part.SKU }}</p>
<div class='btn-row'>
<div class='btn-group'>
<button type='button' class='btn btn-default btn-glyph' id='edit-part' title='Edit supplier part'>
<span class='glyphicon glyphicon-edit'/>
</button>
<button type='button' class='btn btn-default btn-glyph' id='delete-part' title='Delete supplier part'>
<span class='glyphicon glyphicon-trash'/>
</button>
</div>
</div>
{% endblock %}
{% block page_details %}
<hr>
<div class='container-fluid'>
{% block details %}
<!-- Particular SupplierPart page goes here ... -->
{% endblock %}
</div>
<h4>{% trans "Supplier Part Details" %}</h4>
<table class="table table-striped table-condensed">
<col width='25'>
<tr>
<td><span class='fas fa-shapes'></span></td>
<td>{% trans "Internal Part" %}</td>
<td>
{% if part.part %}
<a href="{% url 'part-suppliers' part.part.id %}">{{ part.part.full_name }}</a>
{% endif %}
</td>
</tr>
{% if part.description %}
<tr>
<td></td>
<td>{% trans "Description" %}</td>
<td>{{ part.description }}</td>
</tr>
{% endif %}
{% if part.link %}
<tr>
<td><span class='fas fa-link'></span></td>
<td>{% trans "External Link" %}</td>
<td><a href="{{ part.link }}">{{ part.link }}</a></td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-building'></span></td>
<td>{% trans "Supplier" %}</td>
<td><a href="{% url 'company-detail-parts' part.supplier.id %}">{{ part.supplier.name }}</a></td></tr>
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "SKU" %}</td>
<td>{{ part.SKU }}</tr>
</tr>
{% if part.manufacturer %}
<tr>
<td><span class='fas fa-industry'></span></td>
<td>{% trans "Manufacturer" %}</td>
<td><a href="{% url 'company-detail-parts' part.manufacturer.id %}">{{ part.manufacturer.name }}</a></td></tr>
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "MPN" %}</td>
<td>{{ part.MPN }}</td>
</tr>
{% endif %}
{% if part.note %}
<tr>
<td><span class='fas fa-sticky-note'></span></td>
<td>{% trans "Note" %}</td>
<td>{{ part.note }}</td>
</tr>
{% endif %}
</table>
{% endblock %}
{% block js_ready %}

View File

@ -18,12 +18,10 @@
</li>
{% endif %}
{% if company.is_customer %}
{% if 0 %}
<li{% if tab == 'co' %} class='active'{% endif %}>
<a href="#">{% trans "Sales Orders" %}</a>
<a href="{% url 'company-detail-sales-orders' company.id %}">{% trans "Sales Orders" %} <span class='badge'>{{ company.sales_orders.count }}</span></a>
</li>
{% endif %}
{% endif %}
<li{% if tab == 'notes' %} class='active'{% endif %}>
<a href="{% url 'company-notes' company.id %}">{% trans "Notes" %}{% if company.notes %} <span class='glyphicon glyphicon-small glyphicon-info-sign'></span>{% endif %}</a>
</li>

View File

@ -15,7 +15,8 @@ company_detail_urls = [
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'purchase-orders/?', views.CompanyDetail.as_view(template_name='company/detail_purchase_orders.html'), name='company-detail-purchase-orders'),
url(r'purchase-orders/?', views.CompanyDetail.as_view(template_name='company/purchase_orders.html'), name='company-detail-purchase-orders'),
url(r'sales-orders/?', views.CompanyDetail.as_view(template_name='company/sales_orders.html'), name='company-detail-sales-orders'),
url(r'notes/?', views.CompanyNotes.as_view(), name='company-notes'),
url(r'thumbnail/?', views.CompanyImage.as_view(), name='company-image'),

View File

@ -13,7 +13,6 @@ from django.urls import reverse
from django.forms import HiddenInput
from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView
from InvenTree.status_codes import OrderStatus
from InvenTree.helpers import str2bool
from common.models import Currency
@ -137,7 +136,6 @@ class CompanyDetail(DetailView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['OrderStatus'] = OrderStatus
return ctx
@ -244,7 +242,6 @@ class SupplierPartDetail(DetailView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['OrderStatus'] = OrderStatus
return ctx

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,8 @@ from import_export.resources import ModelResource
from import_export.fields import Field
from .models import PurchaseOrder, PurchaseOrderLineItem
from .models import SalesOrder, SalesOrderLineItem
from .models import SalesOrderAllocation
class PurchaseOrderAdmin(ImportExportModelAdmin):
@ -22,6 +24,17 @@ class PurchaseOrderAdmin(ImportExportModelAdmin):
)
class SalesOrderAdmin(ImportExportModelAdmin):
list_display = (
'reference',
'customer',
'status',
'description',
'creation_date',
)
class POLineItemResource(ModelResource):
""" Class for managing import / export of POLineItem data """
@ -40,6 +53,16 @@ class POLineItemResource(ModelResource):
clean_model_instances = True
class SOLineItemResource(ModelResource):
""" Class for managing import / export of SOLineItem data """
class Meta:
model = SalesOrderLineItem
skip_unchanged = True
report_skipped = False
clean_model_instances = True
class PurchaseOrderLineItemAdmin(ImportExportModelAdmin):
resource_class = POLineItemResource
@ -52,5 +75,31 @@ class PurchaseOrderLineItemAdmin(ImportExportModelAdmin):
)
class SalesOrderLineItemAdmin(ImportExportModelAdmin):
resource_class = SOLineItemResource
list_display = (
'order',
'part',
'quantity',
'reference'
)
class SalesOrderAllocationAdmin(ImportExportModelAdmin):
list_display = (
'line',
'item',
'quantity'
)
admin.site.register(PurchaseOrder, PurchaseOrderAdmin)
admin.site.register(PurchaseOrderLineItem, PurchaseOrderLineItemAdmin)
admin.site.register(SalesOrder, SalesOrderAdmin)
admin.site.register(SalesOrderLineItem, SalesOrderLineItemAdmin)
admin.site.register(SalesOrderAllocation, SalesOrderAllocationAdmin)

View File

@ -19,9 +19,12 @@ from company.models import SupplierPart
from .models import PurchaseOrder, PurchaseOrderLineItem
from .serializers import POSerializer, POLineItemSerializer
from .models import SalesOrder, SalesOrderLineItem
from .serializers import SalesOrderSerializer, SOLineItemSerializer
class POList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of Order objects
""" API endpoint for accessing a list of PurchaseOrder objects
- GET: Return list of PO objects (with filters)
- POST: Create a new PurchaseOrder object
@ -150,7 +153,7 @@ class PODetail(generics.RetrieveUpdateAPIView):
class POLineItemList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of PO Line Item objects
""" API endpoint for accessing a list of POLineItem objects
- GET: Return a list of PO Line Item objects
- POST: Create a new PurchaseOrderLineItem object
@ -159,6 +162,17 @@ class POLineItemList(generics.ListCreateAPIView):
queryset = PurchaseOrderLineItem.objects.all()
serializer_class = POLineItemSerializer
def get_serializer(self, *args, **kwargs):
try:
kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', False))
except AttributeError:
pass
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
permission_classes = [
permissions.IsAuthenticated,
]
@ -184,10 +198,200 @@ class POLineItemDetail(generics.RetrieveUpdateAPIView):
]
po_api_urls = [
url(r'^order/(?P<pk>\d+)/?$', PODetail.as_view(), name='api-po-detail'),
url(r'^order/?$', POList.as_view(), name='api-po-list'),
class SOList(generics.ListCreateAPIView):
"""
API endpoint for accessing a list of SalesOrder objects.
url(r'^line/(?P<pk>\d+)/?$', POLineItemDetail.as_view(), name='api-po-line-detail'),
url(r'^line/?$', POLineItemList.as_view(), name='api-po-line-list'),
- GET: Return list of SO objects (with filters)
- POST: Create a new SalesOrder
"""
queryset = SalesOrder.objects.all()
serializer_class = SalesOrderSerializer
def get_serializer(self, *args, **kwargs):
try:
kwargs['customer_detail'] = str2bool(self.request.query_params.get('customer_detail', False))
except AttributeError:
pass
# Ensure the context is passed through to the serializer
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
def get_queryset(self, *args, **kwargs):
queryset = super().get_queryset(*args, **kwargs)
queryset = queryset.prefetch_related(
'customer',
'lines'
)
queryset = SalesOrderSerializer.annotate_queryset(queryset)
return queryset
def filter_queryset(self, queryset):
"""
Perform custom filtering operations on the SalesOrder queryset.
"""
queryset = super().filter_queryset(queryset)
params = self.request.query_params
status = params.get('status', None)
if status is not None:
queryset = queryset.filter(status=status)
# Filter by "Part"
# Only return SalesOrder which have LineItem referencing the part
part = params.get('part', None)
if part is not None:
try:
part = Part.objects.get(pk=part)
queryset = queryset.filter(id__in=[so.id for so in part.sales_orders()])
except (Part.DoesNotExist, ValueError):
pass
return queryset
permission_classes = [
permissions.IsAuthenticated
]
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
filter_fields = [
'customer',
]
ordering_fields = [
'creation_date',
'reference'
]
ordering = '-creation_date'
class SODetail(generics.RetrieveUpdateAPIView):
"""
API endpoint for detail view of a SalesOrder object.
"""
queryset = SalesOrder.objects.all()
serializer_class = SalesOrderSerializer
def get_serializer(self, *args, **kwargs):
try:
kwargs['customer_detail'] = str2bool(self.request.query_params.get('customer_detail', False))
except AttributeError:
pass
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
def get_queryset(self, *args, **kwargs):
queryset = super().get_queryset(*args, **kwargs)
queryset = queryset.prefetch_related('customer', 'lines')
queryset = SalesOrderSerializer.annotate_queryset(queryset)
return queryset
permission_classes = [permissions.IsAuthenticated]
class SOLineItemList(generics.ListCreateAPIView):
"""
API endpoint for accessing a list of SalesOrderLineItem objects.
"""
queryset = SalesOrderLineItem.objects.all()
serializer_class = SOLineItemSerializer
def get_serializer(self, *args, **kwargs):
try:
kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', False))
except AttributeError:
pass
try:
kwargs['order_detail'] = str2bool(self.request.query_params.get('order_detail', False))
except AttributeError:
pass
try:
kwargs['allocations'] = str2bool(self.request.query_params.get('allocations', False))
except AttributeError:
pass
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
def get_queryset(self, *args, **kwargs):
queryset = super().get_queryset(*args, **kwargs)
queryset = queryset.prefetch_related(
'part',
'part__stock_items',
'allocations',
'allocations__item__location',
'order',
'order__stock_items',
)
return queryset
permission_classes = [permissions.IsAuthenticated]
filter_backends = [DjangoFilterBackend]
filter_fields = [
'order',
'part',
]
class SOLineItemDetail(generics.RetrieveUpdateAPIView):
""" API endpoint for detail view of a SalesOrderLineItem object """
queryset = SalesOrderLineItem.objects.all()
serializer_class = SOLineItemSerializer
permission_classes = [permissions.IsAuthenticated]
order_api_urls = [
# API endpoints for purchase orders
url(r'^po/(?P<pk>\d+)/$', PODetail.as_view(), name='api-po-detail'),
url(r'^po/$', POList.as_view(), name='api-po-list'),
# API endpoints for purchase order line items
url(r'^po-line/(?P<pk>\d+)/$', POLineItemDetail.as_view(), name='api-po-line-detail'),
url(r'^po-line/$', POLineItemList.as_view(), name='api-po-line-list'),
# API endpoints for sales ordesr
url(r'^so/(?P<pk>\d+)/$', SODetail.as_view(), name='api-so-detail'),
url(r'^so/$', SOList.as_view(), name='api-so-list'),
# API endpoints for sales order line items
url(r'^so-line/(?P<pk>\d+)/$', SOLineItemDetail.as_view(), name='api-so-line-detail'),
url(r'^so-line/$', SOLineItemList.as_view(), name='api-so-line-list'),
]

View File

@ -15,6 +15,8 @@ from InvenTree.fields import RoundingDecimalFormField
from stock.models import StockLocation
from .models import PurchaseOrder, PurchaseOrderLineItem, PurchaseOrderAttachment
from .models import SalesOrder, SalesOrderLineItem, SalesOrderAttachment
from .models import SalesOrderAllocation
class IssuePurchaseOrderForm(HelperForm):
@ -48,8 +50,30 @@ class CancelPurchaseOrderForm(HelperForm):
fields = [
'confirm',
]
class CancelSalesOrderForm(HelperForm):
confirm = forms.BooleanField(required=False, help_text=_('Cancel order'))
class Meta:
model = SalesOrder
fields = [
'confirm',
]
class ShipSalesOrderForm(HelperForm):
confirm = forms.BooleanField(required=False, help_text=_('Ship order'))
class Meta:
model = SalesOrder
fields = [
'confirm',
]
class ReceivePurchaseOrderForm(HelperForm):
location = TreeNodeChoiceField(queryset=StockLocation.objects.all(), required=True, help_text=_('Receive parts to this location'))
@ -75,6 +99,20 @@ class EditPurchaseOrderForm(HelperForm):
]
class EditSalesOrderForm(HelperForm):
""" Form for editing a SalesOrder object """
class Meta:
model = SalesOrder
fields = [
'reference',
'customer',
'customer_reference',
'description',
'link'
]
class EditPurchaseOrderAttachmentForm(HelperForm):
""" Form for editing a PurchaseOrderAttachment object """
@ -87,6 +125,18 @@ class EditPurchaseOrderAttachmentForm(HelperForm):
]
class EditSalesOrderAttachmentForm(HelperForm):
""" Form for editing a SalesOrderAttachment object """
class Meta:
model = SalesOrderAttachment
fields = [
'order',
'attachment',
'comment'
]
class EditPurchaseOrderLineItemForm(HelperForm):
""" Form for editing a PurchaseOrderLineItem object """
@ -101,3 +151,32 @@ class EditPurchaseOrderLineItemForm(HelperForm):
'reference',
'notes',
]
class EditSalesOrderLineItemForm(HelperForm):
""" Form for editing a SalesOrderLineItem object """
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5)
class Meta:
model = SalesOrderLineItem
fields = [
'order',
'part',
'quantity',
'reference',
'notes'
]
class EditSalesOrderAllocationForm(HelperForm):
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5)
class Meta:
model = SalesOrderAllocation
fields = [
'line',
'item',
'quantity']

View File

@ -0,0 +1,76 @@
# Generated by Django 3.0.5 on 2020-04-20 09:40
import InvenTree.fields
import InvenTree.models
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import markdownx.models
class Migration(migrations.Migration):
dependencies = [
('company', '0021_remove_supplierpart_manufacturer_name'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('order', '0019_purchaseorder_supplier_reference'),
]
operations = [
migrations.CreateModel(
name='SalesOrder',
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)),
('link', models.URLField(blank=True, help_text='Link to external page')),
('creation_date', models.DateField(blank=True, null=True)),
('status', models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Placed'), (30, 'Complete'), (40, 'Cancelled'), (50, 'Lost'), (60, 'Returned')], default=10, help_text='Order status')),
('issue_date', models.DateField(blank=True, null=True)),
('complete_date', models.DateField(blank=True, null=True)),
('notes', markdownx.models.MarkdownxField(blank=True, help_text='Order notes')),
('customer_reference', models.CharField(blank=True, help_text='Customer order reference code', max_length=64)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('customer', models.ForeignKey(help_text='Customer', limit_choices_to={True, 'is_supplier'}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sales_orders', to='company.Company')),
],
options={
'abstract': False,
},
),
migrations.AlterField(
model_name='purchaseorder',
name='supplier',
field=models.ForeignKey(help_text='Supplier', limit_choices_to={'is_supplier': True}, on_delete=django.db.models.deletion.CASCADE, related_name='purchase_orders', to='company.Company'),
),
migrations.AlterField(
model_name='purchaseorder',
name='supplier_reference',
field=models.CharField(blank=True, help_text='Supplier order reference code', max_length=64),
),
migrations.CreateModel(
name='SalesOrderLineItem',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, help_text='Item quantity', max_digits=15, validators=[django.core.validators.MinValueValidator(0)])),
('reference', models.CharField(blank=True, help_text='Line item reference', max_length=100)),
('notes', models.CharField(blank=True, help_text='Line item notes', max_length=500)),
('order', models.ForeignKey(help_text='Sales Order', on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='order.SalesOrder')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='SalesOrderAttachment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attachment', models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment)),
('comment', models.CharField(help_text='File comment', max_length=100)),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='order.SalesOrder')),
],
options={
'abstract': False,
},
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 3.0.5 on 2020-04-20 10:10
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('company', '0021_remove_supplierpart_manufacturer_name'),
('order', '0020_auto_20200420_0940'),
]
operations = [
migrations.AlterField(
model_name='salesorder',
name='customer',
field=models.ForeignKey(help_text='Customer', limit_choices_to={'is_customer': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sales_orders', to='company.Company'),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 3.0.5 on 2020-04-20 22:33
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('part', '0035_auto_20200406_0045'),
('order', '0021_auto_20200420_1010'),
]
operations = [
migrations.AddField(
model_name='salesorderlineitem',
name='part',
field=models.ForeignKey(help_text='Part', limit_choices_to={'salable': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sales_orders', to='part.Part'),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 3.0.5 on 2020-04-20 23:09
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('part', '0035_auto_20200406_0045'),
('order', '0022_salesorderlineitem_part'),
]
operations = [
migrations.AlterField(
model_name='salesorderlineitem',
name='part',
field=models.ForeignKey(help_text='Part', limit_choices_to={'salable': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sales_order_line_items', to='part.Part'),
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 3.0.5 on 2020-04-22 02:09
import InvenTree.fields
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stock', '0030_auto_20200422_0015'),
('order', '0023_auto_20200420_2309'),
]
operations = [
migrations.CreateModel(
name='SalesOrderAllocation',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, max_digits=15, validators=[django.core.validators.MinValueValidator(0)])),
('item', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='sales_order_allocation', to='stock.StockItem')),
('line', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allocations', to='order.SalesOrderLineItem')),
],
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.0.5 on 2020-04-22 02:22
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('stock', '0031_auto_20200422_0209'),
('order', '0024_salesorderallocation'),
]
operations = [
migrations.AlterUniqueTogether(
name='salesorderallocation',
unique_together={('line', 'item')},
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 3.0.5 on 2020-04-22 02:24
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stock', '0031_auto_20200422_0209'),
('order', '0025_auto_20200422_0222'),
]
operations = [
migrations.AlterField(
model_name='salesorderallocation',
name='item',
field=models.OneToOneField(limit_choices_to={'part__salable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='sales_order_allocation', to='stock.StockItem'),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 3.0.5 on 2020-04-22 02:36
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stock', '0031_auto_20200422_0209'),
('order', '0026_auto_20200422_0224'),
]
operations = [
migrations.AlterField(
model_name='salesorderallocation',
name='item',
field=models.ForeignKey(limit_choices_to={'part__salable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='sales_order_allocations', to='stock.StockItem'),
),
]

View File

@ -0,0 +1,37 @@
# Generated by Django 3.0.5 on 2020-04-23 09:56
import InvenTree.fields
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stock', '0031_auto_20200422_0209'),
('order', '0027_auto_20200422_0236'),
]
operations = [
migrations.AlterField(
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='Purchase order status'),
),
migrations.AlterField(
model_name='salesorder',
name='status',
field=models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Shipped'), (40, 'Cancelled'), (50, 'Lost'), (60, 'Returned')], default=10, help_text='Purchase order status'),
),
migrations.AlterField(
model_name='salesorderallocation',
name='item',
field=models.ForeignKey(help_text='Select stock item to allocate', limit_choices_to={'part__salable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='sales_order_allocations', to='stock.StockItem'),
),
migrations.AlterField(
model_name='salesorderallocation',
name='quantity',
field=InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, help_text='Enter stock allocation quantity', max_digits=15, validators=[django.core.validators.MinValueValidator(0)]),
),
]

View File

@ -0,0 +1,30 @@
# Generated by Django 3.0.5 on 2020-04-23 10:42
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', '0028_auto_20200423_0956'),
]
operations = [
migrations.RenameField(
model_name='salesorder',
old_name='complete_date',
new_name='shipment_date',
),
migrations.RemoveField(
model_name='salesorder',
name='issue_date',
),
migrations.AddField(
model_name='salesorder',
name='shipped_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,20 @@
# Generated by Django 3.0.5 on 2020-04-26 05:51
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stock', '0033_auto_20200426_0539'),
('order', '0029_auto_20200423_1042'),
]
operations = [
migrations.AlterField(
model_name='salesorderallocation',
name='item',
field=models.ForeignKey(help_text='Select stock item to allocate', limit_choices_to={'belongs_to': None, 'build_order': None, 'customer': None, 'part__salable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='sales_order_allocations', to='stock.StockItem'),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 3.0.5 on 2020-04-26 06:12
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stock', '0034_auto_20200426_0602'),
('order', '0030_auto_20200426_0551'),
]
operations = [
migrations.AlterField(
model_name='salesorderallocation',
name='item',
field=models.ForeignKey(help_text='Select stock item to allocate', limit_choices_to={'belongs_to': None, 'build_order': None, 'part__salable': True, 'sales_order': None}, on_delete=django.db.models.deletion.CASCADE, related_name='sales_order_allocations', to='stock.StockItem'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.0.5 on 2020-04-27 00:44
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('part', '0035_auto_20200406_0045'),
('order', '0031_auto_20200426_0612'),
]
operations = [
migrations.AlterUniqueTogether(
name='salesorderlineitem',
unique_together={('order', 'part')},
),
]

View File

@ -5,7 +5,8 @@ Order model definitions
# -*- coding: utf-8 -*-
from django.db import models, transaction
from django.db.models import F
from django.db.models import F, Sum
from django.db.models.functions import Coalesce
from django.core.validators import MinValueValidator
from django.core.exceptions import ValidationError
from django.contrib.auth.models import User
@ -16,13 +17,15 @@ from markdownx.models import MarkdownxField
import os
from datetime import datetime
from decimal import Decimal
from stock.models import StockItem
from part import models as PartModels
from stock import models as stock_models
from company.models import Company, SupplierPart
from InvenTree.fields import RoundingDecimalField
from InvenTree.helpers import decimal2string
from InvenTree.status_codes import OrderStatus
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus
from InvenTree.models import InvenTreeAttachment
@ -73,65 +76,42 @@ class Order(models.Model):
creation_date = models.DateField(blank=True, null=True)
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)
complete_date = models.DateField(blank=True, null=True)
notes = MarkdownxField(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()
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()
def cancel_order(self):
""" Marks the order as CANCELLED. """
if self.status in [OrderStatus.PLACED, OrderStatus.PENDING]:
self.status = OrderStatus.CANCELLED
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
supplier_reference: Optional field for supplier order reference code
received_by: User that received the goods
"""
ORDER_PREFIX = "PO"
def __str__(self):
return "PO {ref} - {company}".format(ref=self.reference, company=self.supplier.name)
status = models.PositiveIntegerField(default=PurchaseOrderStatus.PENDING, choices=PurchaseOrderStatus.items(),
help_text='Purchase order status')
supplier = models.ForeignKey(
Company, on_delete=models.CASCADE,
limit_choices_to={
'is_supplier': True,
},
related_name='purchase_orders',
help_text=_('Company')
help_text=_('Supplier')
)
supplier_reference = models.CharField(max_length=64, blank=True, help_text=_("Supplier order reference"))
supplier_reference = models.CharField(max_length=64, blank=True, help_text=_("Supplier order reference code"))
received_by = models.ForeignKey(
User,
@ -140,6 +120,10 @@ class PurchaseOrder(Order):
related_name='+'
)
issue_date = models.DateField(blank=True, null=True)
complete_date = models.DateField(blank=True, null=True)
def get_absolute_url(self):
return reverse('po-detail', kwargs={'pk': self.id})
@ -188,6 +172,29 @@ class PurchaseOrder(Order):
line.save()
def place_order(self):
""" Marks the PurchaseOrder as PLACED. Order must be currently PENDING. """
if self.status == PurchaseOrderStatus.PENDING:
self.status = PurchaseOrderStatus.PLACED
self.issue_date = datetime.now().date()
self.save()
def complete_order(self):
""" Marks the PurchaseOrder as COMPLETE. Order must be currently PLACED. """
if self.status == PurchaseOrderStatus.PLACED:
self.status = PurchaseOrderStatus.COMPLETE
self.complete_date = datetime.now().date()
self.save()
def cancel_order(self):
""" Marks the PurchaseOrder as CANCELLED. """
if self.status in [PurchaseOrderStatus.PLACED, PurchaseOrderStatus.PENDING]:
self.status = PurchaseOrderStatus.CANCELLED
self.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.
@ -206,7 +213,7 @@ class PurchaseOrder(Order):
""" Receive a line item (or partial line item) against this PO
"""
if not self.status == OrderStatus.PLACED:
if not self.status == PurchaseOrderStatus.PLACED:
raise ValidationError({"status": _("Lines can only be received against an order marked as 'Placed'")})
try:
@ -218,7 +225,7 @@ class PurchaseOrder(Order):
# Create a new stock item
if line.part:
stock = StockItem(
stock = stock_models.StockItem(
part=line.part.part,
supplier_part=line.part,
location=location,
@ -244,6 +251,115 @@ class PurchaseOrder(Order):
self.complete_order() # This will save the model
class SalesOrder(Order):
"""
A SalesOrder represents a list of goods shipped outwards to a customer.
Attributes:
customer: Reference to the company receiving the goods in the order
customer_reference: Optional field for customer order reference code
"""
def __str__(self):
return "SO {ref} - {company}".format(ref=self.reference, company=self.customer.name)
def get_absolute_url(self):
return reverse('so-detail', kwargs={'pk': self.id})
customer = models.ForeignKey(
Company,
on_delete=models.SET_NULL,
null=True,
limit_choices_to={'is_customer': True},
related_name='sales_orders',
help_text=_("Customer"),
)
status = models.PositiveIntegerField(default=SalesOrderStatus.PENDING, choices=SalesOrderStatus.items(),
help_text='Purchase order status')
customer_reference = models.CharField(max_length=64, blank=True, help_text=_("Customer order reference code"))
shipment_date = models.DateField(blank=True, null=True)
shipped_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
blank=True, null=True,
related_name='+'
)
@property
def is_pending(self):
return self.status == SalesOrderStatus.PENDING
def is_fully_allocated(self):
""" Return True if all line items are fully allocated """
for line in self.lines.all():
if not line.is_fully_allocated():
return False
return True
def is_over_allocated(self):
""" Return true if any lines in the order are over-allocated """
for line in self.lines.all():
if line.is_over_allocated():
return True
return False
@transaction.atomic
def ship_order(self, user):
""" Mark this order as 'shipped' """
# The order can only be 'shipped' if the current status is PENDING
if not self.status == SalesOrderStatus.PENDING:
raise ValidationError({'status': _("SalesOrder cannot be shipped as it is not currently pending")})
# Complete the allocation for each allocated StockItem
for line in self.lines.all():
for allocation in line.allocations.all():
allocation.complete_allocation(user)
# Remove the allocation from the database once it has been 'fulfilled'
if allocation.item.sales_order == self:
allocation.delete()
else:
raise ValidationError("Could not complete order - allocation item not fulfilled")
# Ensure the order status is marked as "Shipped"
self.status = SalesOrderStatus.SHIPPED
self.shipment_date = datetime.now().date()
self.shipped_by = user
self.save()
return True
@transaction.atomic
def cancel_order(self):
"""
Cancel this order (only if it is "pending")
- Mark the order as 'cancelled'
- Delete any StockItems which have been allocated
"""
if not self.status == SalesOrderStatus.PENDING:
return False
self.status = SalesOrderStatus.CANCELLED
self.save()
for line in self.lines.all():
for allocation in line.allocations.all():
allocation.delete()
return True
class PurchaseOrderAttachment(InvenTreeAttachment):
"""
Model for storing file attachments against a PurchaseOrder object
@ -255,6 +371,17 @@ class PurchaseOrderAttachment(InvenTreeAttachment):
order = models.ForeignKey(PurchaseOrder, on_delete=models.CASCADE, related_name="attachments")
class SalesOrderAttachment(InvenTreeAttachment):
"""
Model for storing file attachments against a SalesOrder object
"""
def getSubdir(self):
return os.path.join("so_files", str(self.order.id))
order = models.ForeignKey(SalesOrder, on_delete=models.CASCADE, related_name='attachments')
class OrderLineItem(models.Model):
""" Abstract model for an order line item
@ -300,6 +427,10 @@ class PurchaseOrderLineItem(OrderLineItem):
help_text=_('Purchase Order')
)
def get_base_part(self):
""" Return the base-part for the line item """
return self.part.part
# TODO - Function callback for when the SupplierPart is deleted?
part = models.ForeignKey(
@ -315,3 +446,171 @@ class PurchaseOrderLineItem(OrderLineItem):
""" Calculate the number of items remaining to be received """
r = self.quantity - self.received
return max(r, 0)
class SalesOrderLineItem(OrderLineItem):
"""
Model for a single LineItem in a SalesOrder
Attributes:
order: Link to the SalesOrder that this line item belongs to
part: Link to a Part object (may be null)
"""
order = models.ForeignKey(SalesOrder, on_delete=models.CASCADE, related_name='lines', help_text=_('Sales Order'))
part = models.ForeignKey('part.Part', on_delete=models.SET_NULL, related_name='sales_order_line_items', null=True, help_text=_('Part'), limit_choices_to={'salable': True})
class Meta:
unique_together = [
('order', 'part'),
]
def fulfilled_quantity(self):
"""
Return the total stock quantity fulfilled against this line item.
"""
query = self.order.stock_items.filter(part=self.part).aggregate(fulfilled=Coalesce(Sum('quantity'), Decimal(0)))
return query['fulfilled']
def allocated_quantity(self):
""" Return the total stock quantity allocated to this LineItem.
This is a summation of the quantity of each attached StockItem
"""
query = self.allocations.aggregate(allocated=Coalesce(Sum('quantity'), Decimal(0)))
return query['allocated']
def is_fully_allocated(self):
""" Return True if this line item is fully allocated """
if self.order.status == SalesOrderStatus.SHIPPED:
return self.fulfilled_quantity() >= self.quantity
return self.allocated_quantity() >= self.quantity
def is_over_allocated(self):
""" Return True if this line item is over allocated """
return self.allocated_quantity() > self.quantity
class SalesOrderAllocation(models.Model):
"""
This model is used to 'allocate' stock items to a SalesOrder.
Items that are "allocated" to a SalesOrder are not yet "attached" to the order,
but they will be once the order is fulfilled.
Attributes:
line: SalesOrderLineItem reference
item: StockItem reference
quantity: Quantity to take from the StockItem
"""
class Meta:
unique_together = [
# Cannot allocate any given StockItem to the same line more than once
('line', 'item'),
]
def clean(self):
"""
Validate the SalesOrderAllocation object:
- Cannot allocate stock to a line item without a part reference
- The referenced part must match the part associated with the line item
- Allocated quantity cannot exceed the quantity of the stock item
- Allocation quantity must be "1" if the StockItem is serialized
- Allocation quantity cannot be zero
"""
super().clean()
errors = {}
try:
if not self.line.part == self.item.part:
errors['item'] = _('Cannot allocate stock item to a line with a different part')
except PartModels.Part.DoesNotExist:
errors['line'] = _('Cannot allocate stock to a line without a part')
if self.quantity > self.item.quantity:
errors['quantity'] = _('Allocation quantity cannot exceed stock quantity')
# TODO: The logic here needs improving. Do we need to subtract our own amount, or something?
if self.item.quantity - self.item.allocation_count() + self.quantity < self.quantity:
errors['quantity'] = _('StockItem is over-allocated')
if self.quantity <= 0:
errors['quantity'] = _('Allocation quantity must be greater than zero')
if self.item.serial and not self.quantity == 1:
errors['quantity'] = _('Quantity must be 1 for serialized stock item')
if len(errors) > 0:
raise ValidationError(errors)
line = models.ForeignKey(SalesOrderLineItem, on_delete=models.CASCADE, related_name='allocations')
item = models.ForeignKey(
'stock.StockItem',
on_delete=models.CASCADE,
related_name='sales_order_allocations',
limit_choices_to={
'part__salable': True,
'belongs_to': None,
'sales_order': None,
'build_order': None,
},
help_text=_('Select stock item to allocate')
)
quantity = RoundingDecimalField(max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], default=1, help_text=_('Enter stock allocation quantity'))
def get_serial(self):
return self.item.serial
def get_location(self):
return self.item.location.id if self.item.location else None
def get_location_path(self):
if self.item.location:
return self.item.location.pathstring
else:
return ""
def complete_allocation(self, user):
"""
Complete this allocation (called when the parent SalesOrder is marked as "shipped"):
- Determine if the referenced StockItem needs to be "split" (if allocated quantity != stock quantity)
- Mark the StockItem as belonging to the Customer (this will remove it from stock)
"""
order = self.line.order
item = self.item
# If the allocated quantity is less than the amount available,
# then split the stock item into two lots
if item.quantity > self.quantity:
# Grab a copy of the new stock item (which will keep track of its "parent")
item = item.splitStock(self.quantity, None, user)
# Update our own reference to the new item
self.item = item
self.save()
# Assign the StockItem to the SalesOrder customer
item.sales_order = order
# Clear the location
item.location = None
item.status = StockStatus.SHIPPED
item.save()

View File

@ -10,13 +10,16 @@ from rest_framework import serializers
from django.db.models import Count
from InvenTree.serializers import InvenTreeModelSerializer
from company.serializers import CompanyBriefSerializer
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
from part.serializers import PartBriefSerializer
from .models import PurchaseOrder, PurchaseOrderLineItem
from .models import SalesOrder, SalesOrderLineItem
from .models import SalesOrderAllocation
class POSerializer(InvenTreeModelSerializer):
""" Serializes an Order object """
""" Serializer for a PurchaseOrder object """
def __init__(self, *args, **kwargs):
@ -71,6 +74,22 @@ class POSerializer(InvenTreeModelSerializer):
class POLineItemSerializer(InvenTreeModelSerializer):
def __init__(self, *args, **kwargs):
part_detail = kwargs.pop('part_detail', False)
super().__init__(*args, **kwargs)
if part_detail is not True:
self.fields.pop('part_detail')
self.fields.pop('supplier_part_detail')
quantity = serializers.FloatField()
received = serializers.FloatField()
part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True)
supplier_part_detail = SupplierPartSerializer(source='part', many=False, read_only=True)
class Meta:
model = PurchaseOrderLineItem
@ -81,5 +100,134 @@ class POLineItemSerializer(InvenTreeModelSerializer):
'notes',
'order',
'part',
'part_detail',
'supplier_part_detail',
'received',
]
class SalesOrderSerializer(InvenTreeModelSerializer):
"""
Serializers for the SalesOrder object
"""
def __init__(self, *args, **kwargs):
customer_detail = kwargs.pop('customer_detail', False)
super().__init__(*args, **kwargs)
if customer_detail is not True:
self.fields.pop('customer_detail')
@staticmethod
def annotate_queryset(queryset):
"""
Add extra information to the queryset
"""
return queryset.annotate(
line_items=Count('lines'),
)
customer_detail = CompanyBriefSerializer(source='customer', many=False, read_only=True)
line_items = serializers.IntegerField(read_only=True)
status_text = serializers.CharField(source='get_status_display', read_only=True)
class Meta:
model = SalesOrder
fields = [
'pk',
'shipment_date',
'creation_date',
'description',
'line_items',
'link',
'reference',
'customer',
'customer_detail',
'customer_reference',
'status',
'status_text',
'shipment_date',
'notes',
]
read_only_fields = [
'reference',
'status'
]
class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
"""
Serializer for the SalesOrderAllocation model.
This includes some fields from the related model objects.
"""
location_path = serializers.CharField(source='get_location_path')
location_id = serializers.IntegerField(source='get_location')
serial = serializers.CharField(source='get_serial')
quantity = serializers.FloatField()
class Meta:
model = SalesOrderAllocation
fields = [
'pk',
'line',
'serial',
'quantity',
'location_id',
'location_path',
'item',
]
class SOLineItemSerializer(InvenTreeModelSerializer):
""" Serializer for a SalesOrderLineItem object """
def __init__(self, *args, **kwargs):
part_detail = kwargs.pop('part_detail', False)
order_detail = kwargs.pop('order_detail', False)
allocations = kwargs.pop('allocations', False)
super().__init__(*args, **kwargs)
if part_detail is not True:
self.fields.pop('part_detail')
if order_detail is not True:
self.fields.pop('order_detail')
if allocations is not True:
self.fields.pop('allocations')
order_detail = SalesOrderSerializer(source='order', many=False, read_only=True)
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
allocations = SalesOrderAllocationSerializer(many=True, read_only=True)
quantity = serializers.FloatField()
allocated = serializers.FloatField(source='allocated_quantity', read_only=True)
fulfilled = serializers.FloatField(source='fulfilled_quantity', read_only=True)
class Meta:
model = SalesOrderLineItem
fields = [
'pk',
'allocated',
'allocations',
'quantity',
'fulfilled',
'reference',
'notes',
'order',
'order_detail',
'part',
'part_detail',
]

View File

@ -1,4 +1,4 @@
{% extends "base.html" %}
{% extends "two_column.html" %}
{% load i18n %}
{% load static %}
@ -6,129 +6,114 @@
{% load status_codes %}
{% block page_title %}
InvenTree | {{ order }}
InvenTree | {% trans "Purchase Order" %}
{% endblock %}
{% block content %}
{% block thumbnail %}
<img class='part-thumb'
{% if order.supplier.image %}
src="{{ order.supplier.image.url }}"
{% else %}
src="{% static 'img/blank_image.png' %}"
{% endif %}
/>
{% endblock %}
<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>
<p>
<div class='btn-row'>
<div class='btn-group'>
<button type='button' class='btn btn-default btn-glyph' id='edit-order' title='Edit order information'>
<span class='glyphicon glyphicon-edit'></span>
</button>
<button type='button' class='btn btn-default btn-glyph' id='export-order' title='Export order to file'>
<span class='glyphicon glyphicon-download-alt'></span>
</button>
{% if order.status == OrderStatus.PENDING and order.lines.count > 0 %}
<button type='button' class='btn btn-default btn-glyph' id='place-order' title='Place order'>
<span class='glyphicon glyphicon-send'></span>
</button>
{% elif order.status == OrderStatus.PLACED %}
<button type='button' class='btn btn-default btn-glyph' id='receive-order' title='Receive items'>
<span class='glyphicon glyphicon-check'></span>
</button>
<button type='button' class='btn btn-default btn-glyph' id='complete-order' title='Mark order as complete'>
<span class='glyphicon glyphicon-ok'></span>
</button>
{% endif %}
{% if order.status == OrderStatus.PENDING or order.status == OrderStatus.PLACED %}
<button type='button' class='btn btn-default btn-glyph' id='cancel-order' title='Cancel order'>
<span class='glyphicon glyphicon-remove'></span>
</button>
{% endif %}
</div>
</div>
</p>
</div>
{% block page_data %}
<h3>{% trans "Purchase Order" %} {% purchase_order_status_label order.status large=True %}</h3>
<hr>
<h4>{{ order }}</h4>
<p>{{ order.description }}</p>
<p>
<div class='btn-row'>
<div class='btn-group action-buttons'>
<button type='button' class='btn btn-default' id='edit-order' title='Edit order information'>
<span class='fas fa-edit icon-green'></span>
</button>
<button type='button' class='btn btn-default' id='export-order' title='Export order to file'>
<span class='fas fa-file-download'></span>
</button>
{% if order.status == PurchaseOrderStatus.PENDING and order.lines.count > 0 %}
<button type='button' class='btn btn-default' id='place-order' title='Place order'>
<span class='fas fa-paper-plane icon-blue'></span>
</button>
{% elif order.status == PurchaseOrderStatus.PLACED %}
<button type='button' class='btn btn-default' id='receive-order' title='Receive items'>
<span class='fas fa-clipboard-check'></span>
</button>
<button type='button' class='btn btn-default' id='complete-order' title='Mark order as complete'>
<span class='fas fa-check-circle'></span>
</button>
{% endif %}
{% if order.status == PurchaseOrderStatus.PENDING or order.status == PurchaseOrderStatus.PLACED %}
<button type='button' class='btn btn-default' id='cancel-order' title='Cancel order'>
<span class='fas fa-times-circle icon-red'></span>
</button>
{% endif %}
</div>
</div>
<div class='col-sm-6'>
<h4>{% trans "Purchase Order Details" %}</h4>
<table class='table'>
<col width='25'>
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Order Reference" %}</td>
<td>{{ order.reference }}</td>
</tr>
<tr>
<td><span class='fas fa-info'></span></td>
<td>{% trans "Order Status" %}</td>
<td>{% order_status order.status %}</td>
</tr>
<tr>
<td><span class='fas fa-building'></span></td>
<td>{% trans "Supplier" %}</td>
<td><a href="{% url 'company-detail' order.supplier.id %}">{{ order.supplier.name }}</a></td>
</tr>
{% if order.supplier_reference %}
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Supplier Reference" %}</td>
<td>{{ order.supplier_reference }}</td>
</tr>
{% endif %}
{% if order.link %}
<tr>
<td><span class='fas fa-link'></span></td>
<td>External Link</td>
<td><a href="{{ order.link }}">{{ order.link }}</a></td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Created" %}</td>
<td>{{ order.creation_date }}<span class='badge'>{{ order.created_by }}</span></td>
</tr>
{% if order.issue_date %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Issued" %}</td>
<td>{{ order.issue_date }}</td>
</tr>
{% endif %}
{% if order.status == OrderStatus.COMPLETE %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Received" %}</td>
<td>{{ order.complete_date }}<span class='badge'>{{ order.received_by }}</span></td>
</tr>
{% endif %}
</table>
</div>
</div>
<hr>
<div class='container-fluid'>
{% block details %}
<!-- Specific order details to go here -->
</p>
{% endblock %}
</div>
{% block page_details %}
<h4>{% trans "Purchase Order Details" %}</h4>
<table class='table'>
<col width='25'>
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Order Reference" %}</td>
<td>{{ order.reference }}</td>
</tr>
<tr>
<td><span class='fas fa-info'></span></td>
<td>{% trans "Order Status" %}</td>
<td>{% purchase_order_status_label order.status %}</td>
</tr>
<tr>
<td><span class='fas fa-building'></span></td>
<td>{% trans "Supplier" %}</td>
<td><a href="{% url 'company-detail' order.supplier.id %}">{{ order.supplier.name }}</a></td>
</tr>
{% if order.supplier_reference %}
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Supplier Reference" %}</td>
<td>{{ order.supplier_reference }}</td>
</tr>
{% endif %}
{% if order.link %}
<tr>
<td><span class='fas fa-link'></span></td>
<td>External Link</td>
<td><a href="{{ order.link }}">{{ order.link }}</a></td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Created" %}</td>
<td>{{ order.creation_date }}<span class='badge'>{{ order.created_by }}</span></td>
</tr>
{% if order.issue_date %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Issued" %}</td>
<td>{{ order.issue_date }}</td>
</tr>
{% endif %}
{% if order.status == PurchaseOrderStatus.COMPLETE %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Received" %}</td>
<td>{{ order.complete_date }}<span class='badge'>{{ order.received_by }}</span></td>
</tr>
{% endif %}
</table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
{% if order.status == OrderStatus.PENDING and order.lines.count > 0 %}
{% if order.status == PurchaseOrderStatus.PENDING and order.lines.count > 0 %}
$("#place-order").click(function() {
launchModalForm("{% url 'po-issue' order.id %}",
{

View File

@ -1,7 +1,9 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
Cancelling this order means that the order will no longer be editable.
{% trans "Cancelling this order means that the order will no longer be editable." %}
{% endblock %}

View File

@ -7,7 +7,7 @@
{% block details %}
{% include 'order/tabs.html' with tab='notes' %}
{% include 'order/po_tabs.html' with tab='notes' %}
{% if editing %}
<h4>{% trans "Order Notes" %}</h4>

View File

@ -6,7 +6,7 @@
{% block details %}
{% include 'order/tabs.html' with tab='attachments' %}
{% include 'order/po_tabs.html' with tab='attachments' %}
<h4>{% trans "Purchase Order Attachments" %}

View File

@ -7,78 +7,19 @@
{% block details %}
{% include 'order/tabs.html' with tab='details' %}
{% include 'order/po_tabs.html' with tab='details' %}
<hr>
<div id='order-toolbar-buttons' class='btn-group' style='float: right;'>
{% if order.status == OrderStatus.PENDING %}
{% if order.status == PurchaseOrderStatus.PENDING %}
<button type='button' class='btn btn-default' id='new-po-line'>{% trans "Add Line Item" %}</button>
{% endif %}
</div>
<h4>{% trans "Order Items" %}</h4>
<h4>{% trans "Purchase Order Items" %}</h4>
<table class='table table-striped table-condensed' id='po-lines-table' data-toolbar='#order-toolbar-buttons'>
<thead>
<tr>
<th data-sortable='true'>{% trans "Line" %}</th>
<th data-sortable='true'>{% trans "Part" %}</th>
<th>{% trans "Description" %}</th>
<th data-sortable='true'>{% trans "Order Code" %}</th>
<th data-sortable='true'>{% trans "Reference" %}</th>
<th data-sortable='true'>{% trans "Quantity" %}</th>
{% if not order.status == OrderStatus.PENDING %}
<th data-sortable='true'>{% trans "Received" %}</th>
{% endif %}
<th>{% trans "Note" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for line in order.lines.all %}
<tr{% if order.status == OrderStatus.PLACED %} class={% if line.received < line.quantity %}'rowinvalid'{% else %}'rowvalid'{% endif %}{% endif %}>
<td>
{{ forloop.counter }}
</td>
{% if line.part %}
<td>
{% include "hover_image.html" with image=line.part.part.image hover=True %}
<a href="{% url 'part-detail' line.part.part.id %}">{{ line.part.part.full_name }}</a>
</td>
<td>{{ line.part.part.description }}</td>
<td><a href="{% url 'supplier-part-detail' line.part.id %}">{{ line.part.SKU }}</a></td>
{% else %}
<td colspan='3'><strong>Warning: Part has been deleted.</strong></td>
{% endif %}
<td>{{ line.reference }}</td>
<td>{% decimal line.quantity %}</td>
{% if not order.status == OrderStatus.PENDING %}
<td>{% decimal line.received %}</td>
{% endif %}
<td>
{{ line.notes }}
</td>
<td>
<div class='btn-group'>
{% if order.status == OrderStatus.PENDING %}
<button class='btn btn-default btn-glyph' line='{{ line.id }}' id='edit-line-item-{{ line.id }} title='Edit line item' onclick='editPurchaseOrderLineItem()'>
<span url="{% url 'po-line-item-edit' line.id %}" line='{{ line.id }}' class='glyphicon glyphicon-edit'></span>
</button>
<button class='btn btn-default btn-glyph' line='{{ line.id }}' id='remove-line-item-{{ line.id }' title='Remove line item' type='button' onclick='removePurchaseOrderLineItem()'>
<span url="{% url 'po-line-item-delete' line.id %}" line='{{ line.id }}' class='glyphicon glyphicon-remove'></span>
</button>
{% endif %}
{% if order.status == OrderStatus.PLACED and line.received < line.quantity %}
<button class='btn btn-default btn-glyph line-receive' pk='{{ line.pk }}' title='Receive item(s)'>
<span class='glyphicon glyphicon-check'></span>
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
<table class='table table-striped table-condensed' id='po-table' data-toolbar='#order-toolbar-buttons'>
</table>
{% endblock %}
@ -87,27 +28,6 @@
{{ block.super }}
$("#po-lines-table").on('click', ".line-receive", function() {
var button = $(this);
console.log('clicked! ' + button.attr('pk'));
launchModalForm("{% url 'po-receive' order.id %}", {
reload: true,
data: {
line: button.attr('pk')
},
secondary: [
{
field: 'location',
label: 'New Location',
title: 'Create new stock location',
url: "{% url 'stock-location-create' %}",
},
]
});
});
$("#receive-order").click(function() {
launchModalForm("{% url 'po-receive' order.id %}", {
@ -115,8 +35,8 @@ $("#receive-order").click(function() {
secondary: [
{
field: 'location',
label: 'New Location',
title: 'Create new stock location',
label: '{% trans "New Location" %}',
title: '{% trans "Create new stock location" %}',
url: "{% url 'stock-location-create' %}",
},
]
@ -133,7 +53,7 @@ $("#export-order").click(function() {
location.href = "{% url 'po-export' order.id %}";
});
{% if order.status == OrderStatus.PENDING %}
{% if order.status == PurchaseOrderStatus.PENDING %}
$('#new-po-line').click(function() {
launchModalForm("{% url 'po-line-item-create' %}",
{
@ -144,8 +64,8 @@ $('#new-po-line').click(function() {
secondary: [
{
field: 'part',
label: 'New Supplier Part',
title: 'Create new supplier part',
label: '{% trans "New Supplier Part" %}',
title: '{% trans "Create new supplier part" %}',
url: "{% url 'supplier-part-create' %}",
data: {
supplier: {{ order.supplier.id }},
@ -157,7 +77,153 @@ $('#new-po-line').click(function() {
});
{% endif %}
$("#po-lines-table").inventreeTable({
function reloadTable() {
$("#po-table").bootstrapTable("refresh");
}
function setupCallbacks() {
// Setup callbacks for the line buttons
var table = $("#po-table");
{% if order.status == PurchaseOrderStatus.PENDING %}
table.find(".button-line-edit").click(function() {
var pk = $(this).attr('pk');
launchModalForm(`/order/purchase-order/line/${pk}/edit/`, {
success: reloadTable,
});
});
table.find(".button-line-delete").click(function() {
var pk = $(this).attr('pk');
launchModalForm(`/order/purchase-order/line/${pk}/delete/`, {
success: reloadTable,
});
});
{% endif %}
table.find(".button-line-receive").click(function() {
var pk = $(this).attr('pk');
launchModalForm("{% url 'po-receive' order.id %}", {
success: reloadTable,
data: {
line: pk,
},
secondary: [
{
field: 'location',
label: '{% trans "New Location" %}',
title: '{% trans "Create new stock location" %}',
url: "{% url 'stock-location-create' %}",
},
]
});
});
}
$("#po-table").inventreeTable({
onPostBody: setupCallbacks,
formatNoMatches: function() { return "{% trans 'No line items found' %}"; },
queryParams: {
order: {{ order.id }},
part_detail: true,
},
url: "{% url 'api-po-line-list' %}",
columns: [
{
field: 'pk',
title: 'ID',
visible: false,
},
{
field: 'part',
sortable: true,
title: '{% trans "Part" %}',
formatter: function(value, row, index, field) {
if (row.part) {
return imageHoverIcon(row.part_detail.thumbnail) + renderLink(row.part_detail.full_name, `/part/${value}/`);
} else {
return '-';
}
},
},
{
sortable: true,
field: 'part_detail.description',
title: '{% trans "Description" %}',
},
{
sortable: true,
field: 'supplier_part_detail.SKU',
title: '{% trans "Order Code" %}',
formatter: function(value, row, index, field) {
return renderLink(value, `/supplier-part/${row.part}/`);
},
},
{
sortable: true,
field: 'reference',
title: '{% trans "Reference" %}',
},
{
sortable: true,
field: 'quantity',
title: '{% trans "Quantity" %}'
},
{
sortable: true,
field: 'received',
title: '{% trans "Received" %}',
formatter: function(value, row, index, field) {
return makeProgressBar(row.received, row.quantity, {
id: `order-line-progress-${row.pk}`,
});
},
sorter: function(valA, valB, rowA, rowB) {
if (rowA.received == 0 && rowB.received == 0) {
return (rowA.quantity > rowB.quantity) ? 1 : -1;
}
var progressA = parseFloat(rowA.received) / rowA.quantity;
var progressB = parseFloat(rowB.received) / rowB.quantity;
return (progressA < progressB) ? 1 : -1;
}
},
{
field: 'notes',
title: '{% trans "Notes" %}',
},
{
field: 'buttons',
title: '',
formatter: function(value, row, index, field) {
var html = `<div class='btn-group'>`;
var pk = row.pk;
{% if order.status == PurchaseOrderStatus.PENDING %}
html += makeIconButton('fa-edit', 'button-line-edit', pk, '{% trans "Edit line item" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete line item" %}');
{% endif %}
{% if order.status == PurchaseOrderStatus.PLACED %}
if (row.received < row.quantity) {
html += makeIconButton('fa-clipboard-check', 'button-line-receive', pk, '{% trans "Receive line item" %}');
}
{% endif %}
html += `</div>`;
return html;
},
}
]
});

View File

@ -4,18 +4,18 @@
{% load i18n %}
{% block page_title %}
InvenTree | Purchase Orders
InvenTree | {% trans "Purchase Orders" %}
{% endblock %}
{% block content %}
<h3>Purchase Orders</h3>
<h3>{% trans "Purchase Orders" %}</h3>
<hr>
<div id='table-buttons'>
<div class='button-toolbar container-fluid' style='float: right;'>
<button class='btn btn-primary' type='button' id='po-create' title='Create new purchase order'>New Purchase Order</button>
<div class='filter-list' id='filter-list-order'>
<button class='btn btn-primary' type='button' id='po-create' title='{% trans "Create new purchase order" %}'>{% trans "New Purchase Order" %}</button>
<div class='filter-list' id='filter-list-purchaseorder'>
<!-- An empty div in which the filter list will be constructed -->
</div>
</div>

View File

@ -0,0 +1,133 @@
{% extends "two_column.html" %}
{% load i18n %}
{% load static %}
{% load inventree_extras %}
{% load status_codes %}
{% block page_title %}
InvenTree | {% trans "Sales Order" %}
{% endblock %}
{% block pre_content %}
{% if order.status == SalesOrderStatus.PENDING and not order.is_fully_allocated %}
<div class='alert alert-block alert-danger'>
{% trans "This SalesOrder has not been fully allocated" %}
</div>
{% endif %}
{% endblock %}
{% block thumbnail %}
<img class='part-thumb'
{% if order.customer.image %}
src="{{ order.customer.image.url }}"
{% else %}
src="{% static 'img/blank_image.png' %}"
{% endif %}
/>
{% endblock %}
{% block page_data %}
<h3>{% trans "Sales Order" %} {% sales_order_status_label order.status large=True %}</h3>
<hr>
<h4>{{ order }}</h4>
<p>{{ order.description }}</p>
<div class='btn-row'>
<div class='btn-group action-buttons'>
<button type='button' class='btn btn-default' id='edit-order' title='Edit order information'>
<span class='fas fa-edit icon-green'></span>
</button>
<button type='button' class='btn btn-default' id='packing-list' title='{% trans "Packing List" %}'>
<span class='fas fa-clipboard-list'></span>
</button>
{% if order.status == SalesOrderStatus.PENDING %}
<button type='button' class='btn btn-default' id='ship-order' title='{% trans "Ship order" %}'>
<span class='fas fa-paper-plane icon-blue'></span>
</button>
<button type='button' class='btn btn-default' id='cancel-order' title='{% trans "Cancel order" %}'>
<span class='fas fa-times-circle icon-red'></span>
</button>
{% endif %}
</div>
</div>
{% endblock %}
{% block page_details %}
<h4>{% trans "Sales Order Details" %}</h4>
<table class='table'>
<col width='25'>
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Order Reference" %}</td>
<td>{{ order.reference }}</td>
</tr>
<tr>
<td><span class='fas fa-info'></span></td>
<td>{% trans "Order Status" %}</td>
<td>{% sales_order_status_label order.status %}</td>
</tr>
<tr>
<td><span class='fas fa-building'></span></td>
<td>{% trans "Customer" %}</td>
<td><a href="{% url 'company-detail' order.customer.id %}">{{ order.customer.name }}</a></td>
</tr>
{% if order.customer_reference %}
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Customer Reference" %}</td>
<td>{{ order.customer_reference }}</td>
</tr>
{% endif %}
{% if order.link %}
<tr>
<td><span class='fas fa-link'></span></td>
<td>External Link</td>
<td><a href="{{ order.link }}">{{ order.link }}</a></td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Created" %}</td>
<td>{{ order.creation_date }}<span class='badge'>{{ order.created_by }}</span></td>
</tr>
{% if order.shipment_date %}
<tr>
<td><span class='fas fa-truck'></span></td>
<td>{% trans "Shipped" %}</td>
<td>{{ order.shipment_date }}<span class='badge'>{{ order.shipped_by }}</span></td>
</tr>
{% endif %}
{% if order.status == PurchaseOrderStatus.COMPLETE %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Received" %}</td>
<td>{{ order.complete_date }}<span class='badge'>{{ order.received_by }}</span></td>
</tr>
{% endif %}
</table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
$("#edit-order").click(function() {
launchModalForm("{% url 'so-edit' order.id %}", {
reload: true,
});
});
$("#cancel-order").click(function() {
launchModalForm("{% url 'so-cancel' order.id %}", {
reload: true,
});
});
$("#ship-order").click(function() {
launchModalForm("{% url 'so-ship' order.id %}", {
reload: true,
});
});
{% endblock %}

View File

@ -0,0 +1,12 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
<div class='alert alert-block alert-warning'>
<h4>{% trans "Warning" %}</h4>
{% trans "Cancelling this order means that the order will no longer be editable." %}
</div>
{% endblock %}

View File

@ -0,0 +1,361 @@
{% extends "order/sales_order_base.html" %}
{% load inventree_extras %}
{% load status_codes %}
{% load i18n %}
{% load static %}
{% block details %}
{% include "order/so_tabs.html" with tab='details' %}
<hr>
<h4>{% trans "Sales Order Items" %}</h4>
<div id='order-toolbar-buttons' class='btn-group' style='float: right;'>
<button type='button' class='btn btn-default' id='new-so-line'>{% trans "Add Line Item" %}</button>
</div>
<table class='table table-striped table-condensed' id='so-lines-table' data-toolbar='#order-toolbar-buttons'>
</table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
function reloadTable() {
$("#so-lines-table").bootstrapTable("refresh");
}
$("#new-so-line").click(function() {
launchModalForm("{% url 'so-line-item-create' %}", {
success: reloadTable,
data: {
order: {{ order.id }},
},
secondary: [
]
});
});
{% if order.status == SalesOrderStatus.PENDING %}
function showAllocationSubTable(index, row, element) {
// Construct a table showing stock items which have been allocated against this line item
var html = `<div class='sub-table'><table class='table table-striped table-condensed' id='allocation-table-${row.pk}'></table></div>`;
element.html(html);
var lineItem = row;
var table = $(`#allocation-table-${row.pk}`);
table.bootstrapTable({
data: row.allocations,
showHeader: false,
columns: [
{
width: '50%',
field: 'allocated',
title: 'Quantity',
formatter: function(value, row, index, field) {
var text = '';
if (row.serial != null && row.quantity == 1) {
text = `{% trans "Serial Number" %}: ${row.serial}`;
} else {
text = `{% trans "Quantity" %}: ${row.quantity}`;
}
return renderLink(text, `/stock/item/${row.item}/`);
},
},
{
field: 'location_id',
title: 'Location',
formatter: function(value, row, index, field) {
return renderLink(row.location_path, `/stock/location/${row.location_id}/`);
},
},
{
field: 'buttons',
title: 'Actions',
formatter: function(value, row, index, field) {
var html = "<div class='btn-group float-right' role='group'>";
var pk = row.pk;
{% if order.status == SalesOrderStatus.PENDING %}
html += makeIconButton('fa-edit', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}');
html += makeIconButton('fa-trash-alt', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}');
{% endif %}
html += "</div>";
return html;
},
},
],
});
table.find(".button-allocation-edit").click(function() {
var pk = $(this).attr('pk');
launchModalForm(`/order/sales-order/allocation/${pk}/edit/`, {
success: reloadTable,
});
});
table.find(".button-allocation-delete").click(function() {
var pk = $(this).attr('pk');
launchModalForm(`/order/sales-order/allocation/${pk}/delete/`, {
success: reloadTable,
});
});
}
{% endif %}
function showFulfilledSubTable(index, row, element) {
// Construct a table showing stock items which have been fulfilled against this line item
var id = `fulfilled-table-${row.pk}`;
var html = `<div class='sub-table'><table class='table table-striped table-condensed' id='${id}'></table></div>`;
element.html(html);
var lineItem = row;
$(`#${id}`).bootstrapTable({
url: "{% url 'api-stock-list' %}",
queryParams: {
part: row.part,
sales_order: {{ order.id }},
},
showHeader: false,
columns: [
{
field: 'pk',
visible: false,
},
{
field: 'stock',
formatter: function(value, row) {
var text = '';
if (row.serial && row.quantity == 1) {
text = `{% trans "Serial Number" %}: ${row.serial}`;
} else {
text = `{% trans "Quantity" %}: ${row.quantity}`;
}
return renderLink(text, `/stock/item/${row.pk}/`);
},
}
],
});
}
$("#so-lines-table").inventreeTable({
formatNoMatches: function() { return "No matching line items"; },
queryParams: {
order: {{ order.id }},
part_detail: true,
allocations: true,
},
uniqueId: 'pk',
url: "{% url 'api-so-line-list' %}",
onPostBody: setupCallbacks,
{% if order.status == SalesOrderStatus.PENDING or order.status == SalesOrderStatus.SHIPPED %}
detailViewByClick: true,
detailView: true,
detailFilter: function(index, row) {
{% if order.status == SalesOrderStatus.PENDING %}
return row.allocated > 0;
{% else %}
return row.fulfilled > 0;
{% endif %}
},
{% if order.status == SalesOrderStatus.PENDING %}
detailFormatter: showAllocationSubTable,
{% else %}
detailFormatter: showFulfilledSubTable,
{% endif %}
{% endif %}
columns: [
{
field: 'pk',
title: 'ID',
visible: false,
},
{
sortable: true,
field: 'part',
title: 'Part',
formatter: function(value, row, index, field) {
if (row.part) {
return imageHoverIcon(row.part_detail.thumbnail) + renderLink(row.part_detail.full_name, `/part/${value}/`);
} else {
return '-';
}
}
},
{
sortable: true,
field: 'reference',
title: 'Reference'
},
{
sortable: true,
field: 'quantity',
title: 'Quantity',
},
{
sortable: true,
field: 'allocated',
{% if order.status == SalesOrderStatus.PENDING %}
title: '{% trans "Allocated" %}',
{% else %}
title: '{% trans "Fulfilled" %}',
{% endif %}
formatter: function(value, row, index, field) {
{% if order.status == SalesOrderStatus.PENDING %}
var quantity = row.allocated;
{% else %}
var quantity = row.fulfilled;
{% endif %}
return makeProgressBar(quantity, row.quantity, {
id: `order-line-progress-${row.pk}`,
});
},
sorter: function(valA, valB, rowA, rowB) {
{% if order.status == SalesOrderStatus.PENDING %}
var A = rowA.allocated;
var B = rowB.allocated;
{% else %}
var A = rowA.fulfilled;
var B = rowB.fulfilled;
{% endif %}
if (A == 0 && B == 0) {
return (rowA.quantity > rowB.quantity) ? 1 : -1;
}
var progressA = parseFloat(A) / rowA.quantity;
var progressB = parseFloat(B) / rowB.quantity;
return (progressA < progressB) ? 1 : -1;
}
},
{
field: 'notes',
title: 'Notes',
},
{% if order.status == SalesOrderStatus.PENDING %}
{
field: 'buttons',
formatter: function(value, row, index, field) {
var html = `<div class='btn-group float-right' role='group'>`;
var pk = row.pk;
if (row.part) {
var part = row.part_detail;
if (part.purchaseable) {
html += makeIconButton('fa-shopping-cart', 'button-buy', row.part, '{% trans "Buy parts" %}');
}
if (part.assembly) {
html += makeIconButton('fa-tools', 'button-build', row.part, '{% trans "Build parts" %}');
}
html += makeIconButton('fa-plus', 'button-add', pk, '{% trans "Allocate parts" %}');
}
html += makeIconButton('fa-edit', 'button-edit', pk, '{% trans "Edit line item" %}');
html += makeIconButton('fa-trash-alt', 'button-delete', pk, '{% trans "Delete line item " %}');
html += `</div>`;
return html;
}
},
{% endif %}
],
});
function setupCallbacks() {
var table = $("#so-lines-table");
// Set up callbacks for the row buttons
table.find(".button-edit").click(function() {
var pk = $(this).attr('pk');
launchModalForm(`/order/sales-order/line/${pk}/edit/`, {
success: reloadTable,
});
});
table.find(".button-delete").click(function() {
var pk = $(this).attr('pk');
launchModalForm(`/order/sales-order/line/${pk}/delete/`, {
reload: true,
});
});
table.find(".button-add").click(function() {
var pk = $(this).attr('pk');
launchModalForm(`/order/sales-order/allocation/new/`, {
success: reloadTable,
data: {
line: pk,
},
});
});
table.find(".button-build").click(function() {
var pk = $(this).attr('pk');
// Extract the row data from the table!
var idx = $(this).closest('tr').attr('data-index');
var row = table.bootstrapTable('getData')[idx];
var quantity = 1;
if (row.allocated < row.quantity) {
quantity = row.quantity - row.allocated;
}
launchModalForm(`/build/new/`, {
follow: true,
data: {
part: pk,
sales_order: {{ order.id }},
quantity: quantity,
},
});
});
table.find(".button-buy").click(function() {
var pk = $(this).attr('pk');
launchModalForm("{% url 'order-parts' %}", {
data: {
parts: [pk],
},
});
});
}
{% endblock %}

View File

@ -0,0 +1,62 @@
{% extends "order/sales_order_base.html" %}
{% load i18n %}
{% load static %}
{% load inventree_extras %}
{% load status_codes %}
{% load markdownify %}
{% block page_title %}
InvenTree | {% trans "Sales Order" %}
{% endblock %}
{% block details %}
{% include "order/so_tabs.html" with tab='notes' %}
{% if editing %}
<h4>{% trans "Order Notes" %}</h4>
<hr>
<form method='POST'>
{% csrf_token %}
{{ form }}
<hr>
<input type='submit' value='{% trans "Save" %}'/>
</form>
{{ form.media }}
{% else %}
<div class='row'>
<div class='col-sm-6'>
<h4>{% trans "Order Notes" %}</h4>
</div>
<div class='col-sm-6'>
<button title='{% trans "Edit notes" %}' class='btn btn-default btn-glyph float-right' id='edit-notes'><span class='glyphicon glyphicon-edit'></span></button>
</div>
</div>
<hr>
<div class='panel panel-default'>
<div class='panel-content'>
{{ order.notes | markdownify }}
</div>
</div>
{% endif %}
{% endblock %}
{% block js_ready %}
{{ block.super }}
{% if editing %}
{% else %}
$("#edit-notes").click(function() {
location.href = "{% url 'so-notes' order.id %}?edit=1";
});
{% endif %}
{% endblock %}

View File

@ -0,0 +1,30 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
{% if not order.is_fully_allocated %}
<div class='alert alert-block alert-danger'>
<h4>{% trans "Warning" %}</h4>
{% trans "This order has not been fully allocated. If the order is marked as shipped, it can no longer be adjusted." %}
<br>
{% trans "Ensure that the order allocation is correct before shipping the order." %}
</div>
{% endif %}
{% if order.is_over_allocated %}
<div class='alert alert-block alert-warning'>
{% trans "Some line items in this order have been over-allocated" %}
<br>
{% trans "Ensure that this is correct before shipping the order." %}
</div>
{% endif %}
<div class='alert alert-block alert-info'>
<b>{% trans "Sales Order" %} {{ order.reference }} - {{ order.customer.name }}</b>
<br>
{% trans "Shipping this order means that the order will no longer be editable." %}
</div>
{% endblock %}

View File

@ -0,0 +1,44 @@
{% extends "base.html" %}
{% load static %}
{% load i18n %}
{% block page_title %}
InvenTree | {% trans "Sales Orders" %}
{% endblock %}
{% block content %}
<h3>{% trans "Sales Orders" %}</h3>
<hr>
<div id='table-buttons'>
<div class='button-toolbar container-fluid' style='float: right;'>
<button class='btn btn-primary' type='button' id='so-create' title='{% trans "Create new sales order" %}'>{% trans "New Sales Order" %}</button>
<div class='filter-list' id='filter-list-salesorder'>
<!-- An empty div in which the filter list will be constructed -->
</div>
</div>
</div>
<table class='table table-striped table-condensed po-table' data-toolbar='#table-buttons' id='sales-order-table'>
</table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
loadSalesOrderTable("#sales-order-table", {
url: "{% url 'api-so-list' %}",
});
$("#so-create").click(function() {
launchModalForm("{% url 'so-create' %}",
{
follow: true,
}
);
});
{% endblock %}

View File

@ -0,0 +1,14 @@
{% extends "modal_delete_form.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block pre_form_content %}
<div class='alert alert-block alert-warning'>
{% trans "This action will unallocate the following stock from the Sales Order" %}:
<br>
<b>
{% decimal allocation.get_allocated %} x {{ allocation.line.part.full_name }}
{% if allocation.item.location %} ({{ allocation.get_location }}){% endif %}
</b>
</div>
{% endblock %}

View File

@ -0,0 +1,81 @@
{% extends "order/sales_order_base.html" %}
{% load inventree_extras %}
{% load i18n %}
{% load static %}
{% block details %}
{% include 'order/so_tabs.html' with tab='attachments' %}
<h4>{% trans "Sales Order Attachments" %}
<hr>
<div id='attachment-buttons'>
<div class='btn-group'>
<button type='button' class='btn btn-success' id='new-attachment'>{% trans "Add Attachment" %}</button>
</div>
</div>
<table class='table table-striped table-condensed' data-toolbar='#attachment-buttons' id='attachment-table'>
<thead>
<tr>
<th data-field='file' data-searchable='true'>{% trans "File" %}</th>
<th data-field='comment' data-searchable='true'>{% trans "Comment" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for attachment in order.attachments.all %}
<tr>
<td><a href='/media/{{ attachment.attachment }}'>{{ attachment.basename }}</a></td>
<td>{{ attachment.comment }}</td>
<td>
<div class='btn-group' style='float: right;'>
<button type='button' class='btn btn-default btn-glyph attachment-edit-button' url="{% url 'so-attachment-edit' attachment.id %}" data-toggle='tooltip' title='{% trans "Edit attachment" %}'>
<span class='glyphicon glyphicon-edit'/>
</button>
<button type='button' class='btn btn-default btn-glyph attachment-delete-button' url="{% url 'so-attachment-delete' attachment.id %}" data-toggle='tooltip' title='{% trans "Delete attachment" %}'>
<span class='glyphicon glyphicon-trash'/>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
$("#new-attachment").click(function() {
launchModalForm("{% url 'so-attachment-create' %}?order={{ order.id }}",
{
reload: true,
}
);
});
$("#attachment-table").on('click', '.attachment-edit-button', function() {
var button = $(this);
launchModalForm(button.attr('url'), {
reload: true,
});
});
$("#attachment-table").on('click', '.attachment-delete-button', function() {
var button = $(this);
launchModalForm(button.attr('url'), {
reload: true,
});
});
$("#attachment-table").inventreeTable({
});
{% endblock %}

View File

@ -0,0 +1,30 @@
{% extends "order/sales_order_base.html" %}
{% load inventree_extras %}
{% load i18n %}
{% load static %}
{% block details %}
{% include 'order/so_tabs.html' with tab='builds' %}
<h4>{% trans "Build Orders" %}</h4>
<hr>
<table class='table table-striped table-condensed' id='builds-table'></table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
loadBuildTable($("#builds-table"), {
url: "{% url 'api-build-list' %}",
params: {
sales_order: {{ order.id }},
part_detail: true,
},
});
{% endblock %}

View File

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

View File

@ -0,0 +1,25 @@
{% load i18n %}
<ul class='nav nav-tabs'>
<li{% ifequal tab 'details' %} class='active'{% endifequal %}>
<a href="{% url 'so-detail' order.id %}">{% trans "Order Items" %}</a>
</li>
<li{% if tab == 'builds' %} class='active'{% endif %}>
<a href="{% url 'so-builds' order.id %}">
{% trans "Build Orders" %}
{% if order.builds.count > 0 %}
<span class='badge'>{{ order.builds.count }}</span>
{% endif %}
</a>
</li>
<li{% if tab == 'attachments' %} class='active'{% endif %}>
<a href="{% url 'so-attachments' order.id %}">{% trans "Attachments" %}
{% if order.attachments.count > 0 %}
<span class='badge'>{{ order.attachments.count }}</span>
{% endif %}
</a>
</li>
<li{% ifequal tab 'notes' %} class='active'{% endifequal %}>
<a href="{% url 'so-notes' order.id %}">{% trans "Notes" %}{% if order.notes %} <span class='glyphicon glyphicon-small glyphicon-info-sign'></span>{% endif %}</a>
</li>
</ul>

View File

@ -0,0 +1,140 @@
# -*- coding: utf-8 -*-
from django.test import TestCase
from django.core.exceptions import ValidationError
from django.db.utils import IntegrityError
from company.models import Company
from stock.models import StockItem
from order.models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation
from part.models import Part
from InvenTree import status_codes as status
class SalesOrderTest(TestCase):
"""
Run tests to ensure that the SalesOrder model is working correctly.
"""
def setUp(self):
# Create a Company to ship the goods to
self.customer = Company.objects.create(name="ABC Co", description="My customer", is_customer=True)
# Create a Part to ship
self.part = Part.objects.create(name='Spanner', salable=True, description='A spanner that I sell')
# Create some stock!
StockItem.objects.create(part=self.part, quantity=100)
StockItem.objects.create(part=self.part, quantity=200)
# Create a SalesOrder to ship against
self.order = SalesOrder.objects.create(
customer=self.customer,
reference='1234',
customer_reference='ABC 55555'
)
# Create a line item
self.line = SalesOrderLineItem.objects.create(quantity=50, order=self.order, part=self.part)
def test_empty_order(self):
self.assertEqual(self.line.quantity, 50)
self.assertEqual(self.line.allocated_quantity(), 0)
self.assertEqual(self.line.fulfilled_quantity(), 0)
self.assertFalse(self.line.is_fully_allocated())
self.assertFalse(self.line.is_over_allocated())
self.assertTrue(self.order.is_pending)
self.assertFalse(self.order.is_fully_allocated())
def test_add_duplicate_line_item(self):
# Adding a duplicate line item to a SalesOrder must throw an error
with self.assertRaises(IntegrityError):
SalesOrderLineItem.objects.create(order=self.order, part=self.part)
def allocate_stock(self, full=True):
# Allocate stock to the order
SalesOrderAllocation.objects.create(
line=self.line,
item=StockItem.objects.get(pk=1),
quantity=25)
SalesOrderAllocation.objects.create(
line=self.line,
item=StockItem.objects.get(pk=2),
quantity=25 if full else 20
)
def test_allocate_partial(self):
# Partially allocate stock
self.allocate_stock(False)
self.assertFalse(self.order.is_fully_allocated())
self.assertFalse(self.line.is_fully_allocated())
self.assertEqual(self.line.allocated_quantity(), 45)
self.assertEqual(self.line.fulfilled_quantity(), 0)
def test_allocate_full(self):
# Fully allocate stock
self.allocate_stock(True)
self.assertTrue(self.order.is_fully_allocated())
self.assertTrue(self.line.is_fully_allocated())
self.assertEqual(self.line.allocated_quantity(), 50)
def test_order_cancel(self):
# Allocate line items then cancel the order
self.allocate_stock(True)
self.assertEqual(SalesOrderAllocation.objects.count(), 2)
self.assertEqual(self.order.status, status.SalesOrderStatus.PENDING)
self.order.cancel_order()
self.assertEqual(SalesOrderAllocation.objects.count(), 0)
self.assertEqual(self.order.status, status.SalesOrderStatus.CANCELLED)
# Now try to ship it - should fail
with self.assertRaises(ValidationError):
self.order.ship_order(None)
def test_ship_order(self):
# Allocate line items, then ship the order
# Assert some stuff before we run the test
# Initially there are two stock items
self.assertEqual(StockItem.objects.count(), 2)
# Take 25 units from each StockItem
self.allocate_stock(True)
self.assertEqual(SalesOrderAllocation.objects.count(), 2)
self.order.ship_order(None)
# There should now be 4 stock items
self.assertEqual(StockItem.objects.count(), 4)
self.assertEqual(StockItem.objects.get(pk=1).quantity, 75)
self.assertEqual(StockItem.objects.get(pk=2).quantity, 175)
self.assertEqual(StockItem.objects.get(pk=3).quantity, 25)
self.assertEqual(StockItem.objects.get(pk=3).quantity, 25)
self.assertEqual(StockItem.objects.get(pk=1).sales_order, None)
self.assertEqual(StockItem.objects.get(pk=2).sales_order, None)
self.assertEqual(StockItem.objects.get(pk=3).sales_order, self.order)
self.assertEqual(StockItem.objects.get(pk=4).sales_order, self.order)
# And no allocations
self.assertEqual(SalesOrderAllocation.objects.count(), 0)
self.assertEqual(self.order.status, status.SalesOrderStatus.SHIPPED)
self.assertTrue(self.order.is_fully_allocated())
self.assertTrue(self.line.is_fully_allocated())
self.assertEqual(self.line.fulfilled_quantity(), 50)
self.assertEqual(self.line.allocated_quantity(), 0)

View File

@ -7,7 +7,7 @@ from django.test import TestCase
from django.urls import reverse
from django.contrib.auth import get_user_model
from InvenTree.status_codes import OrderStatus
from InvenTree.status_codes import PurchaseOrderStatus
from .models import PurchaseOrder, PurchaseOrderLineItem
@ -53,7 +53,7 @@ class POTests(OrderViewTestCase):
response = self.client.get(reverse('po-detail', args=(1,)))
self.assertEqual(response.status_code, 200)
keys = response.context.keys()
self.assertIn('OrderStatus', keys)
self.assertIn('PurchaseOrderStatus', keys)
def test_po_create(self):
""" Launch forms to create new PurchaseOrder"""
@ -91,7 +91,7 @@ class POTests(OrderViewTestCase):
url = reverse('po-issue', args=(1,))
order = PurchaseOrder.objects.get(pk=1)
self.assertEqual(order.status, OrderStatus.PENDING)
self.assertEqual(order.status, PurchaseOrderStatus.PENDING)
# Test without confirmation
response = self.client.post(url, {'confirm': 0}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
@ -109,7 +109,7 @@ class POTests(OrderViewTestCase):
# Test that the order was actually placed
order = PurchaseOrder.objects.get(pk=1)
self.assertEqual(order.status, OrderStatus.PLACED)
self.assertEqual(order.status, PurchaseOrderStatus.PLACED)
def test_line_item_create(self):
""" Test the form for adding a new LineItem to a PurchaseOrder """
@ -117,7 +117,7 @@ class POTests(OrderViewTestCase):
# Record the number of line items in the PurchaseOrder
po = PurchaseOrder.objects.get(pk=1)
n = po.lines.count()
self.assertEqual(po.status, OrderStatus.PENDING)
self.assertEqual(po.status, PurchaseOrderStatus.PENDING)
url = reverse('po-line-item-create')
@ -181,7 +181,7 @@ class TestPOReceive(OrderViewTestCase):
super().setUp()
self.po = PurchaseOrder.objects.get(pk=1)
self.po.status = OrderStatus.PLACED
self.po.status = PurchaseOrderStatus.PLACED
self.po.save()
self.url = reverse('po-receive', args=(1,))

View File

@ -6,7 +6,7 @@ from .models import PurchaseOrder, PurchaseOrderLineItem
from stock.models import StockLocation
from company.models import SupplierPart
from InvenTree.status_codes import OrderStatus
from InvenTree.status_codes import PurchaseOrderStatus
class OrderTest(TestCase):
@ -31,11 +31,11 @@ class OrderTest(TestCase):
self.assertEqual(order.get_absolute_url(), '/order/purchase-order/1/')
self.assertEqual(str(order), 'PO 1')
self.assertEqual(str(order), 'PO 1 - ACME')
line = PurchaseOrderLineItem.objects.get(pk=1)
self.assertEqual(str(line), "100 x ACME0001 from ACME (for PO 1)")
self.assertEqual(str(line), "100 x ACME0001 from ACME (for PO 1 - ACME)")
def test_on_order(self):
""" There should be 3 separate items on order for the M2x4 LPHS part """
@ -57,7 +57,7 @@ class OrderTest(TestCase):
order = PurchaseOrder.objects.get(pk=1)
self.assertEqual(order.status, OrderStatus.PENDING)
self.assertEqual(order.status, PurchaseOrderStatus.PENDING)
self.assertEqual(order.lines.count(), 3)
sku = SupplierPart.objects.get(SKU='ACME-WIDGET')
@ -104,14 +104,14 @@ class OrderTest(TestCase):
self.assertEqual(len(order.pending_line_items()), 3)
# Should fail, as order is 'PENDING' not 'PLACED"
self.assertEqual(order.status, OrderStatus.PENDING)
self.assertEqual(order.status, PurchaseOrderStatus.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)
self.assertEqual(order.status, PurchaseOrderStatus.PLACED)
order.receive_line_item(line, loc, 50, user=None)
@ -134,9 +134,9 @@ class OrderTest(TestCase):
order.receive_line_item(line, loc, 500, user=None)
self.assertEqual(part.on_order, 800)
self.assertEqual(order.status, OrderStatus.PLACED)
self.assertEqual(order.status, PurchaseOrderStatus.PLACED)
for line in order.pending_line_items():
order.receive_line_item(line, loc, line.quantity, user=None)
self.assertEqual(order.status, OrderStatus.COMPLETE)
self.assertEqual(order.status, PurchaseOrderStatus.COMPLETE)

View File

@ -9,21 +9,15 @@ from django.conf.urls import url, include
from . import views
purchase_order_attachment_urls = [
url(r'^new/', views.PurchaseOrderAttachmentCreate.as_view(), name='po-attachment-create'),
url(r'^(?P<pk>\d+)/edit/', views.PurchaseOrderAttachmentEdit.as_view(), name='po-attachment-edit'),
url(r'^(?P<pk>\d+)/delete/', views.PurchaseOrderAttachmentDelete.as_view(), name='po-attachment-delete'),
]
purchase_order_detail_urls = [
url(r'^cancel/?', views.PurchaseOrderCancel.as_view(), name='po-cancel'),
url(r'^edit/?', views.PurchaseOrderEdit.as_view(), name='po-edit'),
url(r'^issue/?', views.PurchaseOrderIssue.as_view(), name='po-issue'),
url(r'^receive/?', views.PurchaseOrderReceive.as_view(), name='po-receive'),
url(r'^complete/?', views.PurchaseOrderComplete.as_view(), name='po-complete'),
url(r'^cancel/', views.PurchaseOrderCancel.as_view(), name='po-cancel'),
url(r'^edit/', views.PurchaseOrderEdit.as_view(), name='po-edit'),
url(r'^issue/', views.PurchaseOrderIssue.as_view(), name='po-issue'),
url(r'^receive/', views.PurchaseOrderReceive.as_view(), name='po-receive'),
url(r'^complete/', views.PurchaseOrderComplete.as_view(), name='po-complete'),
url(r'^export/?', views.PurchaseOrderExport.as_view(), name='po-export'),
url(r'^export/', views.PurchaseOrderExport.as_view(), name='po-export'),
url(r'^notes/', views.PurchaseOrderNotes.as_view(), name='po-notes'),
@ -31,19 +25,6 @@ purchase_order_detail_urls = [
url(r'^.*$', views.PurchaseOrderDetail.as_view(), name='po-detail'),
]
po_line_item_detail_urls = [
url(r'^edit/', views.POLineItemEdit.as_view(), name='po-line-item-edit'),
url(r'^delete/', views.POLineItemDelete.as_view(), name='po-line-item-delete'),
]
po_line_urls = [
url(r'^new/', views.POLineItemCreate.as_view(), name='po-line-item-create'),
url(r'^(?P<pk>\d+)/', include(po_line_item_detail_urls)),
]
purchase_order_urls = [
url(r'^new/', views.PurchaseOrderCreate.as_view(), name='po-create'),
@ -53,14 +34,72 @@ purchase_order_urls = [
# 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)),
url(r'^line/', include([
url(r'^new/', views.POLineItemCreate.as_view(), name='po-line-item-create'),
url(r'^(?P<pk>\d+)/', include([
url(r'^edit/', views.POLineItemEdit.as_view(), name='po-line-item-edit'),
url(r'^delete/', views.POLineItemDelete.as_view(), name='po-line-item-delete'),
])),
])),
url(r'^attachments/', include(purchase_order_attachment_urls)),
url(r'^attachments/', include([
url(r'^new/', views.PurchaseOrderAttachmentCreate.as_view(), name='po-attachment-create'),
url(r'^(?P<pk>\d+)/edit/', views.PurchaseOrderAttachmentEdit.as_view(), name='po-attachment-edit'),
url(r'^(?P<pk>\d+)/delete/', views.PurchaseOrderAttachmentDelete.as_view(), name='po-attachment-delete'),
])),
# Display complete list of purchase orders
url(r'^.*$', views.PurchaseOrderIndex.as_view(), name='po-index'),
]
sales_order_detail_urls = [
url(r'^edit/', views.SalesOrderEdit.as_view(), name='so-edit'),
url(r'^cancel/', views.SalesOrderCancel.as_view(), name='so-cancel'),
url(r'^ship/', views.SalesOrderShip.as_view(), name='so-ship'),
url(r'^builds/', views.SalesOrderDetail.as_view(template_name='order/so_builds.html'), name='so-builds'),
url(r'^attachments/', views.SalesOrderDetail.as_view(template_name='order/so_attachments.html'), name='so-attachments'),
url(r'^notes/', views.SalesOrderNotes.as_view(), name='so-notes'),
url(r'^.*$', views.SalesOrderDetail.as_view(), name='so-detail'),
]
sales_order_urls = [
url(r'^new/', views.SalesOrderCreate.as_view(), name='so-create'),
url(r'^line/', include([
url(r'^new/', views.SOLineItemCreate.as_view(), name='so-line-item-create'),
url(r'^(?P<pk>\d+)/', include([
url(r'^edit/', views.SOLineItemEdit.as_view(), name='so-line-item-edit'),
url(r'^delete/', views.SOLineItemDelete.as_view(), name='so-line-item-delete'),
])),
])),
# URLs for sales order allocations
url(r'^allocation/', include([
url(r'^new/', views.SalesOrderAllocationCreate.as_view(), name='so-allocation-create'),
url(r'(?P<pk>\d+)/', include([
url(r'^edit/', views.SalesOrderAllocationEdit.as_view(), name='so-allocation-edit'),
url(r'^delete/', views.SalesOrderAllocationDelete.as_view(), name='so-allocation-delete'),
])),
])),
url(r'^attachments/', include([
url(r'^new/', views.SalesOrderAttachmentCreate.as_view(), name='so-attachment-create'),
url(r'^(?P<pk>\d+)/edit/', views.SalesOrderAttachmentEdit.as_view(), name='so-attachment-edit'),
url(r'^(?P<pk>\d+)/delete/', views.SalesOrderAttachmentDelete.as_view(), name='so-attachment-delete'),
])),
# Display detail view for a single SalesOrder
url(r'^(?P<pk>\d+)/', include(sales_order_detail_urls)),
# Display list of all sales orders
url(r'^.*$', views.SalesOrderIndex.as_view(), name='so-index'),
]
order_urls = [
url(r'^purchase-order/', include(purchase_order_urls)),
url(r'^sales-order/', include(sales_order_urls)),
]

View File

@ -16,6 +16,8 @@ import logging
from decimal import Decimal, InvalidOperation
from .models import PurchaseOrder, PurchaseOrderLineItem, PurchaseOrderAttachment
from .models import SalesOrder, SalesOrderLineItem, SalesOrderAttachment
from .models import SalesOrderAllocation
from .admin import POLineItemResource
from build.models import Build
from company.models import Company, SupplierPart
@ -27,7 +29,7 @@ from . import forms as order_forms
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
from InvenTree.helpers import DownloadFile, str2bool
from InvenTree.status_codes import OrderStatus
from InvenTree.status_codes import PurchaseOrderStatus
logger = logging.getLogger(__name__)
@ -50,11 +52,16 @@ class PurchaseOrderIndex(ListView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['OrderStatus'] = OrderStatus
return ctx
class SalesOrderIndex(ListView):
model = SalesOrder
template_name = 'order/sales_orders.html'
context_object_name = 'orders'
class PurchaseOrderDetail(DetailView):
""" Detail view for a PurchaseOrder object """
@ -65,11 +72,17 @@ class PurchaseOrderDetail(DetailView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['OrderStatus'] = OrderStatus
return ctx
class SalesOrderDetail(DetailView):
""" Detail view for a SalesOrder object """
context_object_name = 'order'
queryset = SalesOrder.objects.all().prefetch_related('lines')
template_name = 'order/sales_order_detail.html'
class PurchaseOrderAttachmentCreate(AjaxCreateView):
"""
View for creating a new PurchaseOrderAtt
@ -113,6 +126,34 @@ class PurchaseOrderAttachmentCreate(AjaxCreateView):
return form
class SalesOrderAttachmentCreate(AjaxCreateView):
""" View for creating a new SalesOrderAttachment """
model = SalesOrderAttachment
form_class = order_forms.EditSalesOrderAttachmentForm
ajax_form_title = _('Add Sales Order Attachment')
def get_data(self):
return {
'success': _('Added attachment')
}
def get_initial(self):
initials = super().get_initial().copy()
initials['order'] = SalesOrder.objects.get(id=self.request.GET.get('order', None))
return initials
def get_form(self):
""" Hide the 'order' field """
form = super().get_form()
form.fields['order'].widget = HiddenInput()
return form
class PurchaseOrderAttachmentEdit(AjaxUpdateView):
""" View for editing a PurchaseOrderAttachment object """
@ -134,12 +175,46 @@ class PurchaseOrderAttachmentEdit(AjaxUpdateView):
return form
class SalesOrderAttachmentEdit(AjaxUpdateView):
""" View for editing a SalesOrderAttachment object """
model = SalesOrderAttachment
form_class = order_forms.EditSalesOrderAttachmentForm
ajax_form_title = _("Edit Attachment")
def get_data(self):
return {
'success': _('Attachment updated')
}
def get_form(self):
form = super().get_form()
form.fields['order'].widget = HiddenInput()
return form
class PurchaseOrderAttachmentDelete(AjaxDeleteView):
""" View for deleting a PurchaseOrderAttachment """
model = PurchaseOrderAttachment
ajax_form_title = _("Delete Attachment")
ajax_template_name = "order/po_delete.html"
ajax_template_name = "order/delete_attachment.html"
context_object_name = "attachment"
def get_data(self):
return {
"danger": _("Deleted attachment")
}
class SalesOrderAttachmentDelete(AjaxDeleteView):
""" View for deleting a SalesOrderAttachment """
model = SalesOrderAttachment
ajax_form_title = _("Delete Attachment")
ajax_template_name = "order/delete_attachment.html"
context_object_name = "attachment"
def get_data(self):
@ -165,7 +240,28 @@ class PurchaseOrderNotes(UpdateView):
ctx = super().get_context_data(**kwargs)
ctx['editing'] = str2bool(self.request.GET.get('edit', ''))
ctx['editing'] = str2bool(self.request.GET.get('edit', False))
return ctx
class SalesOrderNotes(UpdateView):
""" View for editing the 'notes' field of a SalesORder """
context_object_name = 'order'
template_name = 'order/sales_order_notes.html'
model = SalesOrder
fields = ['notes']
def get_success_url(self):
return reverse('so-notes', kwargs={'pk': self.get_object().pk})
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['editing'] = str2bool(self.request.GET.get('edit', False))
return ctx
@ -180,7 +276,7 @@ class PurchaseOrderCreate(AjaxCreateView):
def get_initial(self):
initials = super().get_initial().copy()
initials['status'] = OrderStatus.PENDING
initials['status'] = PurchaseOrderStatus.PENDING
supplier_id = self.request.GET.get('supplier', None)
@ -200,6 +296,35 @@ class PurchaseOrderCreate(AjaxCreateView):
self.object.save()
class SalesOrderCreate(AjaxCreateView):
""" View for creating a new SalesOrder object """
model = SalesOrder
ajax_form_title = _("Create Sales Order")
form_class = order_forms.EditSalesOrderForm
def get_initial(self):
initials = super().get_initial().copy()
initials['status'] = PurchaseOrderStatus.PENDING
customer_id = self.request.GET.get('customer', None)
if customer_id is not None:
try:
customer = Company.objects.get(id=customer_id)
initials['customer'] = customer
except (Company.DoesNotExist, ValueError):
pass
return initials
def post_save(self, **kwargs):
# Record the user who created this sales order
self.object.created_by = self.request.user
self.object.save()
class PurchaseOrderEdit(AjaxUpdateView):
""" View for editing a PurchaseOrder using a modal form """
@ -214,12 +339,28 @@ class PurchaseOrderEdit(AjaxUpdateView):
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:
if order.lines.count() > 0 or not order.status == PurchaseOrderStatus.PENDING:
form.fields['supplier'].widget = HiddenInput()
return form
class SalesOrderEdit(AjaxUpdateView):
""" View for editing a SalesOrder """
model = SalesOrder
ajax_form_title = _('Edit Sales Order')
form_class = order_forms.EditSalesOrderForm
def get_form(self):
form = super().get_form()
# Prevent user from editing customer
form.fields['customer'].widget = HiddenInput()
return form
class PurchaseOrderCancel(AjaxUpdateView):
""" View for cancelling a purchase order """
@ -253,6 +394,40 @@ class PurchaseOrderCancel(AjaxUpdateView):
return self.renderJsonResponse(request, form, data)
class SalesOrderCancel(AjaxUpdateView):
""" View for cancelling a sales order """
model = SalesOrder
ajax_form_title = _("Cancel sales order")
ajax_template_name = "order/sales_order_cancel.html"
form_class = order_forms.CancelSalesOrderForm
def post(self, request, *args, **kwargs):
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 cancellation')]
else:
valid = True
if valid:
if not order.cancel_order():
form.non_field_errors = [_('Could not cancel order')]
valid = False
data = {
'form_valid': valid,
}
return self.renderJsonResponse(request, form, data)
class PurchaseOrderIssue(AjaxUpdateView):
""" View for changing a purchase order from 'PENDING' to 'ISSUED' """
@ -310,7 +485,7 @@ class PurchaseOrderComplete(AjaxUpdateView):
if confirm:
po = self.get_object()
po.status = OrderStatus.COMPLETE
po.status = PurchaseOrderStatus.COMPLETE
po.save()
data = {
@ -322,6 +497,48 @@ class PurchaseOrderComplete(AjaxUpdateView):
return self.renderJsonResponse(request, form, data)
class SalesOrderShip(AjaxUpdateView):
""" View for 'shipping' a SalesOrder """
form_class = order_forms.ShipSalesOrderForm
model = SalesOrder
context_object_name = 'order'
ajax_template_name = 'order/sales_order_ship.html'
ajax_form_title = _('Ship Order')
def post(self, request, *args, **kwargs):
self.request = request
order = self.get_object()
self.object = order
form = self.get_form()
confirm = str2bool(request.POST.get('confirm', False))
valid = False
if not confirm:
form.errors['confirm'] = [_('Confirm order shipment')]
else:
valid = True
if valid:
if not order.ship_order(request.user):
form.non_field_errors = [_('Could not ship order')]
valid = False
data = {
'form_valid': valid,
}
context = self.get_context_data()
context['order'] = order
return self.renderJsonResponse(request, form, data, context)
class PurchaseOrderExport(AjaxView):
""" File download for a purchase order
@ -879,7 +1096,7 @@ class POLineItemCreate(AjaxCreateView):
# Limit the available to orders to ones that are PENDING
query = form.fields['order'].queryset
query = query.filter(status=OrderStatus.PENDING)
query = query.filter(status=PurchaseOrderStatus.PENDING)
form.fields['order'].queryset = query
order_id = form['order'].value()
@ -924,12 +1141,80 @@ class POLineItemCreate(AjaxCreateView):
order = PurchaseOrder.objects.get(id=order_id)
initials['order'] = order
except PurchaseOrder.DoesNotExist:
except (PurchaseOrder.DoesNotExist, ValueError):
pass
return initials
class SOLineItemCreate(AjaxCreateView):
""" Ajax view for creating a new SalesOrderLineItem object """
model = SalesOrderLineItem
context_order_name = 'line'
form_class = order_forms.EditSalesOrderLineItemForm
ajax_form_title = _('Add Line Item')
def get_form(self, *args, **kwargs):
form = super().get_form(*args, **kwargs)
# If the order is specified, hide the widget
order_id = form['order'].value()
if SalesOrder.objects.filter(id=order_id).exists():
form.fields['order'].widget = HiddenInput()
return form
def get_initial(self):
"""
Extract initial data for this line item:
Options:
order: The SalesOrder object
part: The Part object
"""
initials = super().get_initial().copy()
order_id = self.request.GET.get('order', None)
part_id = self.request.GET.get('part', None)
if order_id:
try:
order = SalesOrder.objects.get(id=order_id)
initials['order'] = order
except (SalesOrder.DoesNotExist, ValueError):
pass
if part_id:
try:
part = Part.objects.get(id=part_id)
if part.salable:
initials['part'] = part
except (Part.DoesNotExist, ValueError):
pass
return initials
class SOLineItemEdit(AjaxUpdateView):
""" View for editing a SalesOrderLineItem """
model = SalesOrderLineItem
form_class = order_forms.EditSalesOrderLineItemForm
ajax_form_title = _('Edit Line Item')
def get_form(self):
form = super().get_form()
form.fields.pop('order')
form.fields.pop('part')
return form
class POLineItemEdit(AjaxUpdateView):
""" View for editing a PurchaseOrderLineItem object in a modal form.
"""
@ -960,3 +1245,109 @@ class POLineItemDelete(AjaxDeleteView):
return {
'danger': _('Deleted line item'),
}
class SOLineItemDelete(AjaxDeleteView):
model = SalesOrderLineItem
ajax_form_title = _("Delete Line Item")
ajax_template_name = "order/so_lineitem_delete.html"
def get_data(self):
return {
'danger': _('Deleted line item'),
}
class SalesOrderAllocationCreate(AjaxCreateView):
""" View for creating a new SalesOrderAllocation """
model = SalesOrderAllocation
form_class = order_forms.EditSalesOrderAllocationForm
ajax_form_title = _('Allocate Stock to Order')
def get_initial(self):
initials = super().get_initial().copy()
line_id = self.request.GET.get('line', None)
if line_id is not None:
line = SalesOrderLineItem.objects.get(pk=line_id)
initials['line'] = line
# Search for matching stock items, pre-fill if there is only one
items = StockItem.objects.filter(part=line.part)
quantity = line.quantity - line.allocated_quantity()
if quantity < 0:
quantity = 0
if items.count() == 1:
item = items.first()
initials['item'] = item
# Reduce the quantity IF there is not enough stock
qmax = item.quantity - item.allocation_count()
if qmax < quantity:
quantity = qmax
initials['quantity'] = quantity
return initials
def get_form(self):
form = super().get_form()
line_id = form['line'].value()
# If a line item has been specified, reduce the queryset for the stockitem accordingly
try:
line = SalesOrderLineItem.objects.get(pk=line_id)
queryset = form.fields['item'].queryset
# Ensure the part reference matches
queryset = queryset.filter(part=line.part)
# Exclude StockItem which are already allocated to this order
allocated = [allocation.item.pk for allocation in line.allocations.all()]
queryset = queryset.exclude(pk__in=allocated)
form.fields['item'].queryset = queryset
# Hide the 'line' field
form.fields['line'].widget = HiddenInput()
except (ValueError, SalesOrderLineItem.DoesNotExist):
pass
return form
class SalesOrderAllocationEdit(AjaxUpdateView):
model = SalesOrderAllocation
form_class = order_forms.EditSalesOrderAllocationForm
ajax_form_title = _('Edit Allocation Quantity')
def get_form(self):
form = super().get_form()
# Prevent the user from editing particular fields
form.fields.pop('item')
form.fields.pop('line')
return form
class SalesOrderAllocationDelete(AjaxDeleteView):
model = SalesOrderAllocation
ajax_form_title = _("Remove allocation")
context_object_name = 'allocation'
ajax_template_name = "order/so_allocation_delete.html"

View File

@ -1,32 +1,20 @@
# Generated by Django 2.2.10 on 2020-04-04 12:38
from django.db import migrations
from django.db.utils import OperationalError, ProgrammingError
from part.models import Part
from stdimage.utils import render_variations
def create_thumbnails(apps, schema_editor):
"""
Create thumbnails for all existing Part images.
"""
try:
for part in Part.objects.all():
# Render thumbnail for each existing Part
if part.image:
try:
part.image.render_variations()
except FileNotFoundError:
print("Missing image:", part.image())
# The image is missing, so clear the field
part.image = None
part.save()
except (OperationalError, ProgrammingError):
# Migrations have not yet been applied - table does not exist
print("Could not generate Part thumbnails")
Note: This functionality is now performed in apps.py,
as running the thumbnail script here caused too many database level errors.
This migration is left here to maintain the database migration history
"""
pass
class Migration(migrations.Migration):
@ -35,5 +23,5 @@ class Migration(migrations.Migration):
]
operations = [
migrations.RunPython(create_thumbnails),
migrations.RunPython(create_thumbnails, reverse_code=create_thumbnails),
]

View File

@ -39,9 +39,10 @@ from InvenTree.models import InvenTreeTree, InvenTreeAttachment
from InvenTree.fields import InvenTreeURLField
from InvenTree.helpers import decimal2string, normalize
from InvenTree.status_codes import BuildStatus, StockStatus, OrderStatus
from InvenTree.status_codes import BuildStatus, StockStatus, PurchaseOrderStatus
from company.models import SupplierPart
from stock import models as StockModels
class PartCategory(InvenTreeTree):
@ -639,11 +640,12 @@ class Part(models.Model):
def stock_entries(self):
""" Return all 'in stock' items. To be in stock:
- customer is None
- build_order is None
- sales_order is None
- belongs_to is None
"""
return self.stock_items.filter(customer=None, belongs_to=None)
return self.stock_items.filter(StockModels.StockItem.IN_STOCK_FILTER).exclude(status__in=StockStatus.UNAVAILABLE_CODES)
@property
def total_stock(self):
@ -824,6 +826,11 @@ class Part(models.Model):
max_price = None
for item in self.bom_items.all().select_related('sub_part'):
if item.sub_part.pk == self.pk:
print("Warning: Item contains itself in BOM")
continue
prices = item.sub_part.get_price_range(quantity * item.quantity)
if prices is None:
@ -924,6 +931,17 @@ class Part(models.Model):
return n
def sales_orders(self):
""" Return a list of sales orders which reference this part """
orders = []
for line in self.sales_order_line_items.all().prefetch_related('order'):
if line.order not in orders:
orders.append(line.order)
return orders
def purchase_orders(self):
""" Return a list of purchase orders which reference this part """
@ -939,18 +957,18 @@ class Part(models.Model):
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]
return [order for order in self.purchase_orders() if order.status in PurchaseOrderStatus.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]
return [order for order in self.purchase_orders() if order.status not in PurchaseOrderStatus.OPEN]
@property
def on_order(self):
""" Return the total number of items on order for this part. """
orders = self.supplier_parts.filter(purchase_order_line_items__order__status__in=OrderStatus.OPEN).aggregate(
orders = self.supplier_parts.filter(purchase_order_line_items__order__status__in=PurchaseOrderStatus.OPEN).aggregate(
quantity=Sum('purchase_order_line_items__quantity'),
received=Sum('purchase_order_line_items__received')
)

View File

@ -15,7 +15,7 @@ from decimal import Decimal
from django.db.models import Q, Sum
from django.db.models.functions import Coalesce
from InvenTree.status_codes import StockStatus, OrderStatus, BuildStatus
from InvenTree.status_codes import StockStatus, PurchaseOrderStatus, BuildStatus
from InvenTree.serializers import InvenTreeModelSerializer
@ -52,19 +52,19 @@ class PartThumbSerializer(serializers.Serializer):
class PartBriefSerializer(InvenTreeModelSerializer):
""" Serializer for Part (brief detail) """
url = serializers.CharField(source='get_absolute_url', read_only=True)
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
class Meta:
model = Part
fields = [
'pk',
'url',
'full_name',
'description',
'thumbnail',
'active',
'assembly',
'purchaseable',
'salable',
'virtual',
]
@ -118,7 +118,7 @@ class PartSerializer(InvenTreeModelSerializer):
stock_filter = Q(stock_items__status__in=StockStatus.AVAILABLE_CODES)
# Filter to limit orders to "open"
order_filter = Q(supplier_parts__purchase_order_line_items__order__status__in=OrderStatus.OPEN)
order_filter = Q(supplier_parts__purchase_order_line_items__order__status__in=PurchaseOrderStatus.OPEN)
# Filter to limit builds to "active"
build_filter = Q(builds__status__in=BuildStatus.ACTIVE_CODES)
@ -233,9 +233,13 @@ class PartStarSerializer(InvenTreeModelSerializer):
class BomItemSerializer(InvenTreeModelSerializer):
""" Serializer for BomItem object """
price_range = serializers.CharField(read_only=True)
quantity = serializers.FloatField()
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
sub_part_detail = PartBriefSerializer(source='sub_part', many=False, read_only=True)
price_range = serializers.CharField(read_only=True)
validated = serializers.BooleanField(read_only=True, source='is_line_valid')
def __init__(self, *args, **kwargs):

View File

@ -18,7 +18,7 @@
<td><a href="{% url 'build-detail' allocation.build.id %}">{{ allocation.build.title }}</a></td>
<td>{{ allocation.build.quantity }} &times <a href="{% url 'part-detail' allocation.build.part.id %}">{{ allocation.build.part.full_name }}</a></td>
<td>{{ allocation.quantity }}</td>
<td>{% build_status allocation.build.status %}</td>
<td>{% build_status_label allocation.build.status %}</td>
</tr>
{% endfor %}
</table>

View File

@ -14,16 +14,16 @@
<p>{% trans "All parts" %}</p>
{% endif %}
<p>
<div class='btn-group'>
<button class='btn btn-default btn-glyph' id='cat-create' title='Create new part category'>
<span class='glyphicon glyphicon-plus'/>
<div class='btn-group action-buttons'>
<button class='btn btn-default' id='cat-create' title='Create new part category'>
<span class='fas fa-plus-circle icon-green'/>
</button>
{% if category %}
<button class='btn btn-default btn-glyph' id='cat-edit' title='Edit part category'>
<span class='glyphicon glyphicon-edit'/>
<button class='btn btn-default' id='cat-edit' title='Edit part category'>
<span class='fas fa-edit icon-blue'/>
</button>
<button class='btn btn-default btn-glyph' id='cat-delete' title='Delete part category'>
<span class='glyphicon glyphicon-trash'/>
<button class='btn btn-default' id='cat-delete' title='Delete part category'>
<span class='fas fa-trash-alt icon-red'/>
</button>
{% endif %}
</div>

Some files were not shown because too many files have changed in this diff Show More