mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
commit
b338834146
@ -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)
|
||||
|
19
InvenTree/InvenTree/context.py
Normal file
19
InvenTree/InvenTree/context.py
Normal 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,
|
||||
}
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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);
|
||||
}
|
@ -221,7 +221,6 @@ function loadBomTable(table, options) {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
// Part notes
|
||||
|
@ -1,4 +1,5 @@
|
||||
function loadBuildTable(table, options) {
|
||||
// Display a table of Build objects
|
||||
|
||||
var params = options.params || {};
|
||||
|
||||
|
@ -18,6 +18,8 @@ function defaultFilters() {
|
||||
build: "",
|
||||
parts: "cascade=1",
|
||||
company: "",
|
||||
salesorder: "",
|
||||
purchaseorder: "",
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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',
|
||||
|
@ -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>`;
|
||||
|
@ -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;
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -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 = [
|
||||
|
@ -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)),
|
||||
|
@ -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():
|
||||
|
@ -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'),
|
||||
|
||||
|
@ -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
|
@ -22,6 +22,8 @@ class EditBuildForm(HelperForm):
|
||||
fields = [
|
||||
'title',
|
||||
'part',
|
||||
'parent',
|
||||
'sales_order',
|
||||
'quantity',
|
||||
'take_from',
|
||||
'batch',
|
||||
|
20
InvenTree/build/migrations/0012_build_sales_order.py
Normal file
20
InvenTree/build/migrations/0012_build_sales_order.py
Normal 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'),
|
||||
),
|
||||
]
|
55
InvenTree/build/migrations/0013_auto_20200425_0507.py
Normal file
55
InvenTree/build/migrations/0013_auto_20200425_0507.py
Normal 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),
|
||||
]
|
71
InvenTree/build/migrations/0014_auto_20200425_1243.py
Normal file
71
InvenTree/build/migrations/0014_auto_20200425_1243.py
Normal 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'),
|
||||
),
|
||||
]
|
26
InvenTree/build/migrations/0015_auto_20200425_1350.py
Normal file
26
InvenTree/build/migrations/0015_auto_20200425_1350.py
Normal 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)]),
|
||||
),
|
||||
]
|
20
InvenTree/build/migrations/0016_auto_20200426_0551.py
Normal file
20
InvenTree/build/migrations/0016_auto_20200426_0551.py
Normal 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'),
|
||||
),
|
||||
]
|
20
InvenTree/build/migrations/0017_auto_20200426_0612.py
Normal file
20
InvenTree/build/migrations/0017_auto_20200426_0612.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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')
|
||||
)
|
||||
|
@ -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 = [
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
@ -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>
|
@ -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 %}
|
@ -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 %}
|
@ -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 %}
|
||||
|
@ -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 %}
|
@ -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>
|
||||
|
@ -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>
|
227
InvenTree/build/test_build.py
Normal file
227
InvenTree/build/test_build.py
Normal 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)
|
@ -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)
|
||||
|
@ -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'),
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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',
|
||||
]
|
||||
|
||||
|
||||
|
@ -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(
|
||||
|
@ -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 }},
|
||||
}
|
||||
});
|
||||
|
||||
|
41
InvenTree/company/templates/company/sales_orders.html
Normal file
41
InvenTree/company/templates/company/sales_orders.html
Normal 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 %}
|
@ -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 %}
|
||||
|
@ -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>
|
||||
|
@ -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'),
|
||||
|
@ -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
|
||||
|
||||
|
Binary file not shown.
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
@ -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)
|
||||
|
@ -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'),
|
||||
]
|
||||
|
@ -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']
|
||||
|
76
InvenTree/order/migrations/0020_auto_20200420_0940.py
Normal file
76
InvenTree/order/migrations/0020_auto_20200420_0940.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
20
InvenTree/order/migrations/0021_auto_20200420_1010.py
Normal file
20
InvenTree/order/migrations/0021_auto_20200420_1010.py
Normal 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'),
|
||||
),
|
||||
]
|
20
InvenTree/order/migrations/0022_salesorderlineitem_part.py
Normal file
20
InvenTree/order/migrations/0022_salesorderlineitem_part.py
Normal 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'),
|
||||
),
|
||||
]
|
20
InvenTree/order/migrations/0023_auto_20200420_2309.py
Normal file
20
InvenTree/order/migrations/0023_auto_20200420_2309.py
Normal 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'),
|
||||
),
|
||||
]
|
26
InvenTree/order/migrations/0024_salesorderallocation.py
Normal file
26
InvenTree/order/migrations/0024_salesorderallocation.py
Normal 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')),
|
||||
],
|
||||
),
|
||||
]
|
18
InvenTree/order/migrations/0025_auto_20200422_0222.py
Normal file
18
InvenTree/order/migrations/0025_auto_20200422_0222.py
Normal 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')},
|
||||
),
|
||||
]
|
20
InvenTree/order/migrations/0026_auto_20200422_0224.py
Normal file
20
InvenTree/order/migrations/0026_auto_20200422_0224.py
Normal 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'),
|
||||
),
|
||||
]
|
20
InvenTree/order/migrations/0027_auto_20200422_0236.py
Normal file
20
InvenTree/order/migrations/0027_auto_20200422_0236.py
Normal 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'),
|
||||
),
|
||||
]
|
37
InvenTree/order/migrations/0028_auto_20200423_0956.py
Normal file
37
InvenTree/order/migrations/0028_auto_20200423_0956.py
Normal 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)]),
|
||||
),
|
||||
]
|
30
InvenTree/order/migrations/0029_auto_20200423_1042.py
Normal file
30
InvenTree/order/migrations/0029_auto_20200423_1042.py
Normal 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),
|
||||
),
|
||||
]
|
20
InvenTree/order/migrations/0030_auto_20200426_0551.py
Normal file
20
InvenTree/order/migrations/0030_auto_20200426_0551.py
Normal 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'),
|
||||
),
|
||||
]
|
20
InvenTree/order/migrations/0031_auto_20200426_0612.py
Normal file
20
InvenTree/order/migrations/0031_auto_20200426_0612.py
Normal 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'),
|
||||
),
|
||||
]
|
18
InvenTree/order/migrations/0032_auto_20200427_0044.py
Normal file
18
InvenTree/order/migrations/0032_auto_20200427_0044.py
Normal 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')},
|
||||
),
|
||||
]
|
@ -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()
|
||||
|
@ -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',
|
||||
]
|
||||
|
@ -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 %}",
|
||||
{
|
||||
|
@ -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 %}
|
@ -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>
|
||||
|
@ -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" %}
|
||||
|
||||
|
@ -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;
|
||||
},
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
|
@ -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>
|
||||
|
133
InvenTree/order/templates/order/sales_order_base.html
Normal file
133
InvenTree/order/templates/order/sales_order_base.html
Normal 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 %}
|
12
InvenTree/order/templates/order/sales_order_cancel.html
Normal file
12
InvenTree/order/templates/order/sales_order_cancel.html
Normal 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 %}
|
361
InvenTree/order/templates/order/sales_order_detail.html
Normal file
361
InvenTree/order/templates/order/sales_order_detail.html
Normal 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 %}
|
62
InvenTree/order/templates/order/sales_order_notes.html
Normal file
62
InvenTree/order/templates/order/sales_order_notes.html
Normal 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 %}
|
30
InvenTree/order/templates/order/sales_order_ship.html
Normal file
30
InvenTree/order/templates/order/sales_order_ship.html
Normal 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 %}
|
44
InvenTree/order/templates/order/sales_orders.html
Normal file
44
InvenTree/order/templates/order/sales_orders.html
Normal 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 %}
|
14
InvenTree/order/templates/order/so_allocation_delete.html
Normal file
14
InvenTree/order/templates/order/so_allocation_delete.html
Normal 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 %}
|
81
InvenTree/order/templates/order/so_attachments.html
Normal file
81
InvenTree/order/templates/order/so_attachments.html
Normal 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 %}
|
30
InvenTree/order/templates/order/so_builds.html
Normal file
30
InvenTree/order/templates/order/so_builds.html
Normal 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 %}
|
6
InvenTree/order/templates/order/so_lineitem_delete.html
Normal file
6
InvenTree/order/templates/order/so_lineitem_delete.html
Normal 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 %}
|
25
InvenTree/order/templates/order/so_tabs.html
Normal file
25
InvenTree/order/templates/order/so_tabs.html
Normal 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>
|
140
InvenTree/order/test_sales_order.py
Normal file
140
InvenTree/order/test_sales_order.py
Normal 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)
|
@ -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,))
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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)),
|
||||
]
|
||||
|
@ -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"
|
||||
|
@ -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),
|
||||
]
|
||||
|
@ -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')
|
||||
)
|
||||
|
@ -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):
|
||||
|
@ -18,7 +18,7 @@
|
||||
<td><a href="{% url 'build-detail' allocation.build.id %}">{{ allocation.build.title }}</a></td>
|
||||
<td>{{ allocation.build.quantity }} × <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>
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user