@@ -207,6 +216,22 @@ $('#new-po-line').click(function() {
});
});
+{% elif order.status == PurchaseOrderStatus.PLACED %}
+
+ $('#receive-selected-items').click(function() {
+ var items = $("#po-line-table").bootstrapTable('getSelections');
+
+ receivePurchaseOrderItems(
+ {{ order.id }},
+ items,
+ {
+ success: function() {
+ $("#po-line-table").bootstrapTable('refresh');
+ }
+ }
+ );
+ });
+
{% endif %}
loadPurchaseOrderLineItemTable('#po-line-table', {
diff --git a/InvenTree/order/templates/order/receive_parts.html b/InvenTree/order/templates/order/receive_parts.html
deleted file mode 100644
index 7b12101f7f..0000000000
--- a/InvenTree/order/templates/order/receive_parts.html
+++ /dev/null
@@ -1,81 +0,0 @@
-{% extends "modal_form.html" %}
-{% load i18n %}
-{% load inventree_extras %}
-{% load status_codes %}
-
-{% block form %}
-
-{% blocktrans with desc=order.description %}Receive outstanding parts for {{order}} - {{desc}}{% endblocktrans %}
-
-
-
-{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py
index 765c58cc3d..1f7905d1e3 100644
--- a/InvenTree/order/test_api.py
+++ b/InvenTree/order/test_api.py
@@ -251,7 +251,7 @@ class PurchaseOrderReceiveTest(OrderTest):
expected_code=400
).data
- self.assertIn('Line items must be provided', str(data['items']))
+ self.assertIn('Line items must be provided', str(data))
# No new stock items have been created
self.assertEqual(self.n, StockItem.objects.count())
diff --git a/InvenTree/order/test_views.py b/InvenTree/order/test_views.py
index 4b49b6c94e..220c1688db 100644
--- a/InvenTree/order/test_views.py
+++ b/InvenTree/order/test_views.py
@@ -10,7 +10,7 @@ from django.contrib.auth.models import Group
from InvenTree.status_codes import PurchaseOrderStatus
-from .models import PurchaseOrder, PurchaseOrderLineItem
+from .models import PurchaseOrder
import json
@@ -103,86 +103,3 @@ class POTests(OrderViewTestCase):
# Test that the order was actually placed
order = PurchaseOrder.objects.get(pk=1)
self.assertEqual(order.status, PurchaseOrderStatus.PLACED)
-
-
-class TestPOReceive(OrderViewTestCase):
- """ Tests for receiving a purchase order """
-
- def setUp(self):
- super().setUp()
-
- self.po = PurchaseOrder.objects.get(pk=1)
- self.po.status = PurchaseOrderStatus.PLACED
- self.po.save()
- self.url = reverse('po-receive', args=(1,))
-
- def post(self, data, validate=None):
-
- response = self.client.post(self.url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
-
- if validate is not None:
-
- data = json.loads(response.content)
-
- if validate:
- self.assertTrue(data['form_valid'])
- else:
- self.assertFalse(data['form_valid'])
-
- return response
-
- def test_get_dialog(self):
-
- data = {
- }
-
- self.client.get(self.url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
-
- def test_receive_lines(self):
-
- post_data = {
- }
-
- self.post(post_data, validate=False)
-
- # Try with an invalid location
- post_data['location'] = 12345
-
- self.post(post_data, validate=False)
-
- # Try with a valid location
- post_data['location'] = 1
-
- # Should fail due to invalid quantity
- self.post(post_data, validate=False)
-
- # Try to receive against an invalid line
- post_data['line-800'] = 100
-
- # Remove an invalid quantity of items
- post_data['line-1'] = '7x5q'
-
- self.post(post_data, validate=False)
-
- # Receive negative number
- post_data['line-1'] = -100
-
- self.post(post_data, validate=False)
-
- # Receive 75 items
- post_data['line-1'] = 75
-
- self.post(post_data, validate=True)
-
- line = PurchaseOrderLineItem.objects.get(pk=1)
-
- self.assertEqual(line.received, 75)
-
- # Receive 30 more items
- post_data['line-1'] = 30
-
- self.post(post_data, validate=True)
-
- line = PurchaseOrderLineItem.objects.get(pk=1)
-
- self.assertEqual(line.received, 105)
diff --git a/InvenTree/order/urls.py b/InvenTree/order/urls.py
index 2ce90f1f81..5ea9a56867 100644
--- a/InvenTree/order/urls.py
+++ b/InvenTree/order/urls.py
@@ -13,7 +13,6 @@ purchase_order_detail_urls = [
url(r'^cancel/', views.PurchaseOrderCancel.as_view(), name='po-cancel'),
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'^upload/', views.PurchaseOrderUpload.as_view(), name='po-upload'),
diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py
index e8b0dc03e9..8a5e709926 100644
--- a/InvenTree/order/views.py
+++ b/InvenTree/order/views.py
@@ -26,7 +26,7 @@ from .models import SalesOrderAllocation
from .admin import POLineItemResource
from build.models import Build
from company.models import Company, SupplierPart # ManufacturerPart
-from stock.models import StockItem, StockLocation
+from stock.models import StockItem
from part.models import Part
from common.models import InvenTreeSetting
@@ -42,7 +42,7 @@ from InvenTree.helpers import DownloadFile, str2bool
from InvenTree.helpers import extract_serial_numbers
from InvenTree.views import InvenTreeRoleMixin
-from InvenTree.status_codes import PurchaseOrderStatus, StockStatus
+from InvenTree.status_codes import PurchaseOrderStatus
logger = logging.getLogger("inventree")
@@ -468,202 +468,6 @@ class PurchaseOrderExport(AjaxView):
return DownloadFile(filedata, filename)
-class PurchaseOrderReceive(AjaxUpdateView):
- """ View for receiving parts which are outstanding against a PurchaseOrder.
-
- Any parts which are outstanding are listed.
- If all parts are marked as received, the order is closed out.
-
- """
-
- form_class = order_forms.ReceivePurchaseOrderForm
- ajax_form_title = _("Receive Parts")
- ajax_template_name = "order/receive_parts.html"
-
- # Specify role as we do not specify a Model against this view
- role_required = 'purchase_order.change'
-
- # Where the parts will be going (selected in POST request)
- destination = None
-
- def get_context_data(self):
-
- ctx = {
- 'order': self.order,
- 'lines': self.lines,
- 'stock_locations': StockLocation.objects.all(),
- }
-
- return ctx
-
- def get_lines(self):
- """
- Extract particular line items from the request,
- or default to *all* pending line items if none are provided
- """
-
- lines = None
-
- if 'line' in self.request.GET:
- line_id = self.request.GET.get('line')
-
- try:
- lines = PurchaseOrderLineItem.objects.filter(pk=line_id)
- except (PurchaseOrderLineItem.DoesNotExist, ValueError):
- pass
-
- # TODO - Option to pass multiple lines?
-
- # No lines specified - default selection
- if lines is None:
- lines = self.order.pending_line_items()
-
- return lines
-
- def get(self, request, *args, **kwargs):
- """ Respond to a GET request. Determines which parts are outstanding,
- and presents a list of these parts to the user.
- """
-
- self.request = request
- self.order = get_object_or_404(PurchaseOrder, pk=self.kwargs['pk'])
-
- self.lines = self.get_lines()
-
- for line in self.lines:
- # Pre-fill the remaining quantity
- line.receive_quantity = line.remaining()
-
- return self.renderJsonResponse(request, form=self.get_form())
-
- def post(self, request, *args, **kwargs):
- """ Respond to a POST request. Data checking and error handling.
- If the request is valid, new StockItem objects will be made
- for each received item.
- """
-
- self.request = request
- self.order = get_object_or_404(PurchaseOrder, pk=self.kwargs['pk'])
- errors = False
-
- self.lines = []
- self.destination = None
-
- msg = _("Items received")
-
- # Extract the destination for received parts
- if 'location' in request.POST:
- pk = request.POST['location']
- try:
- self.destination = StockLocation.objects.get(id=pk)
- except (StockLocation.DoesNotExist, ValueError):
- pass
-
- # Extract information on all submitted line items
- for item in request.POST:
- if item.startswith('line-'):
- pk = item.replace('line-', '')
-
- try:
- line = PurchaseOrderLineItem.objects.get(id=pk)
- except (PurchaseOrderLineItem.DoesNotExist, ValueError):
- continue
-
- # Check that the StockStatus was set
- status_key = 'status-{pk}'.format(pk=pk)
- status = request.POST.get(status_key, StockStatus.OK)
-
- try:
- status = int(status)
- except ValueError:
- status = StockStatus.OK
-
- if status in StockStatus.RECEIVING_CODES:
- line.status_code = status
- else:
- line.status_code = StockStatus.OK
-
- # Check the destination field
- line.destination = None
- if self.destination:
- # If global destination is set, overwrite line value
- line.destination = self.destination
- else:
- destination_key = f'destination-{pk}'
- destination = request.POST.get(destination_key, None)
-
- if destination:
- try:
- line.destination = StockLocation.objects.get(pk=destination)
- except (StockLocation.DoesNotExist, ValueError):
- pass
-
- # Check that line matches the order
- if not line.order == self.order:
- # TODO - Display a non-field error?
- continue
-
- # Ignore a part that doesn't map to a SupplierPart
- try:
- if line.part is None:
- continue
- except SupplierPart.DoesNotExist:
- continue
-
- receive = self.request.POST[item]
-
- try:
- receive = Decimal(receive)
- except InvalidOperation:
- # In the case on an invalid input, reset to default
- receive = line.remaining()
- msg = _("Error converting quantity to number")
- errors = True
-
- if receive < 0:
- receive = 0
- errors = True
- msg = _("Receive quantity less than zero")
-
- line.receive_quantity = receive
- self.lines.append(line)
-
- if len(self.lines) == 0:
- msg = _("No lines specified")
- errors = True
-
- # No errors? Receive the submitted parts!
- if errors is False:
- self.receive_parts()
-
- data = {
- 'form_valid': errors is False,
- 'success': msg,
- }
-
- return self.renderJsonResponse(request, data=data, form=self.get_form())
-
- @transaction.atomic
- def receive_parts(self):
- """ Called once the form has been validated.
- Create new stockitems against received parts.
- """
-
- for line in self.lines:
-
- if not line.part:
- continue
-
- self.order.receive_line_item(
- line,
- line.destination,
- line.receive_quantity,
- self.request.user,
- status=line.status_code,
- purchase_price=line.purchase_price,
- )
-
-
class OrderParts(AjaxView):
""" View for adding various SupplierPart items to a Purchase Order.
diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py
index 060faf8b0d..4f1ba8cc8b 100644
--- a/InvenTree/part/serializers.py
+++ b/InvenTree/part/serializers.py
@@ -193,6 +193,7 @@ class PartBriefSerializer(InvenTreeModelSerializer):
fields = [
'pk',
'IPN',
+ 'default_location',
'name',
'revision',
'full_name',
diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index 463c3c9ae2..aa68b26dd4 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -973,7 +973,6 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
`;
-
constructForm(`/api/build/${build_id}/allocate/`, {
method: 'POST',
fields: {},
diff --git a/InvenTree/templates/js/translated/filters.js b/InvenTree/templates/js/translated/filters.js
index d7e8f45ca5..3e41003696 100644
--- a/InvenTree/templates/js/translated/filters.js
+++ b/InvenTree/templates/js/translated/filters.js
@@ -273,6 +273,11 @@ function setupFilterList(tableKey, table, target) {
var element = $(target);
+ if (!element) {
+ console.log(`WARNING: setupFilterList could not find target '${target}'`);
+ return;
+ }
+
// One blank slate, please
element.empty();
diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js
index bffb42403b..b43ce0cb2d 100644
--- a/InvenTree/templates/js/translated/forms.js
+++ b/InvenTree/templates/js/translated/forms.js
@@ -1515,6 +1515,7 @@ function initializeChoiceField(field, fields, options) {
select.select2({
dropdownAutoWidth: false,
dropdownParent: $(options.modal),
+ width: '100%',
});
}
diff --git a/InvenTree/templates/js/translated/model_renderers.js b/InvenTree/templates/js/translated/model_renderers.js
index 3136ebee29..0c3dabc27e 100644
--- a/InvenTree/templates/js/translated/model_renderers.js
+++ b/InvenTree/templates/js/translated/model_renderers.js
@@ -112,7 +112,13 @@ function renderStockLocation(name, data, parameters, options) {
var html = `
${level}${data.pathstring}`;
- if (data.description) {
+ var render_description = true;
+
+ if ('render_description' in parameters) {
+ render_description = parameters['render_description'];
+ }
+
+ if (render_description && data.description) {
html += ` -
${data.description}`;
}
diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js
index 532ab81655..43d4b56936 100644
--- a/InvenTree/templates/js/translated/order.js
+++ b/InvenTree/templates/js/translated/order.js
@@ -12,6 +12,7 @@
loadTableFilters,
makeIconBadge,
purchaseOrderStatusDisplay,
+ receivePurchaseOrderItems,
renderLink,
salesOrderStatusDisplay,
setupFilterList,
@@ -234,6 +235,291 @@ function newPurchaseOrderFromOrderWizard(e) {
});
}
+
+/**
+ * Receive stock items against a PurchaseOrder
+ * Uses the POReceive API endpoint
+ *
+ * arguments:
+ * - order_id, ID / PK for the PurchaseOrder instance
+ * - line_items: A list of PurchaseOrderLineItems objects to be allocated
+ *
+ * options:
+ * -
+ */
+function receivePurchaseOrderItems(order_id, line_items, options={}) {
+
+ if (line_items.length == 0) {
+ showAlertDialog(
+ '{% trans "Select Line Items" %}',
+ '{% trans "At least one line item must be selected" %}',
+ );
+ return;
+ }
+
+ function renderLineItem(line_item, opts={}) {
+
+ var pk = line_item.pk;
+
+ // Part thumbnail + description
+ var thumb = thumbnailImage(line_item.part_detail.thumbnail);
+
+ var quantity = (line_item.quantity || 0) - (line_item.received || 0);
+
+ if (quantity < 0) {
+ quantity = 0;
+ }
+
+ // Quantity to Receive
+ var quantity_input = constructField(
+ `items_quantity_${pk}`,
+ {
+ type: 'decimal',
+ min_value: 0,
+ value: quantity,
+ title: '{% trans "Quantity to receive" %}',
+ required: true,
+ },
+ {
+ hideLabels: true,
+ }
+ );
+
+ // Construct list of StockItem status codes
+ var choices = [];
+
+ for (var key in stockCodes) {
+ choices.push({
+ value: key,
+ display_name: stockCodes[key].value,
+ });
+ }
+
+ var destination_input = constructField(
+ `items_location_${pk}`,
+ {
+ type: 'related field',
+ label: '{% trans "Location" %}',
+ required: false,
+ },
+ {
+ hideLabels: true,
+ }
+ );
+
+ var status_input = constructField(
+ `items_status_${pk}`,
+ {
+ type: 'choice',
+ label: '{% trans "Stock Status" %}',
+ required: true,
+ choices: choices,
+ value: 10, // OK
+ },
+ {
+ hideLabels: true,
+ }
+ );
+
+ // Button to remove the row
+ var delete_button = `
`;
+
+ delete_button += makeIconButton(
+ 'fa-times icon-red',
+ 'button-row-remove',
+ pk,
+ '{% trans "Remove row" %}',
+ );
+
+ delete_button += '
';
+
+ var html = `
+
+
+ ${thumb} ${line_item.part_detail.full_name}
+ |
+
+ ${line_item.supplier_part_detail.SKU}
+ |
+
+ ${line_item.quantity}
+ |
+
+ ${line_item.received}
+ |
+
+ ${quantity_input}
+ |
+
+ ${status_input}
+ |
+
+ ${destination_input}
+ |
+
+ ${delete_button}
+ |
+
`;
+
+ return html;
+ }
+
+ var table_entries = '';
+
+ line_items.forEach(function(item) {
+ table_entries += renderLineItem(item);
+ });
+
+ var html = ``;
+
+ // Add table
+ html += `
+
+
+
+ {% trans "Part" %} |
+ {% trans "Order Code" %} |
+ {% trans "Ordered" %} |
+ {% trans "Received" %} |
+ {% trans "Receive" %} |
+ {% trans "Status" %} |
+ {% trans "Destination" %} |
+ |
+
+
+
+ ${table_entries}
+
+
+ `;
+
+ constructForm(`/api/order/po/${order_id}/receive/`, {
+ method: 'POST',
+ fields: {
+ location: {},
+ },
+ preFormContent: html,
+ confirm: true,
+ confirmMessage: '{% trans "Confirm receipt of items" %}',
+ title: '{% trans "Receive Purchase Order Items" %}',
+ afterRender: function(fields, opts) {
+ // Initialize the "destination" field for each item
+ line_items.forEach(function(item) {
+
+ var pk = item.pk;
+
+ var name = `items_location_${pk}`;
+
+ var field_details = {
+ name: name,
+ api_url: '{% url "api-location-list" %}',
+ filters: {
+
+ },
+ type: 'related field',
+ model: 'stocklocation',
+ required: false,
+ auto_fill: false,
+ value: item.destination || item.part_detail.default_location,
+ render_description: false,
+ };
+
+ initializeRelatedField(
+ field_details,
+ null,
+ opts,
+ );
+
+ addClearCallback(
+ name,
+ field_details,
+ opts
+ );
+
+ initializeChoiceField(
+ {
+ name: `items_status_${pk}`,
+ },
+ null,
+ opts
+ );
+ });
+
+ // Add callbacks to remove rows
+ $(opts.modal).find('.button-row-remove').click(function() {
+ var pk = $(this).attr('pk');
+
+ $(opts.modal).find(`#receive_row_${pk}`).remove();
+ });
+ },
+ onSubmit: function(fields, opts) {
+ // Extract data elements from the form
+ var data = {
+ items: [],
+ location: getFormFieldValue('location', {}, opts),
+ };
+
+ var item_pk_values = [];
+
+ line_items.forEach(function(item) {
+
+ var pk = item.pk;
+
+ var quantity = getFormFieldValue(`items_quantity_${pk}`, {}, opts);
+
+ var status = getFormFieldValue(`items_status_${pk}`, {}, opts);
+
+ var location = getFormFieldValue(`items_location_${pk}`, {}, opts);
+
+ if (quantity != null) {
+ data.items.push({
+ line_item: pk,
+ quantity: quantity,
+ status: status,
+ location: location,
+ });
+
+ item_pk_values.push(pk);
+ }
+
+ });
+
+ // Provide list of nested values
+ opts.nested = {
+ 'items': item_pk_values,
+ };
+
+ inventreePut(
+ opts.url,
+ data,
+ {
+ method: 'POST',
+ success: function(response) {
+ // Hide the modal
+ $(opts.modal).modal('hide');
+
+ if (options.success) {
+ options.success(response);
+ }
+ },
+ error: function(xhr) {
+ switch (xhr.status) {
+ case 400:
+ handleFormErrors(xhr.responseJSON, fields, opts);
+ break;
+ default:
+ $(opts.modal).modal('hide');
+ showApiError(xhr);
+ break;
+ }
+ }
+ }
+ );
+ }
+ });
+}
+
+
function editPurchaseOrderLineItem(e) {
/* Edit a purchase order line item in a modal form.
@@ -280,12 +566,10 @@ function loadPurchaseOrderTable(table, options) {
filters[key] = options.params[key];
}
- options.url = options.url || '{% url "api-po-list" %}';
-
setupFilterList('purchaseorder', $(table));
$(table).inventreeTable({
- url: options.url,
+ url: '{% url "api-po-list" %}',
queryParams: filters,
name: 'purchaseorder',
groupBy: false,
@@ -379,6 +663,21 @@ function loadPurchaseOrderTable(table, options) {
*/
function loadPurchaseOrderLineItemTable(table, options={}) {
+ options.params = options.params || {};
+
+ options.params['order'] = options.order;
+ options.params['part_detail'] = true;
+
+ var filters = loadTableFilters('purchaseorderlineitem');
+
+ for (var key in options.params) {
+ filters[key] = options.params[key];
+ }
+
+ var target = options.filter_target || '#filter-list-purchase-order-lines';
+
+ setupFilterList('purchaseorderlineitem', $(table), target);
+
function setupCallbacks() {
if (options.allow_edit) {
$(table).find('.button-line-edit').click(function() {
@@ -424,22 +723,24 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
$(table).find('.button-line-receive').click(function() {
var pk = $(this).attr('pk');
- launchModalForm(`/order/purchase-order/${options.order}/receive/`, {
- success: function() {
- $(table).bootstrapTable('refresh');
- },
- data: {
- line: pk,
- },
- secondary: [
- {
- field: 'location',
- label: '{% trans "New Location" %}',
- title: '{% trans "Create new stock location" %}',
- url: '{% url "stock-location-create" %}',
- },
- ]
- });
+ var line_item = $(table).bootstrapTable('getRowByUniqueId', pk);
+
+ if (!line_item) {
+ console.log('WARNING: getRowByUniqueId returned null');
+ return;
+ }
+
+ receivePurchaseOrderItems(
+ options.order,
+ [
+ line_item,
+ ],
+ {
+ success: function() {
+ $(table).bootstrapTable('refresh');
+ }
+ }
+ );
});
}
}
@@ -451,17 +752,15 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
formatNoMatches: function() {
return '{% trans "No line items found" %}';
},
- queryParams: {
- order: options.order,
- part_detail: true
- },
+ queryParams: filters,
+ original: options.params,
url: '{% url "api-po-line-list" %}',
showFooter: true,
+ uniqueId: 'pk',
columns: [
{
- field: 'pk',
- title: 'ID',
- visible: false,
+ checkbox: true,
+ visible: true,
switchable: false,
},
{
@@ -618,7 +917,7 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
}
if (options.allow_receive && row.received < row.quantity) {
- html += makeIconButton('fa-clipboard-check', 'button-line-receive', pk, '{% trans "Receive line item" %}');
+ html += makeIconButton('fa-sign-in-alt', 'button-line-receive', pk, '{% trans "Receive line item" %}');
}
html += `
`;
diff --git a/InvenTree/templates/js/translated/table_filters.js b/InvenTree/templates/js/translated/table_filters.js
index b94bc324c7..4d12f69780 100644
--- a/InvenTree/templates/js/translated/table_filters.js
+++ b/InvenTree/templates/js/translated/table_filters.js
@@ -274,7 +274,16 @@ function getAvailableTableFilters(tableKey) {
};
}
- // Filters for the "Order" table
+ // Filters for PurchaseOrderLineItem table
+ if (tableKey == 'purchaseorderlineitem') {
+ return {
+ completed: {
+ type: 'bool',
+ title: '{% trans "Completed" %}',
+ },
+ };
+ }
+ // Filters for the PurchaseOrder table
if (tableKey == 'purchaseorder') {
return {