diff --git a/InvenTree/InvenTree/api.py b/InvenTree/InvenTree/api.py index eb45bb94f3..c9b5502546 100644 --- a/InvenTree/InvenTree/api.py +++ b/InvenTree/InvenTree/api.py @@ -242,6 +242,7 @@ class APISearchView(APIView): 'part': part.api.PartList, 'partcategory': part.api.CategoryList, 'purchaseorder': order.api.PurchaseOrderList, + 'returnorder': order.api.ReturnOrderList, 'salesorder': order.api.SalesOrderList, 'stockitem': stock.api.StockList, 'stocklocation': stock.api.StockLocationList, diff --git a/InvenTree/InvenTree/api_tester.py b/InvenTree/InvenTree/api_tester.py index c2da19c5eb..75e41a6bae 100644 --- a/InvenTree/InvenTree/api_tester.py +++ b/InvenTree/InvenTree/api_tester.py @@ -165,6 +165,26 @@ class ExchangeRateMixin: class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase): """Base class for running InvenTree API tests.""" + def checkResponse(self, url, method, expected_code, response): + """Debug output for an unexpected response""" + + # No expected code, return + if expected_code is None: + return + + if expected_code != response.status_code: + + print(f"Unexpected {method} response at '{url}': status_code = {response.status_code}") + + if hasattr(response, 'data'): + print('data:', response.data) + if hasattr(response, 'body'): + print('body:', response.body) + if hasattr(response, 'content'): + print('content:', response.content) + + self.assertEqual(expected_code, response.status_code) + def getActions(self, url): """Return a dict of the 'actions' available at a given endpoint. @@ -188,19 +208,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase): response = self.client.get(url, data, format=format) - if expected_code is not None: - - if response.status_code != expected_code: - print(f"Unexpected response at '{url}': status_code = {response.status_code}") - - if hasattr(response, 'data'): - print('data:', response.data) - if hasattr(response, 'body'): - print('body:', response.body) - if hasattr(response, 'content'): - print('content:', response.content) - - self.assertEqual(response.status_code, expected_code) + self.checkResponse(url, 'GET', expected_code, response) return response @@ -213,17 +221,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase): response = self.client.post(url, data=data, format=format) - if expected_code is not None: - - if response.status_code != expected_code: - print(f"Unexpected response at '{url}': status code = {response.status_code}") - - if hasattr(response, 'data'): - print(response.data) - else: - print(f"(response object {type(response)} has no 'data' attribute") - - self.assertEqual(response.status_code, expected_code) + self.checkResponse(url, 'POST', expected_code, response) return response @@ -235,8 +233,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase): response = self.client.delete(url, data=data, format=format) - if expected_code is not None: - self.assertEqual(response.status_code, expected_code) + self.checkResponse(url, 'DELETE', expected_code, response) return response @@ -244,8 +241,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase): """Issue a PATCH request.""" response = self.client.patch(url, data=data, format=format) - if expected_code is not None: - self.assertEqual(response.status_code, expected_code) + self.checkResponse(url, 'PATCH', expected_code, response) return response @@ -253,13 +249,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase): """Issue a PUT request.""" response = self.client.put(url, data=data, format=format) - if expected_code is not None: - - if response.status_code != expected_code: - print(f"Unexpected response at '{url}':") - print(response.data) - - self.assertEqual(response.status_code, expected_code) + self.checkResponse(url, 'PUT', expected_code, response) return response @@ -267,8 +257,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase): """Issue an OPTIONS request.""" response = self.client.options(url, format='json') - if expected_code is not None: - self.assertEqual(response.status_code, expected_code) + self.checkResponse(url, 'OPTIONS', expected_code, response) return response diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 19e3ba5e64..40ce5d04e3 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -2,16 +2,21 @@ # InvenTree API version -INVENTREE_API_VERSION = 103 +INVENTREE_API_VERSION = 104 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v104 -> 2023-03-23 : https://github.com/inventree/InvenTree/pull/4488 + - Adds various endpoints for new "ReturnOrder" models + - Adds various endpoints for new "ReturnOrderReport" templates + - Exposes API endpoints for "Contact" model + v103 -> 2023-03-17 : https://github.com/inventree/InvenTree/pull/4410 - Add metadata to several more models v102 -> 2023-03-18 : https://github.com/inventree/InvenTree/pull/4505 - - Adds global search API endpoint for consolidated search results +- Adds global search API endpoint for consolidated search results v101 -> 2023-03-07 : https://github.com/inventree/InvenTree/pull/4462 - Adds 'total_in_stock' to Part serializer, and supports API ordering diff --git a/InvenTree/InvenTree/context.py b/InvenTree/InvenTree/context.py index 6c82137d3b..f6cb54af7f 100644 --- a/InvenTree/InvenTree/context.py +++ b/InvenTree/InvenTree/context.py @@ -4,6 +4,7 @@ import InvenTree.status from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus, + ReturnOrderLineStatus, ReturnOrderStatus, SalesOrderStatus, StockHistoryCode, StockStatus) from users.models import RuleSet, check_user_role @@ -58,6 +59,8 @@ def status_codes(request): return { # Expose the StatusCode classes to the templates + 'ReturnOrderStatus': ReturnOrderStatus, + 'ReturnOrderLineStatus': ReturnOrderLineStatus, 'SalesOrderStatus': SalesOrderStatus, 'PurchaseOrderStatus': PurchaseOrderStatus, 'BuildStatus': BuildStatus, diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index 1a7692eb74..8fabbdc0aa 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -1123,7 +1123,10 @@ def render_currency(money, decimal_places=None, currency=None, include_symbol=Tr min_decimal_places: The minimum number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES_MIN setting. """ - if money is None or money.amount is None: + if money in [None, '']: + return '-' + + if type(money) is not Money: return '-' if currency is not None: diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index cedc42beaf..cd9b5747d3 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -315,9 +315,7 @@ main { } .filter-button { - padding: 2px; - padding-left: 4px; - padding-right: 4px; + padding: 6px; } .filter-input { diff --git a/InvenTree/InvenTree/static/script/inventree/inventree.js b/InvenTree/InvenTree/static/script/inventree/inventree.js index 0f06ea98ac..db12de9d9d 100644 --- a/InvenTree/InvenTree/static/script/inventree/inventree.js +++ b/InvenTree/InvenTree/static/script/inventree/inventree.js @@ -155,30 +155,37 @@ function inventreeDocReady() { } +/* + * Determine if a transfer (e.g. drag-and-drop) is a file transfer + */ function isFileTransfer(transfer) { - /* Determine if a transfer (e.g. drag-and-drop) is a file transfer - */ - return transfer.files.length > 0; } -function enableDragAndDrop(element, url, options) { - /* Enable drag-and-drop file uploading for a given element. +/* Enable drag-and-drop file uploading for a given element. - Params: - element - HTML element lookup string e.g. "#drop-div" - url - URL to POST the file to - options - object with following possible values: - label - Label of the file to upload (default='file') - data - Other form data to upload - success - Callback function in case of success - error - Callback function in case of error - method - HTTP method - */ +Params: + element - HTML element lookup string e.g. "#drop-div" + url - URL to POST the file to + options - object with following possible values: + label - Label of the file to upload (default='file') + data - Other form data to upload + success - Callback function in case of success + error - Callback function in case of error + method - HTTP method +*/ +function enableDragAndDrop(elementId, url, options={}) { var data = options.data || {}; + let element = $(elementId); + + if (!element.exists()) { + console.error(`enableDragAndDrop called with invalid target: '${elementId}'`); + return; + } + $(element).on('drop', function(event) { var transfer = event.originalEvent.dataTransfer; @@ -200,6 +207,11 @@ function enableDragAndDrop(element, url, options) { formData, { success: function(data, status, xhr) { + // Reload a table + if (options.refreshTable) { + reloadBootstrapTable(options.refreshTable); + } + if (options.success) { options.success(data, status, xhr); } diff --git a/InvenTree/InvenTree/status_codes.py b/InvenTree/InvenTree/status_codes.py index 73684817e8..edee876c5c 100644 --- a/InvenTree/InvenTree/status_codes.py +++ b/InvenTree/InvenTree/status_codes.py @@ -247,10 +247,14 @@ class StockHistoryCode(StatusCode): BUILD_CONSUMED = 57 # Sales order codes + SHIPPED_AGAINST_SALES_ORDER = 60 # Purchase order codes RECEIVED_AGAINST_PURCHASE_ORDER = 70 + # Return order codes + RETURNED_AGAINST_RETURN_ORDER = 80 + # Customer actions SENT_TO_CUSTOMER = 100 RETURNED_FROM_CUSTOMER = 105 @@ -289,8 +293,11 @@ class StockHistoryCode(StatusCode): BUILD_OUTPUT_COMPLETED: _('Build order output completed'), BUILD_CONSUMED: _('Consumed by build order'), - RECEIVED_AGAINST_PURCHASE_ORDER: _('Received against purchase order') + SHIPPED_AGAINST_SALES_ORDER: _("Shipped against Sales Order"), + RECEIVED_AGAINST_PURCHASE_ORDER: _('Received against Purchase Order'), + + RETURNED_AGAINST_RETURN_ORDER: _('Returned against Return Order'), } @@ -320,3 +327,74 @@ class BuildStatus(StatusCode): PENDING, PRODUCTION, ] + + +class ReturnOrderStatus(StatusCode): + """Defines a set of status codes for a ReturnOrder""" + + # Order is pending, waiting for receipt of items + PENDING = 10 + + # Items have been received, and are being inspected + IN_PROGRESS = 20 + + COMPLETE = 30 + CANCELLED = 40 + + OPEN = [ + PENDING, + IN_PROGRESS, + ] + + options = { + PENDING: _("Pending"), + IN_PROGRESS: _("In Progress"), + COMPLETE: _("Complete"), + CANCELLED: _("Cancelled"), + } + + colors = { + PENDING: 'secondary', + IN_PROGRESS: 'primary', + COMPLETE: 'success', + CANCELLED: 'danger', + } + + +class ReturnOrderLineStatus(StatusCode): + """Defines a set of status codes for a ReturnOrderLineItem""" + + PENDING = 10 + + # Item is to be returned to customer, no other action + RETURN = 20 + + # Item is to be repaired, and returned to customer + REPAIR = 30 + + # Item is to be replaced (new item shipped) + REPLACE = 40 + + # Item is to be refunded (cannot be repaired) + REFUND = 50 + + # Item is rejected + REJECT = 60 + + options = { + PENDING: _('Pending'), + RETURN: _('Return'), + REPAIR: _('Repair'), + REFUND: _('Refund'), + REPLACE: _('Replace'), + REJECT: _('Reject') + } + + colors = { + PENDING: 'secondary', + RETURN: 'success', + REPAIR: 'primary', + REFUND: 'info', + REPLACE: 'warning', + REJECT: 'danger', + } diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index 047485db6d..ec2aeb13f2 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -112,9 +112,13 @@ translated_javascript_urls = [ re_path(r'^modals.js', DynamicJsView.as_view(template_name='js/translated/modals.js'), name='modals.js'), re_path(r'^order.js', DynamicJsView.as_view(template_name='js/translated/order.js'), name='order.js'), re_path(r'^part.js', DynamicJsView.as_view(template_name='js/translated/part.js'), name='part.js'), + re_path(r'^purchase_order.js', DynamicJsView.as_view(template_name='js/translated/purchase_order.js'), name='purchase_order.js'), + re_path(r'^return_order.js', DynamicJsView.as_view(template_name='js/translated/return_order.js'), name='return_order.js'), re_path(r'^report.js', DynamicJsView.as_view(template_name='js/translated/report.js'), name='report.js'), + re_path(r'^sales_order.js', DynamicJsView.as_view(template_name='js/translated/sales_order.js'), name='sales_order.js'), re_path(r'^search.js', DynamicJsView.as_view(template_name='js/translated/search.js'), name='search.js'), re_path(r'^stock.js', DynamicJsView.as_view(template_name='js/translated/stock.js'), name='stock.js'), + re_path(r'^status_codes.js', DynamicJsView.as_view(template_name='js/translated/status_codes.js'), name='status_codes.js'), re_path(r'^plugin.js', DynamicJsView.as_view(template_name='js/translated/plugin.js'), name='plugin.js'), re_path(r'^pricing.js', DynamicJsView.as_view(template_name='js/translated/pricing.js'), name='pricing.js'), re_path(r'^news.js', DynamicJsView.as_view(template_name='js/translated/news.js'), name='news.js'), diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index f0fd607370..f4f7bad41b 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -1,6 +1,6 @@ """JSON API for the Build app.""" -from django.urls import include, re_path +from django.urls import include, path, re_path from django.utils.translation import gettext_lazy as _ from django.contrib.auth.models import User @@ -509,13 +509,13 @@ build_api_urls = [ # Attachments re_path(r'^attachment/', include([ - re_path(r'^(?P\d+)/', BuildAttachmentDetail.as_view(), name='api-build-attachment-detail'), + path(r'/', BuildAttachmentDetail.as_view(), name='api-build-attachment-detail'), re_path(r'^.*$', BuildAttachmentList.as_view(), name='api-build-attachment-list'), ])), # Build Items re_path(r'^item/', include([ - re_path(r'^(?P\d+)/', include([ + path(r'/', include([ re_path(r'^metadata/', BuildItemMetadata.as_view(), name='api-build-item-metadata'), re_path(r'^.*$', BuildItemDetail.as_view(), name='api-build-item-detail'), ])), @@ -523,7 +523,7 @@ build_api_urls = [ ])), # Build Detail - re_path(r'^(?P\d+)/', include([ + path(r'/', include([ re_path(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'), re_path(r'^auto-allocate/', BuildAutoAllocate.as_view(), name='api-build-auto-allocate'), re_path(r'^complete/', BuildOutputComplete.as_view(), name='api-build-output-complete'), diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 48509edfdd..a56b69978d 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -36,9 +36,10 @@ from plugin.events import trigger_event from plugin.models import MetadataMixin import common.notifications -from part import models as PartModels -from stock import models as StockModels -from users import models as UserModels + +import part.models +import stock.models +import users.models class Build(MPTTModel, MetadataMixin, ReferenceIndexingMixin): @@ -279,7 +280,7 @@ class Build(MPTTModel, MetadataMixin, ReferenceIndexingMixin): ) responsible = models.ForeignKey( - UserModels.Owner, + users.models.Owner, on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_('Responsible'), @@ -395,9 +396,9 @@ class Build(MPTTModel, MetadataMixin, ReferenceIndexingMixin): if in_stock is not None: if in_stock: - outputs = outputs.filter(StockModels.StockItem.IN_STOCK_FILTER) + outputs = outputs.filter(stock.models.StockItem.IN_STOCK_FILTER) else: - outputs = outputs.exclude(StockModels.StockItem.IN_STOCK_FILTER) + outputs = outputs.exclude(stock.models.StockItem.IN_STOCK_FILTER) # Filter by 'complete' status complete = kwargs.get('complete', None) @@ -659,7 +660,7 @@ class Build(MPTTModel, MetadataMixin, ReferenceIndexingMixin): else: serial = None - output = StockModels.StockItem.objects.create( + output = stock.models.StockItem.objects.create( quantity=1, location=location, part=self.part, @@ -677,11 +678,11 @@ class Build(MPTTModel, MetadataMixin, ReferenceIndexingMixin): parts = bom_item.get_valid_parts_for_allocation() - items = StockModels.StockItem.objects.filter( + items = stock.models.StockItem.objects.filter( part__in=parts, serial=str(serial), quantity=1, - ).filter(StockModels.StockItem.IN_STOCK_FILTER) + ).filter(stock.models.StockItem.IN_STOCK_FILTER) """ Test if there is a matching serial number! @@ -701,7 +702,7 @@ class Build(MPTTModel, MetadataMixin, ReferenceIndexingMixin): else: """Create a single build output of the given quantity.""" - StockModels.StockItem.objects.create( + stock.models.StockItem.objects.create( quantity=quantity, location=location, part=self.part, @@ -877,7 +878,7 @@ class Build(MPTTModel, MetadataMixin, ReferenceIndexingMixin): ) # Look for available stock items - available_stock = StockModels.StockItem.objects.filter(StockModels.StockItem.IN_STOCK_FILTER) + available_stock = stock.models.StockItem.objects.filter(stock.models.StockItem.IN_STOCK_FILTER) # Filter by list of available parts available_stock = available_stock.filter( @@ -1220,7 +1221,7 @@ class BuildItem(MetadataMixin, models.Model): 'quantity': _('Quantity must be 1 for serialized stock') }) - except (StockModels.StockItem.DoesNotExist, PartModels.Part.DoesNotExist): + except (stock.models.StockItem.DoesNotExist, part.models.Part.DoesNotExist): pass """ @@ -1259,8 +1260,8 @@ class BuildItem(MetadataMixin, models.Model): for idx, ancestor in enumerate(ancestors): try: - bom_item = PartModels.BomItem.objects.get(part=self.build.part, sub_part=ancestor) - except PartModels.BomItem.DoesNotExist: + bom_item = part.models.BomItem.objects.get(part=self.build.part, sub_part=ancestor) + except part.models.BomItem.DoesNotExist: continue # A matching BOM item has been found! @@ -1350,7 +1351,7 @@ class BuildItem(MetadataMixin, models.Model): # Internal model which links part <-> sub_part # We need to track this separately, to allow for "variant' stock bom_item = models.ForeignKey( - PartModels.BomItem, + part.models.BomItem, on_delete=models.CASCADE, related_name='allocate_build_items', blank=True, null=True, diff --git a/InvenTree/build/templates/build/build_base.html b/InvenTree/build/templates/build/build_base.html index 01aae6e573..a372a8cb2f 100644 --- a/InvenTree/build/templates/build/build_base.html +++ b/InvenTree/build/templates/build/build_base.html @@ -247,7 +247,11 @@ src="{% static 'img/blank_image.png' %}" {% if report_enabled %} $('#print-build-report').click(function() { - printBuildReports([{{ build.pk }}]); + printReports({ + items: [{{ build.pk }}], + key: 'build', + url: '{% url "api-build-report-list" %}', + }); }); {% endif %} diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index d7999cec76..66933629a8 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -268,19 +268,6 @@ {% endif %} - - - - {% include "filter_list.html" with id='incompletebuilditems' %} {% endif %} @@ -367,20 +354,6 @@ onPanelLoad('children', function() { onPanelLoad('attachments', function() { - enableDragAndDrop( - '#attachment-dropzone', - '{% url "api-build-attachment-list" %}', - { - data: { - build: {{ build.id }}, - }, - label: 'attachment', - success: function(data, status, xhr) { - $('#attachment-table').bootstrapTable('refresh'); - } - } - ); - loadAttachmentTable('{% url "api-build-attachment-list" %}', { filters: { build: {{ build.pk }}, @@ -409,10 +382,6 @@ onPanelLoad('notes', function() { ); }); -function reloadTable() { - $('#allocation-table-untracked').bootstrapTable('refresh'); -} - onPanelLoad('outputs', function() { {% if build.active %} diff --git a/InvenTree/build/templates/build/index.html b/InvenTree/build/templates/build/index.html index 4d1476f959..6f08348f44 100644 --- a/InvenTree/build/templates/build/index.html +++ b/InvenTree/build/templates/build/index.html @@ -26,20 +26,6 @@
- - {% if report_enabled %} - - {% endif %} {% include "filter_list.html" with id="build" %}
@@ -62,17 +48,4 @@ loadBuildTable($("#build-table"), { locale: '{{ request.LANGUAGE_CODE }}', }); -{% if report_enabled %} -$('#multi-build-print').click(function() { - var rows = getTableData("#build-table"); - var build_ids = []; - - rows.forEach(function(row) { - build_ids.push(row.pk); - }); - - printBuildReports(build_ids); -}); -{% endif %} - {% endblock %} diff --git a/InvenTree/build/urls.py b/InvenTree/build/urls.py index 0b33c7d78e..6dab501239 100644 --- a/InvenTree/build/urls.py +++ b/InvenTree/build/urls.py @@ -1,13 +1,13 @@ """URL lookup for Build app.""" -from django.urls import include, re_path +from django.urls import include, path, re_path from . import views build_urls = [ - re_path(r'^(?P\d+)/', include([ + path(r'/', include([ re_path(r'^.*$', views.BuildDetail.as_view(), name='build-detail'), ])), diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py index 208ca12d26..382197308b 100644 --- a/InvenTree/common/api.py +++ b/InvenTree/common/api.py @@ -457,7 +457,7 @@ settings_api_urls = [ # Notification settings re_path(r'^notification/', include([ # Notification Settings Detail - re_path(r'^(?P\d+)/', NotificationUserSettingsDetail.as_view(), name='api-notification-setting-detail'), + path(r'/', NotificationUserSettingsDetail.as_view(), name='api-notification-setting-detail'), # Notification Settings List re_path(r'^.*$', NotificationUserSettingsList.as_view(), name='api-notifcation-setting-list'), @@ -486,7 +486,7 @@ common_api_urls = [ # Notifications re_path(r'^notifications/', include([ # Individual purchase order detail URLs - re_path(r'^(?P\d+)/', include([ + path(r'/', include([ re_path(r'.*$', NotificationDetail.as_view(), name='api-notifications-detail'), ])), # Read all @@ -498,7 +498,7 @@ common_api_urls = [ # News re_path(r'^news/', include([ - re_path(r'^(?P\d+)/', include([ + path(r'/', include([ re_path(r'.*$', NewsFeedEntryDetail.as_view(), name='api-news-detail'), ])), re_path(r'^.*$', NewsFeedEntryList.as_view(), name='api-news-list'), diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 8cd74527f2..c2ee055327 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -1441,6 +1441,27 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'validator': build.validators.validate_build_order_reference_pattern, }, + 'RETURNORDER_ENABLED': { + 'name': _('Enable Return Orders'), + 'description': _('Enable return order functionality in the user interface'), + 'validator': bool, + 'default': False, + }, + + 'RETURNORDER_REFERENCE_PATTERN': { + 'name': _('Return Order Reference Pattern'), + 'description': _('Required pattern for generating Return Order reference field'), + 'default': 'RMA-{ref:04d}', + 'validator': order.validators.validate_return_order_reference_pattern, + }, + + 'RETURNORDER_EDIT_COMPLETED_ORDERS': { + 'name': _('Edit Completed Return Orders'), + 'description': _('Allow editing of return orders after they have been completed'), + 'default': False, + 'validator': bool, + }, + 'SALESORDER_REFERENCE_PATTERN': { 'name': _('Sales Order Reference Pattern'), 'description': _('Required pattern for generating Sales Order reference field'), @@ -1937,6 +1958,20 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): 'default': True, }, + 'SEARCH_PREVIEW_SHOW_RETURN_ORDERS': { + 'name': _('Search Return Orders'), + 'description': _('Display return orders in search preview window'), + 'default': True, + 'validator': bool, + }, + + 'SEARCH_PREVIEW_EXCLUDE_INACTIVE_RETURN_ORDERS': { + 'name': _('Exclude Inactive Return Orders'), + 'description': _('Exclude inactive return orders from search preview window'), + 'validator': bool, + 'default': True, + }, + 'SEARCH_PREVIEW_RESULTS': { 'name': _('Search Preview Results'), 'description': _('Number of results to show in each section of the search preview window'), diff --git a/InvenTree/common/notifications.py b/InvenTree/common/notifications.py index 34b2190851..a20b5e8a75 100644 --- a/InvenTree/common/notifications.py +++ b/InvenTree/common/notifications.py @@ -305,6 +305,13 @@ class InvenTreeNotificationBodies: template='email/purchase_order_received.html', ) + ReturnOrderItemsReceived = NotificationBody( + name=_('Items Received'), + slug='return_order.items_received', + message=_('Items have been received against a return order'), + template='email/return_order_received.html', + ) + def trigger_notification(obj, category=None, obj_ref='pk', **kwargs): """Send out a notification.""" diff --git a/InvenTree/company/api.py b/InvenTree/company/api.py index e45a977b9e..89dccdb427 100644 --- a/InvenTree/company/api.py +++ b/InvenTree/company/api.py @@ -1,7 +1,7 @@ """Provides a JSON API for the Company app.""" from django.db.models import Q -from django.urls import include, re_path +from django.urls import include, path, re_path from django_filters import rest_framework as rest_filters from django_filters.rest_framework import DjangoFilterBackend @@ -15,10 +15,11 @@ from InvenTree.mixins import (ListCreateAPI, RetrieveUpdateAPI, RetrieveUpdateDestroyAPI) from plugin.serializers import MetadataSerializer -from .models import (Company, CompanyAttachment, ManufacturerPart, +from .models import (Company, CompanyAttachment, Contact, ManufacturerPart, ManufacturerPartAttachment, ManufacturerPartParameter, SupplierPart, SupplierPriceBreak) from .serializers import (CompanyAttachmentSerializer, CompanySerializer, + ContactSerializer, ManufacturerPartAttachmentSerializer, ManufacturerPartParameterSerializer, ManufacturerPartSerializer, SupplierPartSerializer, @@ -118,6 +119,41 @@ class CompanyAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI): serializer_class = CompanyAttachmentSerializer +class ContactList(ListCreateDestroyAPIView): + """API endpoint for list view of Company model""" + + queryset = Contact.objects.all() + serializer_class = ContactSerializer + + filter_backends = [ + DjangoFilterBackend, + filters.SearchFilter, + filters.OrderingFilter, + ] + + filterset_fields = [ + 'company', + ] + + search_fields = [ + 'company__name', + 'name', + ] + + ordering_fields = [ + 'name', + ] + + ordering = 'name' + + +class ContactDetail(RetrieveUpdateDestroyAPI): + """Detail endpoint for Company model""" + + queryset = Contact.objects.all() + serializer_class = ContactSerializer + + class ManufacturerPartFilter(rest_filters.FilterSet): """Custom API filters for the ManufacturerPart list endpoint.""" @@ -519,12 +555,12 @@ manufacturer_part_api_urls = [ # Base URL for ManufacturerPartAttachment API endpoints re_path(r'^attachment/', include([ - re_path(r'^(?P\d+)/', ManufacturerPartAttachmentDetail.as_view(), name='api-manufacturer-part-attachment-detail'), + path(r'/', ManufacturerPartAttachmentDetail.as_view(), name='api-manufacturer-part-attachment-detail'), re_path(r'^$', ManufacturerPartAttachmentList.as_view(), name='api-manufacturer-part-attachment-list'), ])), re_path(r'^parameter/', include([ - re_path(r'^(?P\d+)/', ManufacturerPartParameterDetail.as_view(), name='api-manufacturer-part-parameter-detail'), + path(r'/', ManufacturerPartParameterDetail.as_view(), name='api-manufacturer-part-parameter-detail'), # Catch anything else re_path(r'^.*$', ManufacturerPartParameterList.as_view(), name='api-manufacturer-part-parameter-list'), @@ -570,10 +606,15 @@ company_api_urls = [ ])), re_path(r'^attachment/', include([ - re_path(r'^(?P\d+)/', CompanyAttachmentDetail.as_view(), name='api-company-attachment-detail'), + path(r'/', CompanyAttachmentDetail.as_view(), name='api-company-attachment-detail'), re_path(r'^$', CompanyAttachmentList.as_view(), name='api-company-attachment-list'), ])), + re_path(r'^contact/', include([ + path('/', ContactDetail.as_view(), name='api-contact-detail'), + re_path(r'^.*$', ContactList.as_view(), name='api-contact-list'), + ])), + re_path(r'^.*$', CompanyList.as_view(), name='api-company-list'), ] diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index 57c3541f66..8155caeeea 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -235,6 +235,11 @@ class Contact(models.Model): role: position in company """ + @staticmethod + def get_api_url(): + """Return the API URL associated with the Contcat model""" + return reverse('api-contact-list') + company = models.ForeignKey(Company, related_name='contacts', on_delete=models.CASCADE) diff --git a/InvenTree/company/serializers.py b/InvenTree/company/serializers.py index faa281cbed..bad595d85f 100644 --- a/InvenTree/company/serializers.py +++ b/InvenTree/company/serializers.py @@ -17,7 +17,7 @@ from InvenTree.serializers import (InvenTreeAttachmentSerializer, InvenTreeMoneySerializer, RemoteImageMixin) from part.serializers import PartBriefSerializer -from .models import (Company, CompanyAttachment, ManufacturerPart, +from .models import (Company, CompanyAttachment, Contact, ManufacturerPart, ManufacturerPartAttachment, ManufacturerPartParameter, SupplierPart, SupplierPriceBreak) @@ -132,6 +132,23 @@ class CompanyAttachmentSerializer(InvenTreeAttachmentSerializer): ]) +class ContactSerializer(InvenTreeModelSerializer): + """Serializer class for the Contact model""" + + class Meta: + """Metaclass options""" + + model = Contact + fields = [ + 'pk', + 'company', + 'name', + 'phone', + 'email', + 'role', + ] + + class ManufacturerPartSerializer(InvenTreeModelSerializer): """Serializer for ManufacturerPart object.""" diff --git a/InvenTree/company/templates/company/detail.html b/InvenTree/company/templates/company/detail.html index 49780c56ff..0f3bc6e203 100644 --- a/InvenTree/company/templates/company/detail.html +++ b/InvenTree/company/templates/company/detail.html @@ -1,6 +1,7 @@ {% extends "company/company_base.html" %} {% load static %} {% load i18n %} +{% load inventree_extras %} {% block sidebar %} {% include 'company/sidebar.html' %} @@ -137,6 +138,8 @@
+{% if company.is_customer %} +{% if roles.sales_order.view %}
@@ -162,7 +165,9 @@
+{% endif %} +{% if roles.stock.view %}

{% trans "Assigned Stock" %}

@@ -175,9 +180,40 @@
-
+{% endif %} + +{% if roles.return_order.view and return_order_enabled %} +
+
+
+

{% trans "Return Orders" %}

+ {% include "spacer.html" %} +
+ {% if roles.return_order.add %} + + {% endif %} +
+
+
+
+
+
+
+ {% include "filter_list.html" with id="returnorder" %} +
+
+
+ +
+
+
+{% endif %} + +{% endif %}
@@ -194,6 +230,31 @@
+
+
+
+

{% trans "Company Contacts" %}

+ {% include "spacer.html" %} +
+ {% if roles.purchase_order.add or roles.sales_order.add %} + + {% endif %} +
+
+
+
+
+
+ {% include "filter_list.html" with id="contacts" %} +
+
+ +
+
+
+
@@ -226,22 +287,29 @@ } } }); - - enableDragAndDrop( - '#attachment-dropzone', - '{% url "api-company-attachment-list" %}', - { - data: { - company: {{ company.id }}, - }, - label: 'attachment', - success: function(data, status, xhr) { - reloadAttachmentTable(); - } - } - ); }); + // Callback function when the 'contacts' panel is loaded + onPanelLoad('company-contacts', function() { + loadContactTable('#contacts-table', { + params: { + company: {{ company.pk }}, + }, + allow_edit: {% js_bool roles.purchase_order.change %} || {% js_bool roles.sales_order.change %}, + allow_delete: {% js_bool roles.purchase_order.delete %} || {% js_bool roles.sales_order.delete %}, + }); + + $('#new-contact').click(function() { + createContact({ + company: {{ company.pk }}, + onSuccess: function() { + $('#contacts-table').bootstrapTable('refresh'); + } + }); + }); + }); + + // Callback function when the 'notes' panel is loaded onPanelLoad('company-notes', function() { setupNotesField( @@ -250,18 +318,7 @@ { editable: true, } - ) - }); - - loadStockTable($("#assigned-stock-table"), { - params: { - customer: {{ company.id }}, - part_detail: true, - location_detail: true, - }, - url: "{% url 'api-stock-list' %}", - filterKey: "customerstock", - filterTarget: '#filter-list-customerstock', + ); }); onPanelLoad('company-stock', function() { @@ -282,20 +339,65 @@ }); {% if company.is_customer %} + + {% if return_order_enabled %} + // Callback function when the 'return orders' panel is loaded + onPanelLoad('return-orders', function() { + + {% if roles.return_order.view %} + loadReturnOrderTable('#return-order-table', { + params: { + customer: {{ company.pk }}, + } + }); + {% endif %} + + {% if roles.return_order.add %} + $('#new-return-order').click(function() { + createReturnOrder({ + customer: {{ company.pk }}, + }); + }); + {% endif %} + }); + {% endif %} + + // Callback function when the 'assigned stock' panel is loaded + onPanelLoad('assigned-stock', function() { + + {% if roles.stock.view %} + loadStockTable($("#assigned-stock-table"), { + params: { + customer: {{ company.id }}, + part_detail: true, + location_detail: true, + }, + url: "{% url 'api-stock-list' %}", + filterKey: "customerstock", + filterTarget: '#filter-list-customerstock', + }); + {% endif %} + }); + + // Callback function when the 'sales orders' panel is loaded onPanelLoad('sales-orders', function() { + {% if roles.sales_order.view %} loadSalesOrderTable("#sales-order-table", { url: "{% url 'api-so-list' %}", params: { customer: {{ company.id }}, } }); + {% endif %} + {% if roles.salse_order.add %} $("#new-sales-order").click(function() { createSalesOrder({ customer: {{ company.pk }}, }); }); + {% endif %} }); {% endif %} @@ -334,7 +436,7 @@ createManufacturerPart({ manufacturer: {{ company.pk }}, onSuccess: function() { - $("#part-table").bootstrapTable("refresh"); + $("#part-table").bootstrapTable('refresh'); } }); }); @@ -356,7 +458,7 @@ deleteManufacturerParts(selections, { success: function() { - $("#manufacturer-part-table").bootstrapTable("refresh"); + $("#manufacturer-part-table").bootstrapTable('refresh'); } }); }); diff --git a/InvenTree/company/templates/company/manufacturer_part.html b/InvenTree/company/templates/company/manufacturer_part.html index 0521cd039e..6c3da85448 100644 --- a/InvenTree/company/templates/company/manufacturer_part.html +++ b/InvenTree/company/templates/company/manufacturer_part.html @@ -209,26 +209,8 @@ onPanelLoad("attachments", function() { } } }); - - enableDragAndDrop( - '#attachment-dropzone', - '{% url "api-manufacturer-part-attachment-list" %}', - { - data: { - manufacturer_part: {{ part.id }}, - }, - label: 'attachment', - success: function(data, status, xhr) { - reloadAttachmentTable(); - } - } - ); }); -function reloadParameters() { - $("#parameter-table").bootstrapTable("refresh"); -} - $('#parameter-create').click(function() { constructForm('{% url "api-manufacturer-part-parameter-list" %}', { @@ -243,7 +225,7 @@ $('#parameter-create').click(function() { } }, title: '{% trans "Add Parameter" %}', - onSuccess: reloadParameters + refreshTable: '#parameter-table', }); }); diff --git a/InvenTree/company/templates/company/sidebar.html b/InvenTree/company/templates/company/sidebar.html index ad40c03a0b..7fe616b7d1 100644 --- a/InvenTree/company/templates/company/sidebar.html +++ b/InvenTree/company/templates/company/sidebar.html @@ -17,11 +17,21 @@ {% include "sidebar_item.html" with label='company-stock' text=text icon="fa-boxes" %} {% endif %} {% if company.is_customer %} +{% if roles.sales_order.view %} {% trans "Sales Orders" as text %} {% include "sidebar_item.html" with label='sales-orders' text=text icon="fa-truck" %} +{% endif %} +{% if roles.stock.view %} {% trans "Assigned Stock Items" as text %} {% include "sidebar_item.html" with label='assigned-stock' text=text icon="fa-sign-out-alt" %} {% endif %} +{% if roles.return_order.view and return_order_enabled %} +{% trans "Return Orders" as text %} +{% include "sidebar_item.html" with label='return-orders' text=text icon="fa-undo" %} +{% endif %} +{% endif %} +{% trans "Contacts" as text %} +{% include "sidebar_item.html" with label='company-contacts' text=text icon="fa-users" %} {% trans "Notes" as text %} {% include "sidebar_item.html" with label='company-notes' text=text icon="fa-clipboard" %} {% trans "Attachments" as text %} diff --git a/InvenTree/company/templates/company/supplier_part.html b/InvenTree/company/templates/company/supplier_part.html index 58ac0fa0eb..8ecf4b1ece 100644 --- a/InvenTree/company/templates/company/supplier_part.html +++ b/InvenTree/company/templates/company/supplier_part.html @@ -301,7 +301,7 @@ loadSupplierPriceBreakTable({ $('#new-price-break').click(function() { createSupplierPartPriceBreak({{ part.pk }}, { onSuccess: function() { - $("#price-break-table").bootstrapTable("refresh"); + $("#price-break-table").bootstrapTable('refresh'); } }); diff --git a/InvenTree/company/test_api.py b/InvenTree/company/test_api.py index aa33385d2c..6865a1be99 100644 --- a/InvenTree/company/test_api.py +++ b/InvenTree/company/test_api.py @@ -6,7 +6,7 @@ from rest_framework import status from InvenTree.api_tester import InvenTreeAPITestCase -from .models import Company, SupplierPart +from .models import Company, Contact, SupplierPart class CompanyTest(InvenTreeAPITestCase): @@ -140,6 +140,144 @@ class CompanyTest(InvenTreeAPITestCase): self.assertTrue('currency' in response.data) +class ContactTest(InvenTreeAPITestCase): + """Tests for the Contact models""" + + roles = [] + + @classmethod + def setUpTestData(cls): + """Perform init for this test class""" + + super().setUpTestData() + + # Create some companies + companies = [ + Company( + name=f"Company {idx}", + description="Some company" + ) for idx in range(3) + ] + + Company.objects.bulk_create(companies) + + contacts = [] + + # Create some contacts + for cmp in Company.objects.all(): + contacts += [ + Contact( + company=cmp, + name=f"My name {idx}", + ) for idx in range(3) + ] + + Contact.objects.bulk_create(contacts) + + cls.url = reverse('api-contact-list') + + def test_list(self): + """Test company list API endpoint""" + + # List all results + response = self.get(self.url, {}, expected_code=200) + + self.assertEqual(len(response.data), 9) + + for result in response.data: + for key in ['name', 'email', 'pk', 'company']: + self.assertIn(key, result) + + # Filter by particular company + for cmp in Company.objects.all(): + response = self.get( + self.url, + { + 'company': cmp.pk, + }, + expected_code=200 + ) + + self.assertEqual(len(response.data), 3) + + def test_create(self): + """Test that we can create a new Contact object via the API""" + + n = Contact.objects.count() + + company = Company.objects.first() + + # Without required permissions, creation should fail + self.post( + self.url, + { + 'company': company.pk, + 'name': 'Joe Bloggs', + }, + expected_code=403 + ) + + self.assignRole('return_order.add') + + self.post( + self.url, + { + 'company': company.pk, + 'name': 'Joe Bloggs', + }, + expected_code=201 + ) + + self.assertEqual(Contact.objects.count(), n + 1) + + def test_edit(self): + """Test that we can edit a Contact via the API""" + + url = reverse('api-contact-detail', kwargs={'pk': 1}) + + # Retrieve detail view + data = self.get(url, expected_code=200).data + + for key in ['pk', 'name', 'role']: + self.assertIn(key, data) + + self.patch( + url, + { + 'role': 'model', + }, + expected_code=403 + ) + + self.assignRole('purchase_order.change') + + self.patch( + url, + { + 'role': 'x', + }, + expected_code=200 + ) + + contact = Contact.objects.get(pk=1) + self.assertEqual(contact.role, 'x') + + def test_delete(self): + """Tests that we can delete a Contact via the API""" + + url = reverse('api-contact-detail', kwargs={'pk': 6}) + + # Delete (without required permissions) + self.delete(url, expected_code=403) + + self.assignRole('sales_order.delete') + + self.delete(url, expected_code=204) + + # Try to access again (gone!) + self.get(url, expected_code=404) + + class ManufacturerTest(InvenTreeAPITestCase): """Series of tests for the Manufacturer DRF API.""" diff --git a/InvenTree/company/urls.py b/InvenTree/company/urls.py index 8ab73ba2b2..71985964fb 100644 --- a/InvenTree/company/urls.py +++ b/InvenTree/company/urls.py @@ -1,13 +1,13 @@ """URL lookup for Company app.""" -from django.urls import include, re_path +from django.urls import include, path, re_path from . import views company_urls = [ # Detail URLs for a specific Company instance - re_path(r'^(?P\d+)/', include([ + path(r'/', include([ re_path(r'^.*$', views.CompanyDetail.as_view(), name='company-detail'), ])), @@ -21,11 +21,11 @@ company_urls = [ manufacturer_part_urls = [ - re_path(r'^(?P\d+)/', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part.html'), name='manufacturer-part-detail'), + path(r'/', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part.html'), name='manufacturer-part-detail'), ] supplier_part_urls = [ - re_path(r'^(?P\d+)/', include([ + path(r'/', include([ re_path('^.*$', views.SupplierPartDetail.as_view(template_name='company/supplier_part.html'), name='supplier-part-detail'), ])) diff --git a/InvenTree/label/api.py b/InvenTree/label/api.py index 25d672225a..3a298f3bc5 100644 --- a/InvenTree/label/api.py +++ b/InvenTree/label/api.py @@ -3,7 +3,7 @@ from django.conf import settings from django.core.exceptions import FieldError, ValidationError from django.http import HttpResponse, JsonResponse -from django.urls import include, re_path +from django.urls import include, path, re_path from django.utils.decorators import method_decorator from django.views.decorators.cache import cache_page, never_cache @@ -403,7 +403,7 @@ label_api_urls = [ # Stock item labels re_path(r'stock/', include([ # Detail views - re_path(r'^(?P\d+)/', include([ + path(r'/', include([ re_path(r'print/?', StockItemLabelPrint.as_view(), name='api-stockitem-label-print'), re_path(r'metadata/', StockItemLabelMetadata.as_view(), name='api-stockitem-label-metadata'), re_path(r'^.*$', StockItemLabelDetail.as_view(), name='api-stockitem-label-detail'), @@ -416,7 +416,7 @@ label_api_urls = [ # Stock location labels re_path(r'location/', include([ # Detail views - re_path(r'^(?P\d+)/', include([ + path(r'/', include([ re_path(r'print/?', StockLocationLabelPrint.as_view(), name='api-stocklocation-label-print'), re_path(r'metadata/', StockLocationLabelMetadata.as_view(), name='api-stocklocation-label-metadata'), re_path(r'^.*$', StockLocationLabelDetail.as_view(), name='api-stocklocation-label-detail'), @@ -429,7 +429,7 @@ label_api_urls = [ # Part labels re_path(r'^part/', include([ # Detail views - re_path(r'^(?P\d+)/', include([ + path(r'/', include([ re_path(r'^print/', PartLabelPrint.as_view(), name='api-part-label-print'), re_path(r'^metadata/', PartLabelMetadata.as_view(), name='api-part-label-metadata'), re_path(r'^.*$', PartLabelDetail.as_view(), name='api-part-label-detail'), diff --git a/InvenTree/order/admin.py b/InvenTree/order/admin.py index 2f0f5bd36d..c1123bd5c4 100644 --- a/InvenTree/order/admin.py +++ b/InvenTree/order/admin.py @@ -6,13 +6,9 @@ import import_export.widgets as widgets from import_export.admin import ImportExportModelAdmin from import_export.fields import Field +import order.models as models from InvenTree.admin import InvenTreeResource -from .models import (PurchaseOrder, PurchaseOrderExtraLine, - PurchaseOrderLineItem, SalesOrder, SalesOrderAllocation, - SalesOrderExtraLine, SalesOrderLineItem, - SalesOrderShipment) - # region general classes class GeneralExtraLineAdmin: @@ -42,7 +38,7 @@ class GeneralExtraLineMeta: class PurchaseOrderLineItemInlineAdmin(admin.StackedInline): """Inline admin class for the PurchaseOrderLineItem model""" - model = PurchaseOrderLineItem + model = models.PurchaseOrderLineItem extra = 0 @@ -103,7 +99,7 @@ class PurchaseOrderResource(InvenTreeResource): class Meta: """Metaclass""" - model = PurchaseOrder + model = models.PurchaseOrder skip_unchanged = True clean_model_instances = True exclude = [ @@ -122,7 +118,7 @@ class PurchaseOrderLineItemResource(InvenTreeResource): class Meta: """Metaclass""" - model = PurchaseOrderLineItem + model = models.PurchaseOrderLineItem skip_unchanged = True report_skipped = False clean_model_instances = True @@ -142,7 +138,7 @@ class PurchaseOrderExtraLineResource(InvenTreeResource): class Meta(GeneralExtraLineMeta): """Metaclass options.""" - model = PurchaseOrderExtraLine + model = models.PurchaseOrderExtraLine class SalesOrderResource(InvenTreeResource): @@ -150,7 +146,7 @@ class SalesOrderResource(InvenTreeResource): class Meta: """Metaclass options""" - model = SalesOrder + model = models.SalesOrder skip_unchanged = True clean_model_instances = True exclude = [ @@ -169,7 +165,7 @@ class SalesOrderLineItemResource(InvenTreeResource): class Meta: """Metaclass options""" - model = SalesOrderLineItem + model = models.SalesOrderLineItem skip_unchanged = True report_skipped = False clean_model_instances = True @@ -198,8 +194,7 @@ class SalesOrderExtraLineResource(InvenTreeResource): class Meta(GeneralExtraLineMeta): """Metaclass options.""" - - model = SalesOrderExtraLine + model = models.SalesOrderExtraLine class PurchaseOrderLineItemAdmin(ImportExportModelAdmin): @@ -281,13 +276,92 @@ class SalesOrderAllocationAdmin(ImportExportModelAdmin): autocomplete_fields = ('line', 'shipment', 'item',) -admin.site.register(PurchaseOrder, PurchaseOrderAdmin) -admin.site.register(PurchaseOrderLineItem, PurchaseOrderLineItemAdmin) -admin.site.register(PurchaseOrderExtraLine, PurchaseOrderExtraLineAdmin) +class ReturnOrderResource(InvenTreeResource): + """Class for managing import / export of ReturnOrder data""" -admin.site.register(SalesOrder, SalesOrderAdmin) -admin.site.register(SalesOrderLineItem, SalesOrderLineItemAdmin) -admin.site.register(SalesOrderExtraLine, SalesOrderExtraLineAdmin) + class Meta: + """Metaclass options""" + model = models.ReturnOrder + skip_unchanged = True + clean_model_instances = True + exclude = [ + 'metadata', + ] -admin.site.register(SalesOrderShipment, SalesOrderShipmentAdmin) -admin.site.register(SalesOrderAllocation, SalesOrderAllocationAdmin) + +class ReturnOrderAdmin(ImportExportModelAdmin): + """Admin class for the ReturnOrder model""" + + exclude = [ + 'reference_int', + ] + + list_display = [ + 'reference', + 'customer', + 'status', + ] + + search_fields = [ + 'reference', + 'customer__name', + 'description', + ] + + autocomplete_fields = [ + 'customer', + ] + + +class ReturnOrderLineItemResource(InvenTreeResource): + """Class for managing import / export of ReturnOrderLineItem data""" + + class Meta: + """Metaclass options""" + model = models.ReturnOrderLineItem + skip_unchanged = True + report_skipped = False + clean_model_instances = True + + +class ReturnOrderLineItemAdmin(ImportExportModelAdmin): + """Admin class for ReturnOrderLine model""" + + resource_class = ReturnOrderLineItemResource + + list_display = [ + 'order', + 'item', + 'reference', + ] + + +class ReturnOrderExtraLineClass(InvenTreeResource): + """Class for managing import/export of ReturnOrderExtraLine data""" + + class Meta(GeneralExtraLineMeta): + """Metaclass options""" + model = models.ReturnOrderExtraLine + + +class ReturnOrdeerExtraLineAdmin(GeneralExtraLineAdmin, ImportExportModelAdmin): + """Admin class for the ReturnOrderExtraLine model""" + resource_class = ReturnOrderExtraLineClass + + +# Purchase Order models +admin.site.register(models.PurchaseOrder, PurchaseOrderAdmin) +admin.site.register(models.PurchaseOrderLineItem, PurchaseOrderLineItemAdmin) +admin.site.register(models.PurchaseOrderExtraLine, PurchaseOrderExtraLineAdmin) + +# Sales Order models +admin.site.register(models.SalesOrder, SalesOrderAdmin) +admin.site.register(models.SalesOrderLineItem, SalesOrderLineItemAdmin) +admin.site.register(models.SalesOrderExtraLine, SalesOrderExtraLineAdmin) +admin.site.register(models.SalesOrderShipment, SalesOrderShipmentAdmin) +admin.site.register(models.SalesOrderAllocation, SalesOrderAllocationAdmin) + +# Return Order models +admin.site.register(models.ReturnOrder, ReturnOrderAdmin) +admin.site.register(models.ReturnOrderLineItem, ReturnOrderLineItemAdmin) +admin.site.register(models.ReturnOrderExtraLine, ReturnOrdeerExtraLineAdmin) diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 34f318aa1e..7bc49cb26b 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -28,7 +28,7 @@ from InvenTree.mixins import (CreateAPI, ListAPI, ListCreateAPI, from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus from order.admin import (PurchaseOrderExtraLineResource, PurchaseOrderLineItemResource, PurchaseOrderResource, - SalesOrderExtraLineResource, + ReturnOrderResource, SalesOrderExtraLineResource, SalesOrderLineItemResource, SalesOrderResource) from part.models import Part from plugin.serializers import MetadataSerializer @@ -89,6 +89,14 @@ class GeneralExtraLineList(APIDownloadMixin): class OrderFilter(rest_filters.FilterSet): """Base class for custom API filters for the OrderList endpoint.""" + # Filter against order status + status = rest_filters.NumberFilter(label="Order Status", method='filter_status') + + def filter_status(self, queryset, name, value): + """Filter by integer status code""" + + return queryset.filter(status=value) + # Exact match for reference reference = rest_filters.CharFilter( label='Filter by exact reference', @@ -100,17 +108,55 @@ class OrderFilter(rest_filters.FilterSet): def filter_assigned_to_me(self, queryset, name, value): """Filter by orders which are assigned to the current user.""" - value = str2bool(value) # Work out who "me" is! owners = Owner.get_owners_matching_user(self.request.user) - if value: - queryset = queryset.filter(responsible__in=owners) + if str2bool(value): + return queryset.filter(responsible__in=owners) else: - queryset = queryset.exclude(responsible__in=owners) + return queryset.exclude(responsible__in=owners) - return queryset + overdue = rest_filters.BooleanFilter(label='overdue', method='filter_overdue') + + def filter_overdue(self, queryset, name, value): + """Generic filter for determining if an order is 'overdue'. + + Note that the overdue_filter() classmethod must be defined for the model + """ + + if str2bool(value): + return queryset.filter(self.Meta.model.overdue_filter()) + else: + return queryset.exclude(self.Meta.model.overdue_filter()) + + outstanding = rest_filters.BooleanFilter(label='outstanding', method='filter_outstanding') + + def filter_outstanding(self, queryset, name, value): + """Generic filter for determining if an order is 'outstanding'""" + + if str2bool(value): + return queryset.filter(status__in=self.Meta.model.get_status_class().OPEN) + else: + return queryset.exclude(status__in=self.Meta.model.get_status_class().OPEN) + + +class LineItemFilter(rest_filters.FilterSet): + """Base class for custom API filters for order line item list(s)""" + + # Filter by order status + order_status = rest_filters.NumberFilter(label='order_status', field_name='order__status') + + has_pricing = rest_filters.BooleanFilter(label="Has Pricing", method='filter_has_pricing') + + def filter_has_pricing(self, queryset, name, value): + """Filter by whether or not the line item has pricing information""" + filters = {self.Meta.price_field: None} + + if str2bool(value): + return queryset.exclude(**filters) + else: + return queryset.filter(**filters) class PurchaseOrderFilter(OrderFilter): @@ -125,27 +171,45 @@ class PurchaseOrderFilter(OrderFilter): ] -class SalesOrderFilter(OrderFilter): - """Custom API filters for the SalesOrderList endpoint.""" +class PurchaseOrderMixin: + """Mixin class for PurchaseOrder endpoints""" - class Meta: - """Metaclass options.""" + queryset = models.PurchaseOrder.objects.all() + serializer_class = serializers.PurchaseOrderSerializer - model = models.SalesOrder - fields = [ - 'customer', - ] + def get_serializer(self, *args, **kwargs): + """Return the serializer instance for this endpoint""" + try: + kwargs['supplier_detail'] = str2bool(self.request.query_params.get('supplier_detail', False)) + except AttributeError: + pass + + # Ensure the request context is passed through + kwargs['context'] = self.get_serializer_context() + + return self.serializer_class(*args, **kwargs) + + def get_queryset(self, *args, **kwargs): + """Return the annotated queryset for this endpoint""" + queryset = super().get_queryset(*args, **kwargs) + + queryset = queryset.prefetch_related( + 'supplier', + 'lines', + ) + + queryset = serializers.PurchaseOrderSerializer.annotate_queryset(queryset) + + return queryset -class PurchaseOrderList(APIDownloadMixin, ListCreateAPI): +class PurchaseOrderList(PurchaseOrderMixin, APIDownloadMixin, ListCreateAPI): """API endpoint for accessing a list of PurchaseOrder objects. - GET: Return list of PurchaseOrder objects (with filters) - POST: Create a new PurchaseOrder object """ - queryset = models.PurchaseOrder.objects.all() - serializer_class = serializers.PurchaseOrderSerializer filterset_class = PurchaseOrderFilter def create(self, request, *args, **kwargs): @@ -196,31 +260,6 @@ class PurchaseOrderList(APIDownloadMixin, ListCreateAPI): headers = self.get_success_headers(serializer.data) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) - def get_serializer(self, *args, **kwargs): - """Return the serializer instance for this endpoint""" - try: - kwargs['supplier_detail'] = str2bool(self.request.query_params.get('supplier_detail', False)) - except AttributeError: - pass - - # Ensure the request context is passed through - kwargs['context'] = self.get_serializer_context() - - return self.serializer_class(*args, **kwargs) - - def get_queryset(self, *args, **kwargs): - """Return the annotated queryset for this endpoint""" - queryset = super().get_queryset(*args, **kwargs) - - queryset = queryset.prefetch_related( - 'supplier', - 'lines', - ) - - queryset = serializers.PurchaseOrderSerializer.annotate_queryset(queryset) - - return queryset - def download_queryset(self, queryset, export_format): """Download the filtered queryset as a file""" @@ -239,35 +278,6 @@ class PurchaseOrderList(APIDownloadMixin, ListCreateAPI): params = self.request.query_params - # Filter by 'outstanding' status - outstanding = params.get('outstanding', None) - - if outstanding is not None: - outstanding = str2bool(outstanding) - - if outstanding: - queryset = queryset.filter(status__in=PurchaseOrderStatus.OPEN) - else: - queryset = queryset.exclude(status__in=PurchaseOrderStatus.OPEN) - - # Filter by 'overdue' status - overdue = params.get('overdue', None) - - if overdue is not None: - overdue = str2bool(overdue) - - if overdue: - queryset = queryset.filter(models.PurchaseOrder.OVERDUE_FILTER) - else: - queryset = queryset.exclude(models.PurchaseOrder.OVERDUE_FILTER) - - # Special filtering for 'status' field - status = params.get('status', None) - - if status is not None: - # First attempt to filter by integer value - queryset = queryset.filter(status=status) - # Attempt to filter by part part = params.get('part', None) @@ -328,41 +338,16 @@ class PurchaseOrderList(APIDownloadMixin, ListCreateAPI): ordering = '-reference' -class PurchaseOrderDetail(RetrieveUpdateDestroyAPI): +class PurchaseOrderDetail(PurchaseOrderMixin, RetrieveUpdateDestroyAPI): """API endpoint for detail view of a PurchaseOrder object.""" - - queryset = models.PurchaseOrder.objects.all() - serializer_class = serializers.PurchaseOrderSerializer - - def get_serializer(self, *args, **kwargs): - """Return serializer instance for this endpoint""" - try: - kwargs['supplier_detail'] = str2bool(self.request.query_params.get('supplier_detail', False)) - except AttributeError: - pass - - # Ensure the request context is passed through - kwargs['context'] = self.get_serializer_context() - - return self.serializer_class(*args, **kwargs) - - def get_queryset(self, *args, **kwargs): - """Return annotated queryset for this endpoint""" - queryset = super().get_queryset(*args, **kwargs) - - queryset = queryset.prefetch_related( - 'supplier', - 'lines', - ) - - queryset = serializers.PurchaseOrderSerializer.annotate_queryset(queryset) - - return queryset + pass class PurchaseOrderContextMixin: """Mixin to add purchase order object as serializer context variable.""" + queryset = models.PurchaseOrder.objects.all() + def get_serializer_context(self): """Add the PurchaseOrder object to the serializer context.""" context = super().get_serializer_context() @@ -384,23 +369,17 @@ class PurchaseOrderCancel(PurchaseOrderContextMixin, CreateAPI): The purchase order must be in a state which can be cancelled """ - queryset = models.PurchaseOrder.objects.all() - serializer_class = serializers.PurchaseOrderCancelSerializer class PurchaseOrderComplete(PurchaseOrderContextMixin, CreateAPI): """API endpoint to 'complete' a purchase order.""" - queryset = models.PurchaseOrder.objects.all() - serializer_class = serializers.PurchaseOrderCompleteSerializer class PurchaseOrderIssue(PurchaseOrderContextMixin, CreateAPI): - """API endpoint to 'issue' (send) a purchase order.""" - - queryset = models.PurchaseOrder.objects.all() + """API endpoint to 'issue' (place) a PurchaseOrder.""" serializer_class = serializers.PurchaseOrderIssueSerializer @@ -416,7 +395,7 @@ class PurchaseOrderMetadata(RetrieveUpdateAPI): class PurchaseOrderReceive(PurchaseOrderContextMixin, CreateAPI): - """API endpoint to receive stock items against a purchase order. + """API endpoint to receive stock items against a PurchaseOrder. - The purchase order is specified in the URL. - Items to receive are specified as a list called "items" with the following options: @@ -435,12 +414,12 @@ class PurchaseOrderReceive(PurchaseOrderContextMixin, CreateAPI): serializer_class = serializers.PurchaseOrderReceiveSerializer -class PurchaseOrderLineItemFilter(rest_filters.FilterSet): +class PurchaseOrderLineItemFilter(LineItemFilter): """Custom filters for the PurchaseOrderLineItemList endpoint.""" class Meta: """Metaclass options.""" - + price_field = 'purchase_price' model = models.PurchaseOrderLineItem fields = [ 'order', @@ -451,16 +430,11 @@ class PurchaseOrderLineItemFilter(rest_filters.FilterSet): def filter_pending(self, queryset, name, value): """Filter by "pending" status (order status = pending)""" - value = str2bool(value) - if value: - queryset = queryset.filter(order__status__in=PurchaseOrderStatus.OPEN) + if str2bool(value): + return queryset.filter(order__status__in=PurchaseOrderStatus.OPEN) else: - queryset = queryset.exclude(order__status__in=PurchaseOrderStatus.OPEN) - - return queryset - - order_status = rest_filters.NumberFilter(label='order_status', field_name='order__status') + return queryset.exclude(order__status__in=PurchaseOrderStatus.OPEN) received = rest_filters.BooleanFilter(label='received', method='filter_received') @@ -469,42 +443,20 @@ class PurchaseOrderLineItemFilter(rest_filters.FilterSet): A line is considered "received" when received >= quantity """ - value = str2bool(value) - q = Q(received__gte=F('quantity')) - if value: - queryset = queryset.filter(q) + if str2bool(value): + return queryset.filter(q) else: # Only count "pending" orders - queryset = queryset.exclude(q).filter(order__status__in=PurchaseOrderStatus.OPEN) - - return queryset - - has_pricing = rest_filters.BooleanFilter(label="Has Pricing", method='filter_has_pricing') - - def filter_has_pricing(self, queryset, name, value): - """Filter by whether or not the line item has pricing information""" - value = str2bool(value) - - if value: - queryset = queryset.exclude(purchase_price=None) - else: - queryset = queryset.filter(purchase_price=None) - - return queryset + return queryset.exclude(q).filter(order__status__in=PurchaseOrderStatus.OPEN) -class PurchaseOrderLineItemList(APIDownloadMixin, ListCreateDestroyAPIView): - """API endpoint for accessing a list of PurchaseOrderLineItem objects. - - - GET: Return a list of PurchaseOrder Line Item objects - - POST: Create a new PurchaseOrderLineItem object - """ +class PurchaseOrderLineItemMixin: + """Mixin class for PurchaseOrderLineItem endpoints""" queryset = models.PurchaseOrderLineItem.objects.all() serializer_class = serializers.PurchaseOrderLineItemSerializer - filterset_class = PurchaseOrderLineItemFilter def get_queryset(self, *args, **kwargs): """Return annotated queryset for this endpoint""" @@ -526,6 +478,16 @@ class PurchaseOrderLineItemList(APIDownloadMixin, ListCreateDestroyAPIView): return self.serializer_class(*args, **kwargs) + +class PurchaseOrderLineItemList(PurchaseOrderLineItemMixin, APIDownloadMixin, ListCreateDestroyAPIView): + """API endpoint for accessing a list of PurchaseOrderLineItem objects. + + - GET: Return a list of PurchaseOrder Line Item objects + - POST: Create a new PurchaseOrderLineItem object + """ + + filterset_class = PurchaseOrderLineItemFilter + def filter_queryset(self, queryset): """Additional filtering options.""" params = self.request.query_params @@ -589,19 +551,9 @@ class PurchaseOrderLineItemList(APIDownloadMixin, ListCreateDestroyAPIView): ] -class PurchaseOrderLineItemDetail(RetrieveUpdateDestroyAPI): +class PurchaseOrderLineItemDetail(PurchaseOrderLineItemMixin, RetrieveUpdateDestroyAPI): """Detail API endpoint for PurchaseOrderLineItem object.""" - - queryset = models.PurchaseOrderLineItem.objects.all() - serializer_class = serializers.PurchaseOrderLineItemSerializer - - def get_queryset(self): - """Return annotated queryset for this endpoint""" - queryset = super().get_queryset() - - queryset = serializers.PurchaseOrderLineItemSerializer.annotate_queryset(queryset) - - return queryset + pass class PurchaseOrderLineItemMetadata(RetrieveUpdateAPI): @@ -669,28 +621,23 @@ class SalesOrderAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI): serializer_class = serializers.SalesOrderAttachmentSerializer -class SalesOrderList(APIDownloadMixin, ListCreateAPI): - """API endpoint for accessing a list of SalesOrder objects. +class SalesOrderFilter(OrderFilter): + """Custom API filters for the SalesOrderList endpoint.""" - - GET: Return list of SalesOrder objects (with filters) - - POST: Create a new SalesOrder - """ + class Meta: + """Metaclass options.""" + + model = models.SalesOrder + fields = [ + 'customer', + ] + + +class SalesOrderMixin: + """Mixin class for SalesOrder endpoints""" queryset = models.SalesOrder.objects.all() serializer_class = serializers.SalesOrderSerializer - filterset_class = SalesOrderFilter - - def create(self, request, *args, **kwargs): - """Save user information on create.""" - serializer = self.get_serializer(data=self.clean_data(request.data)) - serializer.is_valid(raise_exception=True) - - item = serializer.save() - item.created_by = request.user - item.save() - - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) def get_serializer(self, *args, **kwargs): """Return serializer instance for this endpoint""" @@ -717,6 +664,28 @@ class SalesOrderList(APIDownloadMixin, ListCreateAPI): return queryset + +class SalesOrderList(SalesOrderMixin, APIDownloadMixin, ListCreateAPI): + """API endpoint for accessing a list of SalesOrder objects. + + - GET: Return list of SalesOrder objects (with filters) + - POST: Create a new SalesOrder + """ + + filterset_class = SalesOrderFilter + + def create(self, request, *args, **kwargs): + """Save user information on create.""" + serializer = self.get_serializer(data=self.clean_data(request.data)) + serializer.is_valid(raise_exception=True) + + item = serializer.save() + item.created_by = request.user + item.save() + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + def download_queryset(self, queryset, export_format): """Download this queryset as a file""" @@ -734,33 +703,6 @@ class SalesOrderList(APIDownloadMixin, ListCreateAPI): params = self.request.query_params - # Filter by 'outstanding' status - outstanding = params.get('outstanding', None) - - if outstanding is not None: - outstanding = str2bool(outstanding) - - if outstanding: - queryset = queryset.filter(status__in=SalesOrderStatus.OPEN) - else: - queryset = queryset.exclude(status__in=SalesOrderStatus.OPEN) - - # Filter by 'overdue' status - overdue = params.get('overdue', None) - - if overdue is not None: - overdue = str2bool(overdue) - - if overdue: - queryset = queryset.filter(models.SalesOrder.OVERDUE_FILTER) - else: - queryset = queryset.exclude(models.SalesOrder.OVERDUE_FILTER) - - 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) @@ -817,62 +759,23 @@ class SalesOrderList(APIDownloadMixin, ListCreateAPI): ordering = '-reference' -class SalesOrderDetail(RetrieveUpdateDestroyAPI): +class SalesOrderDetail(SalesOrderMixin, RetrieveUpdateDestroyAPI): """API endpoint for detail view of a SalesOrder object.""" - - queryset = models.SalesOrder.objects.all() - serializer_class = serializers.SalesOrderSerializer - - def get_serializer(self, *args, **kwargs): - """Return the serializer instance for this endpoint""" - 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): - """Return the annotated queryset for this serializer""" - queryset = super().get_queryset(*args, **kwargs) - - queryset = queryset.prefetch_related('customer', 'lines') - - queryset = serializers.SalesOrderSerializer.annotate_queryset(queryset) - - return queryset + pass -class SalesOrderLineItemFilter(rest_filters.FilterSet): +class SalesOrderLineItemFilter(LineItemFilter): """Custom filters for SalesOrderLineItemList endpoint.""" class Meta: """Metaclass options.""" - + price_field = 'sale_price' model = models.SalesOrderLineItem fields = [ 'order', 'part', ] - has_pricing = rest_filters.BooleanFilter(label="Has Pricing", method='filter_has_pricing') - - def filter_has_pricing(self, queryset, name, value): - """Filter by whether or not the line item has pricing information""" - - value = str2bool(value) - - if value: - queryset = queryset.exclude(sale_price=None) - else: - queryset = queryset.filter(sale_price=None) - - return queryset - - order_status = rest_filters.NumberFilter(label='Order Status', field_name='order__status') - completed = rest_filters.BooleanFilter(label='completed', method='filter_completed') def filter_completed(self, queryset, name, value): @@ -880,24 +783,19 @@ class SalesOrderLineItemFilter(rest_filters.FilterSet): A line is completed when shipped >= quantity """ - value = str2bool(value) - q = Q(shipped__gte=F('quantity')) - if value: - queryset = queryset.filter(q) + if str2bool(value): + return queryset.filter(q) else: - queryset = queryset.exclude(q) - - return queryset + return queryset.exclude(q) -class SalesOrderLineItemList(APIDownloadMixin, ListCreateAPI): - """API endpoint for accessing a list of SalesOrderLineItem objects.""" +class SalesOrderLineItemMixin: + """Mixin class for SalesOrderLineItem endpoints""" queryset = models.SalesOrderLineItem.objects.all() serializer_class = serializers.SalesOrderLineItemSerializer - filterset_class = SalesOrderLineItemFilter def get_serializer(self, *args, **kwargs): """Return serializer for this endpoint with extra data as requested""" @@ -933,6 +831,12 @@ class SalesOrderLineItemList(APIDownloadMixin, ListCreateAPI): return queryset + +class SalesOrderLineItemList(SalesOrderLineItemMixin, APIDownloadMixin, ListCreateAPI): + """API endpoint for accessing a list of SalesOrderLineItem objects.""" + + filterset_class = SalesOrderLineItemFilter + def download_queryset(self, queryset, export_format): """Download the requested queryset as a file""" @@ -963,6 +867,11 @@ class SalesOrderLineItemList(APIDownloadMixin, ListCreateAPI): ] +class SalesOrderLineItemDetail(SalesOrderLineItemMixin, RetrieveUpdateDestroyAPI): + """API endpoint for detail view of a SalesOrderLineItem object.""" + pass + + class SalesOrderLineItemMetadata(RetrieveUpdateAPI): """API endpoint for viewing / updating SalesOrderLineItem metadata.""" @@ -997,7 +906,7 @@ class SalesOrderExtraLineDetail(RetrieveUpdateDestroyAPI): class SalesOrderExtraLineItemMetadata(RetrieveUpdateAPI): - """API endpoint for viewing / updating SalesOrderExtraLine metadata.""" + """API endpoint for viewing / updating SalesOrderExtraLineItem metadata.""" def get_serializer(self, *args, **kwargs): """Return MetadataSerializer instance""" @@ -1006,21 +915,6 @@ class SalesOrderExtraLineItemMetadata(RetrieveUpdateAPI): queryset = models.SalesOrderExtraLine.objects.all() -class SalesOrderLineItemDetail(RetrieveUpdateDestroyAPI): - """API endpoint for detail view of a SalesOrderLineItem object.""" - - queryset = models.SalesOrderLineItem.objects.all() - serializer_class = serializers.SalesOrderLineItemSerializer - - def get_queryset(self, *args, **kwargs): - """Return annotated queryset for this endpoint""" - queryset = super().get_queryset(*args, **kwargs) - - queryset = serializers.SalesOrderLineItemSerializer.annotate_queryset(queryset) - - return queryset - - class SalesOrderContextMixin: """Mixin to add sales order object as serializer context variable.""" @@ -1161,10 +1055,6 @@ class SalesOrderAllocationList(ListAPI): rest_filters.DjangoFilterBackend, ] - # Default filterable fields - filterset_fields = [ - ] - class SalesOrderShipmentFilter(rest_filters.FilterSet): """Custom filterset for the SalesOrderShipmentList endpoint.""" @@ -1181,14 +1071,10 @@ class SalesOrderShipmentFilter(rest_filters.FilterSet): def filter_shipped(self, queryset, name, value): """Filter SalesOrder list by 'shipped' status (boolean)""" - value = str2bool(value) - - if value: - queryset = queryset.exclude(shipment_date=None) + if str2bool(value): + return queryset.exclude(shipment_date=None) else: - queryset = queryset.filter(shipment_date=None) - - return queryset + return queryset.filter(shipment_date=None) class SalesOrderShipmentList(ListCreateAPI): @@ -1263,6 +1149,291 @@ class PurchaseOrderAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI): serializer_class = serializers.PurchaseOrderAttachmentSerializer +class ReturnOrderFilter(OrderFilter): + """Custom API filters for the ReturnOrderList endpoint""" + + class Meta: + """Metaclass options""" + + model = models.ReturnOrder + fields = [ + 'customer', + ] + + +class ReturnOrderMixin: + """Mixin class for ReturnOrder endpoints""" + + queryset = models.ReturnOrder.objects.all() + serializer_class = serializers.ReturnOrderSerializer + + def get_serializer(self, *args, **kwargs): + """Return serializer instance for this endpoint""" + 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): + """Return annotated queryset for this endpoint""" + queryset = super().get_queryset(*args, **kwargs) + + queryset = queryset.prefetch_related( + 'customer', + ) + + queryset = serializers.ReturnOrderSerializer.annotate_queryset(queryset) + + return queryset + + +class ReturnOrderList(ReturnOrderMixin, APIDownloadMixin, ListCreateAPI): + """API endpoint for accessing a list of ReturnOrder objects""" + + filterset_class = ReturnOrderFilter + + def create(self, request, *args, **kwargs): + """Save user information on create.""" + serializer = self.get_serializer(data=self.clean_data(request.data)) + serializer.is_valid(raise_exception=True) + + item = serializer.save() + item.created_by = request.user + item.save() + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + + def download_queryset(self, queryset, export_format): + """Download this queryset as a file""" + + dataset = ReturnOrderResource().export(queryset=queryset) + filedata = dataset.export(export_format) + filename = f"InvenTree_ReturnOrders.{export_format}" + + return DownloadFile(filedata, filename) + + filter_backends = [ + rest_filters.DjangoFilterBackend, + filters.SearchFilter, + InvenTreeOrderingFilter, + ] + + ordering_field_aliases = { + 'reference': ['reference_int', 'reference'], + } + + ordering_fields = [ + 'creation_date', + 'reference', + 'customer__name', + 'customer_reference', + 'line_items', + 'status', + 'target_date', + ] + + search_fields = [ + 'customer__name', + 'reference', + 'description', + 'customer_reference', + ] + + ordering = '-reference' + + +class ReturnOrderDetail(ReturnOrderMixin, RetrieveUpdateDestroyAPI): + """API endpoint for detail view of a single ReturnOrder object""" + pass + + +class ReturnOrderContextMixin: + """Simple mixin class to add a ReturnOrder to the serializer context""" + + queryset = models.ReturnOrder.objects.all() + + def get_serializer_context(self): + """Add the PurchaseOrder object to the serializer context.""" + context = super().get_serializer_context() + + # Pass the ReturnOrder instance through to the serializer for validation + try: + context['order'] = models.ReturnOrder.objects.get(pk=self.kwargs.get('pk', None)) + except Exception: + pass + + context['request'] = self.request + + return context + + +class ReturnOrderCancel(ReturnOrderContextMixin, CreateAPI): + """API endpoint to cancel a ReturnOrder""" + serializer_class = serializers.ReturnOrderCancelSerializer + + +class ReturnOrderComplete(ReturnOrderContextMixin, CreateAPI): + """API endpoint to complete a ReturnOrder""" + serializer_class = serializers.ReturnOrderCompleteSerializer + + +class ReturnOrderIssue(ReturnOrderContextMixin, CreateAPI): + """API endpoint to issue (place) a ReturnOrder""" + serializer_class = serializers.ReturnOrderIssueSerializer + + +class ReturnOrderReceive(ReturnOrderContextMixin, CreateAPI): + """API endpoint to receive items against a ReturnOrder""" + + queryset = models.ReturnOrder.objects.none() + serializer_class = serializers.ReturnOrderReceiveSerializer + + +class ReturnOrderLineItemFilter(LineItemFilter): + """Custom filters for the ReturnOrderLineItemList endpoint""" + + class Meta: + """Metaclass options""" + price_field = 'price' + model = models.ReturnOrderLineItem + fields = [ + 'order', + 'item', + ] + + outcome = rest_filters.NumberFilter(label='outcome') + + received = rest_filters.BooleanFilter(label='received', method='filter_received') + + def filter_received(self, queryset, name, value): + """Filter by 'received' field""" + + if str2bool(value): + return queryset.exclude(received_date=None) + else: + return queryset.filter(received_date=None) + + +class ReturnOrderLineItemMixin: + """Mixin class for ReturnOrderLineItem endpoints""" + + queryset = models.ReturnOrderLineItem.objects.all() + serializer_class = serializers.ReturnOrderLineItemSerializer + + def get_serializer(self, *args, **kwargs): + """Return serializer for this endpoint with extra data as requested""" + + try: + params = self.request.query_params + + kwargs['order_detail'] = str2bool(params.get('order_detail', False)) + kwargs['item_detail'] = str2bool(params.get('item_detail', True)) + kwargs['part_detail'] = str2bool(params.get('part_detail', False)) + except AttributeError: + pass + + kwargs['context'] = self.get_serializer_context() + + return self.serializer_class(*args, **kwargs) + + def get_queryset(self, *args, **kwargs): + """Return annotated queryset for this endpoint""" + + queryset = super().get_queryset(*args, **kwargs) + + queryset = queryset.prefetch_related( + 'order', + 'item', + 'item__part', + ) + + return queryset + + +class ReturnOrderLineItemList(ReturnOrderLineItemMixin, APIDownloadMixin, ListCreateAPI): + """API endpoint for accessing a list of ReturnOrderLineItemList objects""" + + filterset_class = ReturnOrderLineItemFilter + + def download_queryset(self, queryset, export_format): + """Download the requested queryset as a file""" + + raise NotImplementedError("download_queryset not yet implemented for this endpoint") + + filter_backends = [ + rest_filters.DjangoFilterBackend, + filters.SearchFilter, + filters.OrderingFilter, + ] + + ordering_fields = [ + 'reference', + 'target_date', + 'received_date', + ] + + search_fields = [ + 'item_serial', + 'item__part__name', + 'item__part__description', + 'reference', + ] + + +class ReturnOrderLineItemDetail(ReturnOrderLineItemMixin, RetrieveUpdateDestroyAPI): + """API endpoint for detail view of a ReturnOrderLineItem object""" + pass + + +class ReturnOrderExtraLineList(GeneralExtraLineList, ListCreateAPI): + """API endpoint for accessing a list of ReturnOrderExtraLine objects""" + + queryset = models.ReturnOrderExtraLine.objects.all() + serializer_class = serializers.ReturnOrderExtraLineSerializer + + def download_queryset(self, queryset, export_format): + """Download this queryset as a file""" + + raise NotImplementedError("download_queryset not yet implemented") + + +class ReturnOrderExtraLineDetail(RetrieveUpdateDestroyAPI): + """API endpoint for detail view of a ReturnOrderExtraLine object""" + + queryset = models.ReturnOrderExtraLine.objects.all() + serializer_class = serializers.ReturnOrderExtraLineSerializer + + +class ReturnOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView): + """API endpoint for listing (and creating) a ReturnOrderAttachment (file upload)""" + + queryset = models.ReturnOrderAttachment.objects.all() + serializer_class = serializers.ReturnOrderAttachmentSerializer + + filter_backends = [ + rest_filters.DjangoFilterBackend, + ] + + filterset_fields = [ + 'order', + ] + + +class ReturnOrderAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI): + """Detail endpoint for the ReturnOrderAttachment model""" + + queryset = models.ReturnOrderAttachment.objects.all() + serializer_class = serializers.ReturnOrderAttachmentSerializer + + class OrderCalendarExport(ICalFeed): """Calendar export for Purchase/Sales Orders @@ -1424,7 +1595,7 @@ order_api_urls = [ ])), # Individual purchase order detail URLs - re_path(r'^(?P\d+)/', include([ + path(r'/', include([ re_path(r'^cancel/', PurchaseOrderCancel.as_view(), name='api-po-cancel'), re_path(r'^complete/', PurchaseOrderComplete.as_view(), name='api-po-complete'), re_path(r'^issue/', PurchaseOrderIssue.as_view(), name='api-po-issue'), @@ -1465,7 +1636,7 @@ order_api_urls = [ ])), re_path(r'^shipment/', include([ - re_path(r'^(?P\d+)/', include([ + path(r'/', include([ path('ship/', SalesOrderShipmentComplete.as_view(), name='api-so-shipment-ship'), re_path(r'^metadata/', SalesOrderShipmentMetadata.as_view(), name='api-so-shipment-metadata'), re_path(r'^.*$', SalesOrderShipmentDetail.as_view(), name='api-so-shipment-detail'), @@ -1474,7 +1645,7 @@ order_api_urls = [ ])), # Sales order detail view - re_path(r'^(?P\d+)/', include([ + path(r'/', include([ re_path(r'^allocate/', SalesOrderAllocate.as_view(), name='api-so-allocate'), re_path(r'^allocate-serials/', SalesOrderAllocateSerials.as_view(), name='api-so-allocate-serials'), re_path(r'^cancel/', SalesOrderCancel.as_view(), name='api-so-cancel'), @@ -1513,6 +1684,39 @@ order_api_urls = [ re_path(r'^.*$', SalesOrderAllocationList.as_view(), name='api-so-allocation-list'), ])), + # API endpoints for return orders + re_path(r'^ro/', include([ + + re_path(r'^attachment/', include([ + path('/', ReturnOrderAttachmentDetail.as_view(), name='api-return-order-attachment-detail'), + re_path(r'^.*$', ReturnOrderAttachmentList.as_view(), name='api-return-order-attachment-list'), + ])), + + # Return Order detail endpoints + path('/', include([ + re_path(r'cancel/', ReturnOrderCancel.as_view(), name='api-return-order-cancel'), + re_path(r'complete/', ReturnOrderComplete.as_view(), name='api-return-order-complete'), + re_path(r'issue/', ReturnOrderIssue.as_view(), name='api-return-order-issue'), + re_path(r'receive/', ReturnOrderReceive.as_view(), name='api-return-order-receive'), + re_path(r'.*$', ReturnOrderDetail.as_view(), name='api-return-order-detail'), + ])), + + # Return Order list + re_path(r'^.*$', ReturnOrderList.as_view(), name='api-return-order-list'), + ])), + + # API endpoints for reutrn order lines + re_path(r'^ro-line/', include([ + path('/', ReturnOrderLineItemDetail.as_view(), name='api-return-order-line-detail'), + path('', ReturnOrderLineItemList.as_view(), name='api-return-order-line-list'), + ])), + + # API endpoints for return order extra line + re_path(r'^ro-extra-line/', include([ + path('/', ReturnOrderExtraLineDetail.as_view(), name='api-return-order-extra-line-detail'), + path('', ReturnOrderExtraLineList.as_view(), name='api-return-order-extra-line-list'), + ])), + # API endpoint for subscribing to ICS calendar of purchase/sales orders re_path(r'^calendar/(?Ppurchase-order|sales-order)/calendar.ics', OrderCalendarExport(), name='api-po-so-calendar'), ] diff --git a/InvenTree/order/fixtures/return_order.yaml b/InvenTree/order/fixtures/return_order.yaml new file mode 100644 index 0000000000..1f96ea1e2c --- /dev/null +++ b/InvenTree/order/fixtures/return_order.yaml @@ -0,0 +1,53 @@ +- model: order.returnorder + pk: 1 + fields: + reference: 'RMA-001' + reference_int: 1 + description: 'RMA from a customer' + customer: 4 + status: 10 # Pending + +- model: order.returnorder + pk: 2 + fields: + reference: 'RMA-002' + reference_int: 2 + description: 'RMA from a customer' + customer: 4 + status: 20 # In Progress + +- model: order.returnorder + pk: 3 + fields: + reference: 'RMA-003' + reference_int: 3 + description: 'RMA from a customer' + customer: 4 + status: 30 # Complete + +- model: order.returnorder + pk: 4 + fields: + reference: 'RMA-004' + reference_int: 4 + description: 'RMA from a customer' + customer: 5 + status: 40 # Cancelled + +- model: order.returnorder + pk: 5 + fields: + reference: 'RMA-005' + reference_int: 5 + description: 'RMA from a customer' + customer: 5 + status: 20 # In progress + +- model: order.returnorder + pk: 6 + fields: + reference: 'RMA-006' + reference_int: 6 + description: 'RMA from a customer' + customer: 5 + status: 10 # Pending diff --git a/InvenTree/order/migrations/0081_auto_20230314_0725.py b/InvenTree/order/migrations/0081_auto_20230314_0725.py new file mode 100644 index 0000000000..719a7a5037 --- /dev/null +++ b/InvenTree/order/migrations/0081_auto_20230314_0725.py @@ -0,0 +1,64 @@ +# Generated by Django 3.2.18 on 2023-03-14 07:25 + +import InvenTree.fields +import InvenTree.models +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import order.validators + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('company', '0054_companyattachment'), + ('users', '0006_alter_ruleset_name'), + ('order', '0080_auto_20230317_0816'), + ] + + operations = [ + migrations.CreateModel( + name='ReturnOrder', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('metadata', models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata')), + ('reference_int', models.BigIntegerField(default=0)), + ('description', models.CharField(help_text='Order description', max_length=250, verbose_name='Description')), + ('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external page', verbose_name='Link')), + ('creation_date', models.DateField(blank=True, null=True, verbose_name='Creation Date')), + ('notes', InvenTree.fields.InvenTreeNotesField(blank=True, help_text='Order notes', max_length=50000, null=True, verbose_name='Notes')), + ('reference', models.CharField(default=order.validators.generate_next_return_order_reference, help_text='Return Order reference', max_length=64, unique=True, validators=[order.validators.validate_return_order_reference], verbose_name='Reference')), + ('status', models.PositiveIntegerField(choices=[(10, 'Pending'), (30, 'Complete'), (40, 'Cancelled')], default=10, help_text='Return order status', verbose_name='Status')), + ('customer_reference', models.CharField(blank=True, help_text='Customer order reference code', max_length=64, verbose_name='Customer Reference ')), + ('issue_date', models.DateField(blank=True, help_text='Date order was issued', null=True, verbose_name='Issue Date')), + ('complete_date', models.DateField(blank=True, help_text='Date order was completed', null=True, verbose_name='Completion Date')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('customer', models.ForeignKey(help_text='Company from which items are being returned', limit_choices_to={'is_customer': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sales_orders', to='company.company', verbose_name='Customer')), + ('responsible', models.ForeignKey(blank=True, help_text='User or group responsible for this order', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='users.owner', verbose_name='Responsible')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AlterField( + model_name='salesorder', + name='customer', + field=models.ForeignKey(help_text='Company to which the items are being sold', limit_choices_to={'is_customer': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='return_orders', to='company.company', verbose_name='Customer'), + ), + migrations.CreateModel( + name='ReturnOrderAttachment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment')), + ('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link')), + ('comment', models.CharField(blank=True, help_text='File comment', max_length=100, verbose_name='Comment')), + ('upload_date', models.DateField(auto_now_add=True, null=True, verbose_name='upload date')), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='order.returnorder')), + ('user', models.ForeignKey(blank=True, help_text='User', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/InvenTree/order/migrations/0082_auto_20230314_1259.py b/InvenTree/order/migrations/0082_auto_20230314_1259.py new file mode 100644 index 0000000000..8a9061406c --- /dev/null +++ b/InvenTree/order/migrations/0082_auto_20230314_1259.py @@ -0,0 +1,30 @@ +# Generated by Django 3.2.18 on 2023-03-14 12:59 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('company', '0054_companyattachment'), + ('order', '0081_auto_20230314_0725'), + ] + + operations = [ + migrations.AddField( + model_name='purchaseorder', + name='contact', + field=models.ForeignKey(blank=True, help_text='Point of contact for this order', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='company.contact', verbose_name='Contact'), + ), + migrations.AddField( + model_name='returnorder', + name='contact', + field=models.ForeignKey(blank=True, help_text='Point of contact for this order', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='company.contact', verbose_name='Contact'), + ), + migrations.AddField( + model_name='salesorder', + name='contact', + field=models.ForeignKey(blank=True, help_text='Point of contact for this order', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='company.contact', verbose_name='Contact'), + ), + ] diff --git a/InvenTree/order/migrations/0083_returnorderextraline.py b/InvenTree/order/migrations/0083_returnorderextraline.py new file mode 100644 index 0000000000..ba1d8c2812 --- /dev/null +++ b/InvenTree/order/migrations/0083_returnorderextraline.py @@ -0,0 +1,35 @@ +# Generated by Django 3.2.18 on 2023-03-16 02:52 + +import InvenTree.fields +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import djmoney.models.fields +import djmoney.models.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0082_auto_20230314_1259'), + ] + + operations = [ + migrations.CreateModel( + name='ReturnOrderExtraLine', + 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)], verbose_name='Quantity')), + ('reference', models.CharField(blank=True, help_text='Line item reference', max_length=100, verbose_name='Reference')), + ('notes', models.CharField(blank=True, help_text='Line item notes', max_length=500, verbose_name='Notes')), + ('target_date', models.DateField(blank=True, help_text='Target date for this line item (leave blank to use the target date from the order)', null=True, verbose_name='Target Date')), + ('context', models.JSONField(blank=True, help_text='Additional context for this line', null=True, verbose_name='Context')), + ('price_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)), + ('price', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Unit price', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Price')), + ('order', models.ForeignKey(help_text='Return Order', on_delete=django.db.models.deletion.CASCADE, related_name='extra_lines', to='order.returnorder', verbose_name='Order')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/InvenTree/order/migrations/0084_auto_20230321_1111.py b/InvenTree/order/migrations/0084_auto_20230321_1111.py new file mode 100644 index 0000000000..aa96291e33 --- /dev/null +++ b/InvenTree/order/migrations/0084_auto_20230321_1111.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.18 on 2023-03-21 11:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0083_returnorderextraline'), + ] + + operations = [ + migrations.AddField( + model_name='returnorder', + name='target_date', + field=models.DateField(blank=True, help_text='Expected date for order delivery. Order will be overdue after this date.', null=True, verbose_name='Target Date'), + ), + migrations.AlterField( + model_name='purchaseorder', + name='target_date', + field=models.DateField(blank=True, help_text='Expected date for order delivery. Order will be overdue after this date.', null=True, verbose_name='Target Date'), + ), + migrations.AlterField( + model_name='returnorder', + name='status', + field=models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'In Progress'), (30, 'Complete'), (40, 'Cancelled')], default=10, help_text='Return order status', verbose_name='Status'), + ), + migrations.AlterField( + model_name='salesorder', + name='target_date', + field=models.DateField(blank=True, help_text='Expected date for order delivery. Order will be overdue after this date.', null=True, verbose_name='Target Date'), + ), + ] diff --git a/InvenTree/order/migrations/0085_auto_20230322_1056.py b/InvenTree/order/migrations/0085_auto_20230322_1056.py new file mode 100644 index 0000000000..9a5f4a652f --- /dev/null +++ b/InvenTree/order/migrations/0085_auto_20230322_1056.py @@ -0,0 +1,49 @@ +# Generated by Django 3.2.18 on 2023-03-22 10:56 + +import InvenTree.fields +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import djmoney.models.fields +import djmoney.models.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0095_stocklocation_external'), + ('order', '0084_auto_20230321_1111'), + ] + + operations = [ + migrations.AddField( + model_name='returnorder', + name='total_price', + field=InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Total price for this order', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Total Price'), + ), + migrations.AddField( + model_name='returnorder', + name='total_price_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3), + ), + migrations.CreateModel( + name='ReturnOrderLineItem', + 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)], verbose_name='Quantity')), + ('reference', models.CharField(blank=True, help_text='Line item reference', max_length=100, verbose_name='Reference')), + ('notes', models.CharField(blank=True, help_text='Line item notes', max_length=500, verbose_name='Notes')), + ('target_date', models.DateField(blank=True, help_text='Target date for this line item (leave blank to use the target date from the order)', null=True, verbose_name='Target Date')), + ('received_date', models.DateField(blank=True, help_text='The date this this return item was received', null=True, verbose_name='Received Date')), + ('outcome', models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Return'), (30, 'Repair'), (50, 'Refund'), (40, 'Replace'), (60, 'Reject')], default=10, help_text='Outcome for this line item', verbose_name='Outcome')), + ('price_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)), + ('price', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Cost associated with return or repair for this line item', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Price')), + ('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external page', verbose_name='Link')), + ('item', models.ForeignKey(help_text='Select item to return from customer', on_delete=django.db.models.deletion.CASCADE, related_name='return_order_lines', to='stock.stockitem', verbose_name='Item')), + ('order', models.ForeignKey(help_text='Return Order', on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='order.returnorder', verbose_name='Order')), + ], + options={ + 'unique_together': {('order', 'item')}, + }, + ), + ] diff --git a/InvenTree/order/migrations/0086_auto_20230323_1108.py b/InvenTree/order/migrations/0086_auto_20230323_1108.py new file mode 100644 index 0000000000..3cfcd2f629 --- /dev/null +++ b/InvenTree/order/migrations/0086_auto_20230323_1108.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.18 on 2023-03-23 11:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0085_auto_20230322_1056'), + ] + + operations = [ + migrations.AddField( + model_name='returnorderextraline', + name='metadata', + field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'), + ), + migrations.AddField( + model_name='returnorderlineitem', + name='metadata', + field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'), + ), + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 82be199475..fb8b65e6e0 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -26,25 +26,110 @@ import InvenTree.helpers import InvenTree.ready import InvenTree.tasks import order.validators +import stock.models +import users.models as UserModels from common.notifications import InvenTreeNotificationBodies from common.settings import currency_code_default -from company.models import Company, SupplierPart +from company.models import Company, Contact, SupplierPart from InvenTree.exceptions import log_error from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeNotesField, InvenTreeURLField, RoundingDecimalField) from InvenTree.helpers import decimal2string, getSetting, notify_responsible from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin -from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus, +from InvenTree.status_codes import (PurchaseOrderStatus, ReturnOrderLineStatus, + ReturnOrderStatus, SalesOrderStatus, StockHistoryCode, StockStatus) from part import models as PartModels from plugin.events import trigger_event from plugin.models import MetadataMixin -from stock import models as stock_models -from users import models as UserModels logger = logging.getLogger('inventree') +class TotalPriceMixin(models.Model): + """Mixin which provides 'total_price' field for an order""" + + class Meta: + """Meta for MetadataMixin.""" + abstract = True + + def save(self, *args, **kwargs): + """Update the total_price field when saved""" + + # Recalculate total_price for this order + self.update_total_price(commit=False) + super().save(*args, **kwargs) + + total_price = InvenTreeModelMoneyField( + null=True, blank=True, + allow_negative=False, + verbose_name=_('Total Price'), + help_text=_('Total price for this order') + ) + + def update_total_price(self, commit=True): + """Recalculate and save the total_price for this order""" + + self.total_price = self.calculate_total_price() + + if commit: + self.save() + + def calculate_total_price(self, target_currency=None): + """Calculates the total price of all order lines, and converts to the specified target currency. + + If not specified, the default system currency is used. + + If currency conversion fails (e.g. there are no valid conversion rates), + then we simply return zero, rather than attempting some other calculation. + """ + # Set default - see B008 + if target_currency is None: + target_currency = currency_code_default() + + total = Money(0, target_currency) + + # order items + for line in self.lines.all(): + + if not line.price: + continue + + try: + total += line.quantity * convert_money(line.price, target_currency) + except MissingRate: + # Record the error, try to press on + kind, info, data = sys.exc_info() + + log_error('order.calculate_total_price') + logger.error(f"Missing exchange rate for '{target_currency}'") + + # Return None to indicate the calculated price is invalid + return None + + # extra items + for line in self.extra_lines.all(): + + if not line.price: + continue + + try: + total += line.quantity * convert_money(line.price, target_currency) + except MissingRate: + # Record the error, try to press on + + log_error('order.calculate_total_price') + logger.error(f"Missing exchange rate for '{target_currency}'") + + # Return None to indicate the calculated price is invalid + return None + + # set decimal-places + total.decimal_places = 4 + + return total + + class Order(MetadataMixin, ReferenceIndexingMixin): """Abstract model for an order. @@ -78,15 +163,49 @@ class Order(MetadataMixin, ReferenceIndexingMixin): if not self.creation_date: self.creation_date = datetime.now().date() - # Recalculate total_price for this order - self.update_total_price(commit=False) - super().save(*args, **kwargs) + def clean(self): + """Custom clean method for the generic order class""" + + super().clean() + + # Check that the referenced 'contact' matches the correct 'company' + if self.company and self.contact: + if self.contact.company != self.company: + raise ValidationError({ + "contact": _("Contact does not match selected company") + }) + + @classmethod + def overdue_filter(cls): + """A generic implementation of an 'overdue' filter for the Model class + + It requires any subclasses to implement the get_status_class() class method + """ + + today = datetime.now().date() + return Q(status__in=cls.get_status_class().OPEN) & ~Q(target_date=None) & Q(target_date__lt=today) + + @property + def is_overdue(self): + """Method to determine if this order is overdue. + + Makes use of the overdue_filter() method to avoid code duplication + """ + + return self.__class__.objects.filter(pk=self.pk).filter(self.__class__.overdue_filter()).exists() + description = models.CharField(max_length=250, verbose_name=_('Description'), help_text=_('Order description')) link = InvenTreeURLField(blank=True, verbose_name=_('Link'), help_text=_('Link to external page')) + target_date = models.DateField( + blank=True, null=True, + verbose_name=_('Target Date'), + help_text=_('Expected date for order delivery. Order will be overdue after this date.'), + ) + creation_date = models.DateField(blank=True, null=True, verbose_name=_('Creation Date')) created_by = models.ForeignKey(User, @@ -105,84 +224,25 @@ class Order(MetadataMixin, ReferenceIndexingMixin): related_name='+', ) - notes = InvenTreeNotesField(help_text=_('Order notes')) - - total_price = InvenTreeModelMoneyField( - null=True, blank=True, - allow_negative=False, - verbose_name=_('Total Price'), - help_text=_('Total price for this order') + contact = models.ForeignKey( + Contact, + on_delete=models.SET_NULL, + blank=True, null=True, + verbose_name=_('Contact'), + help_text=_('Point of contact for this order'), + related_name='+', ) - def update_total_price(self, commit=True): - """Recalculate and save the total_price for this order""" + notes = InvenTreeNotesField(help_text=_('Order notes')) - self.total_price = self.calculate_total_price() + @classmethod + def get_status_class(cls): + """Return the enumeration class which represents the 'status' field for this model""" - if commit: - self.save() - - def calculate_total_price(self, target_currency=None): - """Calculates the total price of all order lines, and converts to the specified target currency. - - If not specified, the default system currency is used. - - If currency conversion fails (e.g. there are no valid conversion rates), - then we simply return zero, rather than attempting some other calculation. - """ - # Set default - see B008 - if target_currency is None: - target_currency = currency_code_default() - - total = Money(0, target_currency) - - # gather name reference - price_ref_tag = 'sale_price' if isinstance(self, SalesOrder) else 'purchase_price' - - # order items - for line in self.lines.all(): - - price_ref = getattr(line, price_ref_tag) - - if not price_ref: - continue - - try: - total += line.quantity * convert_money(price_ref, target_currency) - except MissingRate: - # Record the error, try to press on - kind, info, data = sys.exc_info() - - log_error('order.calculate_total_price') - logger.error(f"Missing exchange rate for '{target_currency}'") - - # Return None to indicate the calculated price is invalid - return None - - # extra items - for line in self.extra_lines.all(): - - if not line.price: - continue - - try: - total += line.quantity * convert_money(line.price, target_currency) - except MissingRate: - # Record the error, try to press on - - log_error('order.calculate_total_price') - logger.error(f"Missing exchange rate for '{target_currency}'") - - # Return None to indicate the calculated price is invalid - return None - - # set decimal-places - total.decimal_places = 4 - - return total + raise NotImplementedError(f"get_status_class() not implemented for {__class__}") -class PurchaseOrder(Order): +class PurchaseOrder(TotalPriceMixin, Order): """A PurchaseOrder represents goods shipped inwards from an external supplier. Attributes: @@ -192,14 +252,23 @@ class PurchaseOrder(Order): target_date: Expected delivery target date for PurchaseOrder completion (optional) """ + def get_absolute_url(self): + """Get the 'web' URL for this order""" + return reverse('po-detail', kwargs={'pk': self.pk}) + @staticmethod def get_api_url(): """Return the API URL associated with the PurchaseOrder model""" return reverse('api-po-list') + @classmethod + def get_status_class(cls): + """Return the PurchasOrderStatus class""" + return PurchaseOrderStatus + @classmethod def api_defaults(cls, request): - """Return default values for thsi model when issuing an API OPTIONS request""" + """Return default values for this model when issuing an API OPTIONS request""" defaults = { 'reference': order.validators.generate_next_purchase_order_reference(), @@ -207,8 +276,6 @@ class PurchaseOrder(Order): return defaults - OVERDUE_FILTER = Q(status__in=PurchaseOrderStatus.OPEN) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date()) - # Global setting for specifying reference pattern REFERENCE_PATTERN_SETTING = 'PURCHASEORDER_REFERENCE_PATTERN' @@ -283,6 +350,11 @@ class PurchaseOrder(Order): help_text=_('Company from which the items are being ordered') ) + @property + def company(self): + """Accessor helper for Order base class""" + return self.supplier + supplier_reference = models.CharField(max_length=64, blank=True, verbose_name=_('Supplier Reference'), help_text=_("Supplier order reference code")) received_by = models.ForeignKey( @@ -299,22 +371,12 @@ class PurchaseOrder(Order): help_text=_('Date order was issued') ) - target_date = models.DateField( - blank=True, null=True, - verbose_name=_('Target Delivery Date'), - help_text=_('Expected date for order delivery. Order will be overdue after this date.'), - ) - complete_date = models.DateField( blank=True, null=True, verbose_name=_('Completion Date'), help_text=_('Date order was completed') ) - def get_absolute_url(self): - """Return the web URL of the detail view for this order""" - return reverse('po-detail', kwargs={'pk': self.id}) - @transaction.atomic def add_line_item(self, supplier_part, quantity, group: bool = True, reference: str = '', purchase_price=None): """Add a new line item to this purchase order. @@ -417,17 +479,6 @@ class PurchaseOrder(Order): """Return True if the PurchaseOrder is 'pending'""" return self.status == PurchaseOrderStatus.PENDING - @property - def is_overdue(self): - """Returns True if this PurchaseOrder is "overdue". - - Makes use of the OVERDUE_FILTER to avoid code duplication. - """ - query = PurchaseOrder.objects.filter(pk=self.pk) - query = query.filter(PurchaseOrder.OVERDUE_FILTER) - - return query.exists() - def can_cancel(self): """A PurchaseOrder can only be cancelled under the following circumstances. @@ -534,7 +585,7 @@ class PurchaseOrder(Order): for sn in serials: - stock = stock_models.StockItem( + item = stock.models.StockItem( part=line.part.part, supplier_part=line.part, location=location, @@ -547,14 +598,14 @@ class PurchaseOrder(Order): barcode_hash=barcode_hash ) - stock.save(add_note=False) + item.save(add_note=False) tracking_info = { 'status': status, 'purchaseorder': self.pk, } - stock.add_tracking_entry( + item.add_tracking_entry( StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER, user, notes=notes, @@ -595,20 +646,23 @@ def after_save_purchase_order(sender, instance: PurchaseOrder, created: bool, ** notify_responsible(instance, sender, exclude=instance.created_by) -class SalesOrder(Order): - """A SalesOrder represents a list of goods shipped outwards to a customer. +class SalesOrder(TotalPriceMixin, 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 - target_date: Target date for SalesOrder completion (optional) - """ + def get_absolute_url(self): + """Get the 'web' URL for this order""" + return reverse('so-detail', kwargs={'pk': self.pk}) @staticmethod def get_api_url(): """Return the API URL associated with the SalesOrder model""" return reverse('api-so-list') + @classmethod + def get_status_class(cls): + """Return the SalesOrderStatus class""" + return SalesOrderStatus + @classmethod def api_defaults(cls, request): """Return default values for this model when issuing an API OPTIONS request""" @@ -618,8 +672,6 @@ class SalesOrder(Order): return defaults - OVERDUE_FILTER = Q(status__in=SalesOrderStatus.OPEN) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date()) - # Global setting for specifying reference pattern REFERENCE_PATTERN_SETTING = 'SALESORDER_REFERENCE_PATTERN' @@ -663,10 +715,6 @@ class SalesOrder(Order): return f"{self.reference} - {self.customer.name if self.customer else _('deleted')}" - def get_absolute_url(self): - """Return the web URL for the detail view of this order""" - return reverse('so-detail', kwargs={'pk': self.id}) - reference = models.CharField( unique=True, max_length=64, @@ -684,13 +732,21 @@ class SalesOrder(Order): on_delete=models.SET_NULL, null=True, limit_choices_to={'is_customer': True}, - related_name='sales_orders', + related_name='return_orders', verbose_name=_('Customer'), help_text=_("Company to which the items are being sold"), ) - status = models.PositiveIntegerField(default=SalesOrderStatus.PENDING, choices=SalesOrderStatus.items(), - verbose_name=_('Status'), help_text=_('Purchase order status')) + @property + def company(self): + """Accessor helper for Order base""" + return self.customer + + status = models.PositiveIntegerField( + default=SalesOrderStatus.PENDING, + choices=SalesOrderStatus.items(), + verbose_name=_('Status'), help_text=_('Purchase order status') + ) @property def status_text(self): @@ -699,12 +755,6 @@ class SalesOrder(Order): customer_reference = models.CharField(max_length=64, blank=True, verbose_name=_('Customer Reference '), help_text=_("Customer order reference code")) - target_date = models.DateField( - null=True, blank=True, - verbose_name=_('Target completion date'), - help_text=_('Target date for order completion. Order will be overdue after this date.') - ) - shipment_date = models.DateField(blank=True, null=True, verbose_name=_('Shipment Date')) shipped_by = models.ForeignKey( @@ -715,17 +765,6 @@ class SalesOrder(Order): verbose_name=_('shipped by') ) - @property - def is_overdue(self): - """Returns true if this SalesOrder is "overdue". - - Makes use of the OVERDUE_FILTER to avoid code duplication. - """ - query = SalesOrder.objects.filter(pk=self.pk) - query = query.filter(SalesOrder.OVERDUE_FILTER) - - return query.exists() - @property def is_pending(self): """Return True if this order is 'pending'""" @@ -1121,9 +1160,9 @@ class PurchaseOrderLineItem(OrderLineItem): stock items location will be reported as the location for the entire line. """ - for stock in stock_models.StockItem.objects.filter(supplier_part=self.part, purchase_order=self.order): - if stock.location: - return stock.location + for item in stock.models.StockItem.objects.filter(supplier_part=self.part, purchase_order=self.order): + if item.location: + return item.location if self.destination: return self.destination if self.part and self.part.part and self.part.part.default_location: @@ -1420,7 +1459,11 @@ class SalesOrderExtraLine(OrderExtraLine): """Return the API URL associated with the SalesOrderExtraLine model""" return reverse('api-so-extra-line-list') - order = models.ForeignKey(SalesOrder, on_delete=models.CASCADE, related_name='extra_lines', verbose_name=_('Order'), help_text=_('Sales Order')) + order = models.ForeignKey( + SalesOrder, on_delete=models.CASCADE, + related_name='extra_lines', + verbose_name=_('Order'), help_text=_('Sales Order') + ) class SalesOrderAllocation(models.Model): @@ -1455,7 +1498,7 @@ class SalesOrderAllocation(models.Model): try: if not self.item: raise ValidationError({'item': _('Stock item has not been assigned')}) - except stock_models.StockItem.DoesNotExist: + except stock.models.StockItem.DoesNotExist: raise ValidationError({'item': _('Stock item has not been assigned')}) try: @@ -1547,3 +1590,299 @@ class SalesOrderAllocation(models.Model): # (It may have changed if the stock was split) self.item = item self.save() + + +class ReturnOrder(TotalPriceMixin, Order): + """A ReturnOrder represents goods returned from a customer, e.g. an RMA or warranty + + Attributes: + customer: Reference to the customer + sales_order: Reference to an existing SalesOrder (optional) + status: The status of the order (refer to status_codes.ReturnOrderStatus) + """ + + def get_absolute_url(self): + """Get the 'web' URL for this order""" + return reverse('return-order-detail', kwargs={'pk': self.pk}) + + @staticmethod + def get_api_url(): + """Return the API URL associated with the ReturnOrder model""" + return reverse('api-return-order-list') + + @classmethod + def get_status_class(cls): + """Return the ReturnOrderStatus class""" + return ReturnOrderStatus + + @classmethod + def api_defaults(cls, request): + """Return default values for this model when issuing an API OPTIONS request""" + defaults = { + 'reference': order.validators.generate_next_return_order_reference(), + } + + return defaults + + REFERENCE_PATTERN_SETTING = 'RETURNORDER_REFERENCE_PATTERN' + + def __str__(self): + """Render a string representation of this ReturnOrder""" + + return f"{self.reference} - {self.customer.name if self.customer else _('no customer')}" + + reference = models.CharField( + unique=True, + max_length=64, + blank=False, + verbose_name=_('Reference'), + help_text=_('Return Order reference'), + default=order.validators.generate_next_return_order_reference, + validators=[ + order.validators.validate_return_order_reference, + ] + ) + + customer = models.ForeignKey( + Company, + on_delete=models.SET_NULL, + null=True, + limit_choices_to={'is_customer': True}, + related_name='sales_orders', + verbose_name=_('Customer'), + help_text=_("Company from which items are being returned"), + ) + + @property + def company(self): + """Accessor helper for Order base class""" + return self.customer + + status = models.PositiveIntegerField( + default=ReturnOrderStatus.PENDING, + choices=ReturnOrderStatus.items(), + verbose_name=_('Status'), help_text=_('Return order status') + ) + + customer_reference = models.CharField( + max_length=64, blank=True, + verbose_name=_('Customer Reference '), + help_text=_("Customer order reference code") + ) + + issue_date = models.DateField( + blank=True, null=True, + verbose_name=_('Issue Date'), + help_text=_('Date order was issued') + ) + + complete_date = models.DateField( + blank=True, null=True, + verbose_name=_('Completion Date'), + help_text=_('Date order was completed') + ) + + @property + def is_pending(self): + """Return True if this order is pending""" + return self.status == ReturnOrderStatus.PENDING + + @property + def is_open(self): + """Return True if this order is outstanding""" + return self.status in ReturnOrderStatus.OPEN + + @property + def is_received(self): + """Return True if this order is fully received""" + return not self.lines.filter(received_date=None).exists() + + @transaction.atomic + def cancel_order(self): + """Cancel this ReturnOrder (if not already cancelled)""" + if self.status != ReturnOrderStatus.CANCELLED: + self.status = ReturnOrderStatus.CANCELLED + self.save() + + trigger_event('returnorder.cancelled', id=self.pk) + + @transaction.atomic + def complete_order(self): + """Complete this ReturnOrder (if not already completed)""" + + if self.status == ReturnOrderStatus.IN_PROGRESS: + self.status = ReturnOrderStatus.COMPLETE + self.complete_date = datetime.now().date() + self.save() + + trigger_event('returnorder.completed', id=self.pk) + + @transaction.atomic + def place_order(self): + """Issue this ReturnOrder (if currently pending)""" + + if self.status == ReturnOrderStatus.PENDING: + self.status = ReturnOrderStatus.IN_PROGRESS + self.issue_date = datetime.now().date() + self.save() + + trigger_event('returnorder.placed', id=self.pk) + + @transaction.atomic + def receive_line_item(self, line, location, user, note=''): + """Receive a line item against this ReturnOrder: + + - Transfers the StockItem to the specified location + - Marks the StockItem as "quarantined" + - Adds a tracking entry to the StockItem + - Removes the 'customer' reference from the StockItem + """ + + # Prevent an item from being "received" multiple times + if line.received_date is not None: + logger.warning("receive_line_item called with item already returned") + return + + stock_item = line.item + + deltas = { + 'status': StockStatus.QUARANTINED, + 'returnorder': self.pk, + 'location': location.pk, + } + + if stock_item.customer: + deltas['customer'] = stock_item.customer.pk + + # Update the StockItem + stock_item.status = StockStatus.QUARANTINED + stock_item.location = location + stock_item.customer = None + stock_item.sales_order = None + stock_item.save(add_note=False) + + # Add a tracking entry to the StockItem + stock_item.add_tracking_entry( + StockHistoryCode.RETURNED_AGAINST_RETURN_ORDER, + user, + notes=note, + deltas=deltas, + location=location, + returnorder=self, + ) + + # Update the LineItem + line.received_date = datetime.now().date() + line.save() + + trigger_event('returnorder.received', id=self.pk) + + # Notify responsible users + notify_responsible( + self, + ReturnOrder, + exclude=user, + content=InvenTreeNotificationBodies.ReturnOrderItemsReceived, + ) + + +class ReturnOrderLineItem(OrderLineItem): + """Model for a single LineItem in a ReturnOrder""" + + class Meta: + """Metaclass options for this model""" + + unique_together = [ + ('order', 'item'), + ] + + @staticmethod + def get_api_url(): + """Return the API URL associated with this model""" + return reverse('api-return-order-line-list') + + def clean(self): + """Perform extra validation steps for the ReturnOrderLineItem model""" + + super().clean() + + if self.item and not self.item.serialized: + raise ValidationError({ + 'item': _("Only serialized items can be assigned to a Return Order"), + }) + + order = models.ForeignKey( + ReturnOrder, + on_delete=models.CASCADE, + related_name='lines', + verbose_name=_('Order'), + help_text=_('Return Order'), + ) + + item = models.ForeignKey( + stock.models.StockItem, + on_delete=models.CASCADE, + related_name='return_order_lines', + verbose_name=_('Item'), + help_text=_('Select item to return from customer') + ) + + received_date = models.DateField( + null=True, blank=True, + verbose_name=_('Received Date'), + help_text=_('The date this this return item was received'), + ) + + @property + def received(self): + """Return True if this item has been received""" + return self.received_date is not None + + outcome = models.PositiveIntegerField( + default=ReturnOrderLineStatus.PENDING, + choices=ReturnOrderLineStatus.items(), + verbose_name=_('Outcome'), help_text=_('Outcome for this line item') + ) + + price = InvenTreeModelMoneyField( + null=True, blank=True, + verbose_name=_('Price'), + help_text=_('Cost associated with return or repair for this line item'), + ) + + link = InvenTreeURLField(blank=True, verbose_name=_('Link'), help_text=_('Link to external page')) + + +class ReturnOrderExtraLine(OrderExtraLine): + """Model for a single ExtraLine in a ReturnOrder""" + + @staticmethod + def get_api_url(): + """Return the API URL associated with the ReturnOrderExtraLine model""" + return reverse('api-return-order-extra-line-list') + + order = models.ForeignKey( + ReturnOrder, on_delete=models.CASCADE, + related_name='extra_lines', + verbose_name=_('Order'), help_text=_('Return Order') + ) + + +class ReturnOrderAttachment(InvenTreeAttachment): + """Model for storing file attachments against a ReturnOrder object""" + + @staticmethod + def get_api_url(): + """Return the API URL associated with the ReturnOrderAttachment class""" + + return reverse('api-return-order-attachment-list') + + def getSubdir(self): + """Return the directory path where ReturnOrderAttachment files are located""" + return os.path.join('return_files', str(self.order.id)) + + order = models.ForeignKey( + ReturnOrder, + on_delete=models.CASCADE, + related_name='attachments', + ) diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index b0627156b1..b992665157 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -17,21 +17,22 @@ import order.models import part.filters import stock.models import stock.serializers -from company.serializers import CompanyBriefSerializer, SupplierPartSerializer +from company.serializers import (CompanyBriefSerializer, ContactSerializer, + SupplierPartSerializer) from InvenTree.helpers import extract_serial_numbers, normalize, str2bool from InvenTree.serializers import (InvenTreeAttachmentSerializer, InvenTreeCurrencySerializer, InvenTreeDecimalField, InvenTreeModelSerializer, InvenTreeMoneySerializer) -from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus, - StockStatus) +from InvenTree.status_codes import (PurchaseOrderStatus, ReturnOrderStatus, + SalesOrderStatus, StockStatus) from part.serializers import PartBriefSerializer from users.serializers import OwnerSerializer -class AbstractOrderSerializer(serializers.Serializer): - """Abstract field definitions for OrderSerializers.""" +class TotalPriceMixin(serializers.Serializer): + """Serializer mixin which provides total price fields""" total_price = InvenTreeMoneySerializer( allow_null=True, @@ -41,6 +42,69 @@ class AbstractOrderSerializer(serializers.Serializer): total_price_currency = InvenTreeCurrencySerializer(read_only=True) +class AbstractOrderSerializer(serializers.Serializer): + """Abstract serializer class which provides fields common to all order types""" + + # Number of line items in this order + line_items = serializers.IntegerField(read_only=True) + + # Human-readable status text (read-only) + status_text = serializers.CharField(source='get_status_display', read_only=True) + + # status field cannot be set directly + status = serializers.IntegerField(read_only=True) + + # Reference string is *required* + reference = serializers.CharField(required=True) + + # Detail for point-of-contact field + contact_detail = ContactSerializer(source='contact', many=False, read_only=True) + + # Detail for responsible field + responsible_detail = OwnerSerializer(source='responsible', read_only=True, many=False) + + # Boolean field indicating if this order is overdue (Note: must be annotated) + overdue = serializers.BooleanField(required=False, read_only=True) + + def validate_reference(self, reference): + """Custom validation for the reference field""" + + self.Meta.model.validate_reference_field(reference) + return reference + + @staticmethod + def annotate_queryset(queryset): + """Add extra information to the queryset""" + + queryset = queryset.annotate( + line_items=SubqueryCount('lines') + ) + + return queryset + + @staticmethod + def order_fields(extra_fields): + """Construct a set of fields for this serializer""" + + return [ + 'pk', + 'creation_date', + 'target_date', + 'description', + 'line_items', + 'link', + 'reference', + 'responsible', + 'responsible_detail', + 'contact', + 'contact_detail', + 'status', + 'status_text', + 'notes', + 'overdue', + ] + extra_fields + + class AbstractExtraLineSerializer(serializers.Serializer): """Abstract Serializer for a ExtraLine object.""" @@ -78,7 +142,7 @@ class AbstractExtraLineMeta: ] -class PurchaseOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer): +class PurchaseOrderSerializer(TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer): """Serializer for a PurchaseOrder object.""" class Meta: @@ -86,31 +150,17 @@ class PurchaseOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer) model = order.models.PurchaseOrder - fields = [ - 'pk', + fields = AbstractOrderSerializer.order_fields([ 'issue_date', 'complete_date', - 'creation_date', - 'description', - 'line_items', - 'link', - 'overdue', - 'reference', - 'responsible', - 'responsible_detail', 'supplier', 'supplier_detail', 'supplier_reference', - 'status', - 'status_text', - 'target_date', - 'notes', 'total_price', 'total_price_currency', - ] + ]) read_only_fields = [ - 'status' 'issue_date', 'complete_date', 'creation_date', @@ -132,14 +182,13 @@ class PurchaseOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer) - Number of lines in the PurchaseOrder - Overdue status of the PurchaseOrder """ - queryset = queryset.annotate( - line_items=SubqueryCount('lines') - ) + queryset = AbstractOrderSerializer.annotate_queryset(queryset) queryset = queryset.annotate( overdue=Case( When( - order.models.PurchaseOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()), + order.models.PurchaseOrder.overdue_filter(), + then=Value(True, output_field=BooleanField()), ), default=Value(False, output_field=BooleanField()) ) @@ -149,24 +198,6 @@ class PurchaseOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer) supplier_detail = CompanyBriefSerializer(source='supplier', many=False, read_only=True) - line_items = serializers.IntegerField(read_only=True) - - status_text = serializers.CharField(source='get_status_display', read_only=True) - - overdue = serializers.BooleanField(required=False, read_only=True) - - reference = serializers.CharField(required=True) - - def validate_reference(self, reference): - """Custom validation for the reference field""" - - # Ensure that the reference matches the required pattern - order.models.PurchaseOrder.validate_reference_field(reference) - - return reference - - responsible_detail = OwnerSerializer(source='responsible', read_only=True, many=False) - class PurchaseOrderCancelSerializer(serializers.Serializer): """Serializer for cancelling a PurchaseOrder.""" @@ -307,7 +338,7 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer): queryset = queryset.annotate( overdue=Case( When( - Q(order__status__in=PurchaseOrderStatus.OPEN) & order.models.PurchaseOrderLineItem.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()) + order.models.PurchaseOrderLineItem.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()) ), default=Value(False, output_field=BooleanField()), ) @@ -531,7 +562,7 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer): class PurchaseOrderReceiveSerializer(serializers.Serializer): - """Serializer for receiving items against a purchase order.""" + """Serializer for receiving items against a PurchaseOrder.""" class Meta: """Metaclass options.""" @@ -644,34 +675,22 @@ class PurchaseOrderAttachmentSerializer(InvenTreeAttachmentSerializer): ]) -class SalesOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer): - """Serializers for the SalesOrder object.""" +class SalesOrderSerializer(TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer): + """Serializer for the SalesOrder model class""" class Meta: """Metaclass options.""" model = order.models.SalesOrder - fields = [ - 'pk', - 'creation_date', + fields = AbstractOrderSerializer.order_fields([ 'customer', 'customer_detail', 'customer_reference', - 'description', - 'line_items', - 'link', - 'notes', - 'overdue', - 'reference', - 'responsible', - 'status', - 'status_text', 'shipment_date', - 'target_date', 'total_price', 'total_price_currency', - ] + ]) read_only_fields = [ 'status', @@ -695,14 +714,13 @@ class SalesOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer): - Number of line items in the SalesOrder - Overdue status of the SalesOrder """ - queryset = queryset.annotate( - line_items=SubqueryCount('lines') - ) + queryset = AbstractOrderSerializer.annotate_queryset(queryset) queryset = queryset.annotate( overdue=Case( When( - order.models.SalesOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()), + order.models.SalesOrder.overdue_filter(), + then=Value(True, output_field=BooleanField()), ), default=Value(False, output_field=BooleanField()) ) @@ -712,22 +730,6 @@ class SalesOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer): 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) - - overdue = serializers.BooleanField(required=False, read_only=True) - - reference = serializers.CharField(required=True) - - def validate_reference(self, reference): - """Custom validation for the reference field""" - - # Ensure that the reference matches the required pattern - order.models.SalesOrder.validate_reference_field(reference) - - return reference - class SalesOrderAllocationSerializer(InvenTreeModelSerializer): """Serializer for the SalesOrderAllocation model. @@ -1379,13 +1381,13 @@ class SalesOrderShipmentAllocationSerializer(serializers.Serializer): class SalesOrderExtraLineSerializer(AbstractExtraLineSerializer, InvenTreeModelSerializer): """Serializer for a SalesOrderExtraLine object.""" - order_detail = SalesOrderSerializer(source='order', many=False, read_only=True) - class Meta(AbstractExtraLineMeta): """Metaclass options.""" model = order.models.SalesOrderExtraLine + order_detail = SalesOrderSerializer(source='order', many=False, read_only=True) + class SalesOrderAttachmentSerializer(InvenTreeAttachmentSerializer): """Serializers for the SalesOrderAttachment model.""" @@ -1398,3 +1400,253 @@ class SalesOrderAttachmentSerializer(InvenTreeAttachmentSerializer): fields = InvenTreeAttachmentSerializer.attachment_fields([ 'order', ]) + + +class ReturnOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer): + """Serializer for the ReturnOrder model class""" + + class Meta: + """Metaclass options""" + + model = order.models.ReturnOrder + + fields = AbstractOrderSerializer.order_fields([ + 'customer', + 'customer_detail', + 'customer_reference', + ]) + + read_only_fields = [ + 'creation_date', + ] + + def __init__(self, *args, **kwargs): + """Initialization routine for the serializer""" + + 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): + """Custom annotation for the serializer queryset""" + + queryset = AbstractOrderSerializer.annotate_queryset(queryset) + + queryset = queryset.annotate( + overdue=Case( + When( + order.models.ReturnOrder.overdue_filter(), + then=Value(True, output_field=BooleanField()), + ), + default=Value(False, output_field=BooleanField()) + ) + ) + + return queryset + + customer_detail = CompanyBriefSerializer(source='customer', many=False, read_only=True) + + +class ReturnOrderIssueSerializer(serializers.Serializer): + """Serializer for issuing a ReturnOrder""" + + class Meta: + """Metaclass options""" + fields = [] + + def save(self): + """Save the serializer to 'issue' the order""" + order = self.context['order'] + order.place_order() + + +class ReturnOrderCancelSerializer(serializers.Serializer): + """Serializer for cancelling a ReturnOrder""" + + class Meta: + """Metaclass options""" + fields = [] + + def save(self): + """Save the serializer to 'cancel' the order""" + order = self.context['order'] + order.cancel_order() + + +class ReturnOrderCompleteSerializer(serializers.Serializer): + """Serializer for completing a ReturnOrder""" + + class Meta: + """Metaclass options""" + fields = [] + + def save(self): + """Save the serializer to 'complete' the order""" + order = self.context['order'] + order.complete_order() + + +class ReturnOrderLineItemReceiveSerializer(serializers.Serializer): + """Serializer for receiving a single line item against a ReturnOrder""" + + class Meta: + """Metaclass options""" + fields = [ + 'item', + ] + + item = serializers.PrimaryKeyRelatedField( + queryset=order.models.ReturnOrderLineItem.objects.all(), + many=False, + allow_null=False, + required=True, + label=_('Return order line item'), + ) + + def validate_line_item(self, item): + """Validation for a single line item""" + + if item.order != self.context['order']: + raise ValidationError(_("Line item does not match return order")) + + if item.received: + raise ValidationError(_("Line item has already been received")) + + return item + + +class ReturnOrderReceiveSerializer(serializers.Serializer): + """Serializer for receiving items against a ReturnOrder""" + + class Meta: + """Metaclass options""" + + fields = [ + 'items', + 'location', + ] + + items = ReturnOrderLineItemReceiveSerializer(many=True) + + location = serializers.PrimaryKeyRelatedField( + queryset=stock.models.StockLocation.objects.all(), + many=False, + allow_null=False, + required=True, + label=_('Location'), + help_text=_('Select destination location for received items'), + ) + + def validate(self, data): + """Perform data validation for this serializer""" + + order = self.context['order'] + if order.status != ReturnOrderStatus.IN_PROGRESS: + raise ValidationError(_("Items can only be received against orders which are in progress")) + + data = super().validate(data) + + items = data.get('items', []) + + if len(items) == 0: + raise ValidationError(_("Line items must be provided")) + + return data + + @transaction.atomic + def save(self): + """Saving this serializer marks the returned items as received""" + + order = self.context['order'] + request = self.context['request'] + + data = self.validated_data + items = data['items'] + location = data['location'] + + with transaction.atomic(): + for item in items: + line_item = item['item'] + order.receive_line_item( + line_item, + location, + request.user + ) + + +class ReturnOrderLineItemSerializer(InvenTreeModelSerializer): + """Serializer for a ReturnOrderLineItem object""" + + class Meta: + """Metaclass options""" + + model = order.models.ReturnOrderLineItem + + fields = [ + 'pk', + 'order', + 'order_detail', + 'item', + 'item_detail', + 'received_date', + 'outcome', + 'part_detail', + 'price', + 'price_currency', + 'link', + 'reference', + 'notes', + 'target_date', + ] + + def __init__(self, *args, **kwargs): + """Initialization routine for the serializer""" + + order_detail = kwargs.pop('order_detail', False) + item_detail = kwargs.pop('item_detail', False) + part_detail = kwargs.pop('part_detail', False) + + super().__init__(*args, **kwargs) + + if not order_detail: + self.fields.pop('order_detail') + + if not item_detail: + self.fields.pop('item_detail') + + if not part_detail: + self.fields.pop('part_detail') + + order_detail = ReturnOrderSerializer(source='order', many=False, read_only=True) + item_detail = stock.serializers.StockItemSerializer(source='item', many=False, read_only=True) + part_detail = PartBriefSerializer(source='item.part', many=False, read_only=True) + + price = InvenTreeMoneySerializer(allow_null=True) + price_currency = InvenTreeCurrencySerializer(help_text=_('Line price currency')) + + +class ReturnOrderExtraLineSerializer(AbstractExtraLineSerializer, InvenTreeModelSerializer): + """Serializer for a ReturnOrderExtraLine object""" + + class Meta(AbstractExtraLineMeta): + """Metaclass options""" + model = order.models.ReturnOrderExtraLine + + order_detail = ReturnOrderSerializer(source='order', many=False, read_only=True) + + +class ReturnOrderAttachmentSerializer(InvenTreeAttachmentSerializer): + """Serializer for the ReturnOrderAttachment model""" + + class Meta: + """Metaclass options""" + + model = order.models.ReturnOrderAttachment + + fields = InvenTreeAttachmentSerializer.attachment_fields([ + 'order', + ]) diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html index d2a5bb9871..08da68fa42 100644 --- a/InvenTree/order/templates/order/order_base.html +++ b/InvenTree/order/templates/order/order_base.html @@ -7,16 +7,16 @@ {% block page_title %} {% inventree_title %} | {% trans "Purchase Order" %} -{% endblock %} +{% endblock page_title %} {% block breadcrumbs %} - + -{% endblock %} +{% endblock breadcrumbs %} {% block heading %} {% trans "Purchase Order" %}: {{ order.reference }} -{% endblock %} +{% endblock heading %} {% block actions %} {% if user.is_staff and roles.purchase_order.change %} @@ -67,8 +67,7 @@ {% trans "Receive Items" %} {% endif %} {% endif %} @@ -82,7 +81,7 @@ src="{{ order.supplier.image.url }}" src="{% static 'img/blank_image.png' %}" {% endif %} /> -{% endblock %} +{% endblock thumbnail %} {% block details %} @@ -111,7 +110,7 @@ src="{% static 'img/blank_image.png' %}" -{% endblock %} +{% endblock details %} {% block details_right %} @@ -169,7 +168,10 @@ src="{% static 'img/blank_image.png' %}" - + {% endif %} {% if order.status == PurchaseOrderStatus.COMPLETE %} @@ -179,6 +181,13 @@ src="{% static 'img/blank_image.png' %}" {% endif %} + {% if order.contact %} + + + + + + {% endif %} {% if order.responsible %} @@ -201,12 +210,11 @@ src="{% static 'img/blank_image.png' %}"
{% trans "Target Date" %}{% render_date order.target_date %} + {% render_date order.target_date %} + {% if order.is_overdue %}{% endif %} +
{% render_date order.complete_date %}{{ order.received_by }}
{% trans "Contact" %}{{ order.contact.name }}
-{% endblock %} +{% endblock details_right %} {% block js_ready %} {{ block.super }} - {% if order.status == PurchaseOrderStatus.PENDING %} $("#place-order").click(function() { @@ -222,7 +230,11 @@ $("#place-order").click(function() { {% if report_enabled %} $('#print-order-report').click(function() { - printPurchaseOrderReports([{{ order.pk }}]); + printReports({ + items: [{{ order.pk }}], + key: 'order', + url: '{% url "api-po-report-list" %}', + }); }); {% endif %} @@ -293,4 +305,4 @@ $("#export-order").click(function() { }); -{% endblock %} +{% endblock js_ready %} diff --git a/InvenTree/order/templates/order/purchase_order_detail.html b/InvenTree/order/templates/order/purchase_order_detail.html index a7cceb0d8a..1ad734de45 100644 --- a/InvenTree/order/templates/order/purchase_order_detail.html +++ b/InvenTree/order/templates/order/purchase_order_detail.html @@ -8,7 +8,7 @@ {% block sidebar %} {% include 'order/po_sidebar.html' %} -{% endblock %} +{% endblock sidebar %} {% block page_content %} {% settings_value "PURCHASEORDER_EDIT_COMPLETED_ORDERS" as allow_extra_editing %} @@ -27,9 +27,10 @@ - {% elif order.status == PurchaseOrderStatus.PLACED %} - {% endif %} {% endif %} @@ -126,7 +127,7 @@
-{% endblock %} +{% endblock page_content %} {% block js_ready %} @@ -146,30 +147,18 @@ ); }); - enableDragAndDrop( - '#attachment-dropzone', - '{% url "api-po-attachment-list" %}', - { - data: { - order: {{ order.id }}, + onPanelLoad('order-attachments', function() { + loadAttachmentTable('{% url "api-po-attachment-list" %}', { + filters: { + order: {{ order.pk }}, }, - label: 'attachment', - success: function(data, status, xhr) { - $('#attachment-table').bootstrapTable('refresh'); + fields: { + order: { + value: {{ order.pk }}, + hidden: true, + } } - } - ); - - loadAttachmentTable('{% url "api-po-attachment-list" %}', { - filters: { - order: {{ order.pk }}, - }, - fields: { - order: { - value: {{ order.pk }}, - hidden: true, - } - } + }); }); loadStockTable($("#stock-table"), { @@ -204,7 +193,7 @@ $('#new-po-line').click(function() { {% elif order.status == PurchaseOrderStatus.PLACED %} $('#receive-selected-items').click(function() { - var items = getTableData('#po-line-table'); + let items = getTableData('#po-line-table'); receivePurchaseOrderItems( {{ order.id }}, @@ -219,59 +208,56 @@ $('#new-po-line').click(function() { {% endif %} -loadPurchaseOrderLineItemTable('#po-line-table', { - order: {{ order.pk }}, - {% if order.supplier %} - supplier: {{ order.supplier.pk }}, - {% endif %} - {% if roles.purchase_order.change %} - allow_edit: true, - {% else %} - allow_edit: false, - {% endif %} - {% if order.status == PurchaseOrderStatus.PENDING %} - pending: true, - {% endif %} - {% if order.status == PurchaseOrderStatus.PLACED and roles.purchase_order.change %} - allow_receive: true, - {% else %} - allow_receive: false, - {% endif %} -}); - -$("#new-po-extra-line").click(function() { - - var fields = extraLineFields({ +onPanelLoad('order-items', function() { + loadPurchaseOrderLineItemTable('#po-line-table', { order: {{ order.pk }}, - }); - - {% if order.supplier.currency %} - fields.price_currency.value = '{{ order.supplier.currency }}'; - {% endif %} - - constructForm('{% url "api-po-extra-line-list" %}', { - fields: fields, - method: 'POST', - title: '{% trans "Add Order Line" %}', - onSuccess: function() { - $("#po-extra-lines-table").bootstrapTable("refresh"); - }, - }); -}); - -loadPurchaseOrderExtraLineTable( - '#po-extra-lines-table', - { - order: {{ order.pk }}, - status: {{ order.status }}, - {% if order.is_pending %} - pending: true, + {% if order.supplier %} + supplier: {{ order.supplier.pk }}, {% endif %} {% if roles.purchase_order.change %} allow_edit: true, + {% else %} + allow_edit: false, {% endif %} - } -); + {% if order.status == PurchaseOrderStatus.PENDING %} + pending: true, + {% endif %} + {% if order.status == PurchaseOrderStatus.PLACED and roles.purchase_order.change %} + allow_receive: true, + {% else %} + allow_receive: false, + {% endif %} + }); + + $("#new-po-extra-line").click(function() { + + createExtraLineItem({ + order: {{ order.pk }}, + table: '#po-extra-lines-table', + url: '{% url "api-po-extra-line-list" %}', + {% if order.supplier.currency %} + currency: '{{ order.supplier.currency }}', + {% endif %} + }); + }); + + loadExtraLineTable({ + table: '#po-extra-lines-table', + order: {{ order.pk }}, + url: '{% url "api-po-extra-line-list" %}', + name: 'purchaseorderextraline', + filtertarget: '#filter-list-purchase-order-extra-lines', + {% settings_value "PURCHASEORDER_EDIT_COMPLETED_ORDERS" as allow_edit %} + {% if order.is_pending or allow_edit %} + allow_edit: {% js_bool roles.purchase_order.change %}, + allow_delete: {% js_bool roles.purchase_order.delete %}, + {% else %} + allow_edit: false, + allow_delete: false, + {% endif %} + pricing: true, + }); +}); loadOrderTotal( '#poTotalPrice', diff --git a/InvenTree/order/templates/order/purchase_orders.html b/InvenTree/order/templates/order/purchase_orders.html index 7f91f01600..e60f015fa6 100644 --- a/InvenTree/order/templates/order/purchase_orders.html +++ b/InvenTree/order/templates/order/purchase_orders.html @@ -26,11 +26,6 @@
- {% if report_enabled %} - - {% endif %} {% include "filter_list.html" with id="purchaseorder" %}
@@ -53,20 +48,6 @@ {% block js_ready %} {{ block.super }} -{% if report_enabled %} -$("#order-print").click(function() { - var rows = getTableData('#purchase-order-table'); - - var orders = []; - - rows.forEach(function(row) { - orders.push(row.pk); - }); - - printPurchaseOrderReports(orders); -}) -{% endif %} - $("#po-create").click(function() { createPurchaseOrder(); }); diff --git a/InvenTree/order/templates/order/return_order_base.html b/InvenTree/order/templates/order/return_order_base.html new file mode 100644 index 0000000000..48574b7646 --- /dev/null +++ b/InvenTree/order/templates/order/return_order_base.html @@ -0,0 +1,235 @@ +{% extends "page_base.html" %} + +{% load i18n %} +{% load static %} +{% load inventree_extras %} +{% load status_codes %} + +{% block page_title %} +{% inventree_title %} | {% trans "Return Order" %} +{% endblock page_title %} + +{% block breadcrumbs %} + + +{% endblock breadcrumbs %} + +{% block thumbnail %} + +{% endblock thumbnail%} + +{% block heading %} +{% trans "Return Order" %} {{ order.reference }} +{% endblock heading %} + +{% block actions %} +{% if user.is_staff and roles.return_order.change %} +{% url 'admin:order_returnorder_change' order.pk as url %} +{% include "admin_button.html" with url=url %} +{% endif %} + +
+ + +
+ +{% if roles.return_order.change %} + +
+ + + {% if order.status == ReturnOrderStatus.PENDING %} + + {% elif order.status == ReturnOrderStatus.IN_PROGRESS %} + + {% endif %} +
+{% endif %} + +{% endblock actions %} + +{% block details %} + + + + + + + + + + + + + + + + + + +
{% trans "Order Reference" %}{{ order.reference }}{% include "clip.html"%}
{% trans "Order Description" %}{{ order.description }}{% include "clip.html" %}
{% trans "Order Status" %} + {% return_order_status_label order.status %} + {% if order.is_overdue %} + {% trans "Overdue" %} + {% endif %} +
+ +{% endblock details %} + +{% block details_right %} + + + {% if order.customer %} + + + + + + {% endif %} + {% if order.customer_reference %} + + + + + + {% endif %} + {% if order.link %} + + + + + + {% endif %} + + + + + + {% if order.issue_date %} + + + + + + {% endif %} + {% if order.target_date %} + + + + + + {% endif %} + {% if order.contact %} + + + + + + {% endif %} + {% if order.responsible %} + + + + + + {% endif %} + + + + + + +
{% trans "Customer" %}{{ order.customer.name }}{% include "clip.html"%}
{% trans "Customer Reference" %}{{ order.customer_reference }}{% include "clip.html"%}
External Link{{ order.link }}{% include "clip.html"%}
{% trans "Created" %}{% render_date order.creation_date %}{{ order.created_by }}
{% trans "Issued" %}{% render_date order.issue_date %}
{% trans "Target Date" %} + {% render_date order.target_date %} + {% if order.is_overdue %}{% endif %} +
{% trans "Contact" %}{{ order.contact.name }}
{% trans "Responsible" %}{{ order.responsible }}
{% trans "Total Cost" %} + {% with order.total_price as tp %} + {% if tp == None %} + {% trans "Total cost could not be calculated" %} + {% else %} + {% render_currency tp currency=order.customer.currency %} + {% endif %} + {% endwith %} +
+{% endblock details_right %} + +{% block js_ready %} +{{ block.super }} + +{% if roles.return_order.change %} + +{% if order.status == ReturnOrderStatus.PENDING %} +$('#submit-order').click(function() { + issueReturnOrder({{ order.pk }}, { + reload: true, + }); +}); +{% elif order.status == ReturnOrderStatus.IN_PROGRESS %} +$('#complete-order').click(function() { + completeReturnOrder( + {{ order.pk }}, + { + reload: true, + } + ); +}) +{% endif %} + +$('#edit-order').click(function() { + editReturnOrder({{ order.pk }}, { + reload: true, + }); +}); + +{% if order.is_open %} +$('#cancel-order').click(function() { + cancelReturnOrder( + {{ order.pk }}, + { + reload: true + } + ); +}); +{% endif %} +{% endif %} + +{% if report_enabled %} +$('#print-order-report').click(function() { + printReports({ + items: [{{ order.pk }}], + key: 'order', + url: '{% url "api-return-order-report-list" %}', + }); +}); +{% endif %} + + + +{% endblock js_ready %} diff --git a/InvenTree/order/templates/order/return_order_detail.html b/InvenTree/order/templates/order/return_order_detail.html new file mode 100644 index 0000000000..e57896ec41 --- /dev/null +++ b/InvenTree/order/templates/order/return_order_detail.html @@ -0,0 +1,209 @@ +{% extends "order/return_order_base.html" %} + +{% load inventree_extras %} +{% load status_codes %} +{% load i18n %} +{% load static %} + +{% block sidebar %} +{% include "order/return_order_sidebar.html" %} +{% endblock sidebar %} + +{% block page_content %} +{% settings_value "RETURNORDER_EDIT_COMPLETED_ORDERS" as allow_extra_editing %} + +
+
+
+

{% trans "Line Items" %}

+ {% include "spacer.html" %} +
+ {% if roles.return_order.add %} + {% if order.is_open or allow_extra_editing %} + + {% endif %} + {% if order.status == ReturnOrderStatus.IN_PROGRESS %} + + {% endif %} + {% endif %} +
+
+
+
+
+
+ {% include "filter_list.html" with id="returnorderlines" %} +
+
+ +
+
+
+
+

{% trans "Extra Lines" %}

+ {% include "spacer.html" %} +
+ {% if roles.return_order.add %} + {% if order.is_open or allow_extra_editing %} + + {% endif %} + {% endif %} +
+
+
+
+
+
+ {% include "filter_list.html" with id="return-order-extra-lines" %} +
+
+ +
+
+
+ +
+
+
+

{% trans "Attachments" %}

+ {% include "spacer.html" %} +
+ {% include "attachment_button.html" %} +
+
+
+
+ {% include "attachment_table.html" %} +
+
+ +
+
+
+

{% trans "Order Notes" %}

+ {% include "spacer.html" %} +
+ {% include "notes_buttons.html" %} +
+
+
+
+ +
+
+ +{% endblock page_content %} + +{% block js_ready %} +{{ block.super }} + +// Callback function when the 'details' panel is loaded +onPanelLoad('order-details', function() { + + {% if roles.return_order.add %} + + $('#receive-line-items').click(function() { + let items = getTableData('#return-order-lines-table'); + + receiveReturnOrderItems( + {{ order.pk }}, + items, + { + onSuccess: function() { + reloadBootstrapTable('#return-order-lines-table'); + } + } + ); + }); + + $('#new-return-order-line').click(function() { + createReturnOrderLineItem({ + order: {{ order.pk }}, + customer: {{ order.customer.pk }}, + onSuccess: function() { + reloadBootstrapTable('#return-order-lines-table'); + } + }); + }); + + $('#new-return-order-extra-line').click(function() { + + createExtraLineItem({ + order: {{ order.pk }}, + table: '#return-order-extra-lines-table', + url: '{% url "api-return-order-extra-line-list" %}', + {% if order.customer.currency %} + currency: '{{ order.customer.currency }}', + {% endif %} + }); + }); + + {% endif %} + + {% settings_value "RETURNORDER_EDIT_COMPLETED_ORDERS" as allow_extra_editing %} + + loadReturnOrderLineItemTable({ + table: '#return-order-lines-table', + order: {{ order.pk }}, + {% if order.status == ReturnOrderStatus.IN_PROGRESS %} + allow_receive: true, + {% endif %} + {% if order.is_open or allow_extra_editing %} + allow_edit: {% js_bool roles.return_order.change %}, + allow_delete: {% js_bool roles.return_order.delete %}, + {% endif %} + }); + + loadExtraLineTable({ + order: {{ order.pk }}, + url: '{% url "api-return-order-extra-line-list" %}', + table: "#return-order-extra-lines-table", + name: 'returnorderextralines', + filtertarget: '#filter-list-return-order-extra-lines', + {% if order.is_open or allow_extra_editing %} + allow_edit: {% js_bool roles.return_order.change %}, + allow_delete: {% js_bool roles.return_order.delete %}, + {% endif %} + }); +}); + +// Callback function when the 'notes' panel is loaded +onPanelLoad('order-notes', function() { + setupNotesField( + 'order-notes', + '{% url "api-return-order-detail" order.pk %}', + { + {% if roles.purchase_order.change %} + editable: true, + {% else %} + editable: false, + {% endif %} + } + ); +}); + +// Callback function when the 'attachments' panel is loaded +onPanelLoad('order-attachments', function() { + + loadAttachmentTable('{% url "api-return-order-attachment-list" %}', { + filters: { + order: {{ order.pk }}, + }, + fields: { + order: { + value: {{ order.pk }}, + hidden: true, + }, + } + }); +}); + +enableSidebar('returnorder'); + +{% endblock js_ready %} diff --git a/InvenTree/order/templates/order/return_order_sidebar.html b/InvenTree/order/templates/order/return_order_sidebar.html new file mode 100644 index 0000000000..a9a0519885 --- /dev/null +++ b/InvenTree/order/templates/order/return_order_sidebar.html @@ -0,0 +1,10 @@ +{% load i18n %} +{% load static %} +{% load inventree_extras %} + +{% trans "Order Details" as text %} +{% include "sidebar_item.html" with label='order-details' text=text icon="fa-info-circle" %} +{% trans "Attachments" as text %} +{% include "sidebar_item.html" with label='order-attachments' text=text icon="fa-paperclip" %} +{% trans "Notes" as text %} +{% include "sidebar_item.html" with label='order-notes' text=text icon="fa-clipboard" %} diff --git a/InvenTree/order/templates/order/return_orders.html b/InvenTree/order/templates/order/return_orders.html new file mode 100644 index 0000000000..e364214c85 --- /dev/null +++ b/InvenTree/order/templates/order/return_orders.html @@ -0,0 +1,55 @@ +{% extends "page_base.html" %} + +{% load inventree_extras %} +{% load static %} +{% load i18n %} + +{% block page_title %} +{% inventree_title %} | {% trans "Return Orders" %} +{% endblock %} + +{% block breadcrumb_list %} +{% endblock %} + +{% block heading %} +{% trans "Return Orders" %} +{% endblock %} + + +{% block actions %} +{% if roles.return_order.add %} + +{% endif %} +{% endblock actions %} + +{% block page_info %} + +
+
+
+
+ {% include "filter_list.html" with id="returnorder" %} +
+
+
+ + +
+ +
+
+ +{% endblock page_info %} + +{% block js_ready %} +{{ block.super }} + +loadReturnOrderTable('#return-order-table', {}); + +$('#return-order-create').click(function() { + createReturnOrder(); +}); + +{% endblock js_ready %} diff --git a/InvenTree/order/templates/order/sales_order_base.html b/InvenTree/order/templates/order/sales_order_base.html index 20acc35379..b0892f9037 100644 --- a/InvenTree/order/templates/order/sales_order_base.html +++ b/InvenTree/order/templates/order/sales_order_base.html @@ -10,7 +10,7 @@ {% endblock %} {% block breadcrumbs %} - + {% endblock %} @@ -162,7 +162,10 @@ src="{% static 'img/blank_image.png' %}" {% trans "Target Date" %} - {% render_date order.target_date %} + + {% render_date order.target_date %} + {% if order.is_overdue %}{% endif %} + {% endif %} {% if order.shipment_date %} @@ -177,6 +180,13 @@ src="{% static 'img/blank_image.png' %}" {% endif %} + {% if order.contact %} + + + {% trans "Contact" %} + {{ order.contact.name }} + + {% endif %} {% if order.responsible %} @@ -187,7 +197,7 @@ src="{% static 'img/blank_image.png' %}" - {% trans "Total cost" %} + {% trans "Total Cost" %} {% with order.total_price as tp %} {% if tp == None %} @@ -204,12 +214,13 @@ src="{% static 'img/blank_image.png' %}" {% block js_ready %} {{ block.super }} +{% if roles.sales_order.change %} $("#edit-order").click(function() { - editSalesOrder({{ order.pk }}, { reload: true, }); }); +{% endif %} $("#complete-order-shipments").click(function() { @@ -242,7 +253,11 @@ $("#complete-order").click(function() { {% if report_enabled %} $('#print-order-report').click(function() { - printSalesOrderReports([{{ order.pk }}]); + printReports({ + items: [{{ order.pk }}], + key: 'order', + url: '{% url "api-so-report-list" %}', + }); }); {% endif %} diff --git a/InvenTree/order/templates/order/sales_order_detail.html b/InvenTree/order/templates/order/sales_order_detail.html index 5b28b88f1a..bcd96796d5 100644 --- a/InvenTree/order/templates/order/sales_order_detail.html +++ b/InvenTree/order/templates/order/sales_order_detail.html @@ -18,7 +18,7 @@

{% trans "Sales Order Items" %}

{% include "spacer.html" %}
- {% if roles.sales_order.change %} + {% if roles.sales_order.add %} {% if order.is_pending or allow_extra_editing %} - {% endif %} {% include "filter_list.html" with id="salesorder" %}
@@ -54,20 +49,6 @@ loadSalesOrderTable("#sales-order-table", { url: "{% url 'api-so-list' %}", }); -{% if report_enabled %} -$("#order-print").click(function() { - var rows = getTableData('#sales-order-table'); - - var orders = []; - - rows.forEach(function(row) { - orders.push(row.pk); - }); - - printSalesOrderReports(orders); -}) -{% endif %} - $("#so-create").click(function() { createSalesOrder(); }); diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index 631b54caaf..98bfe8f840 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -17,7 +17,9 @@ import order.models as models from common.settings import currency_codes from company.models import Company from InvenTree.api_tester import InvenTreeAPITestCase -from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus +from InvenTree.status_codes import (PurchaseOrderStatus, ReturnOrderLineStatus, + ReturnOrderStatus, SalesOrderStatus, + StockStatus) from part.models import Part from stock.models import StockItem @@ -1802,3 +1804,286 @@ class SalesOrderAllocateTest(OrderTest): response = self.get(url, expected_code=200) self.assertEqual(len(response.data), 1 + 3 * models.SalesOrder.objects.count()) + + +class ReturnOrderTests(InvenTreeAPITestCase): + """Unit tests for ReturnOrder API endpoints""" + + fixtures = [ + 'category', + 'company', + 'return_order', + 'part', + 'location', + 'supplier_part', + 'stock', + ] + + def test_options(self): + """Test the OPTIONS endpoint""" + + self.assignRole('return_order.add') + data = self.options(reverse('api-return-order-list'), expected_code=200).data + + self.assertEqual(data['name'], 'Return Order List') + + # Some checks on the 'reference' field + post = data['actions']['POST'] + reference = post['reference'] + + self.assertEqual(reference['default'], 'RMA-0007') + self.assertEqual(reference['label'], 'Reference') + self.assertEqual(reference['help_text'], 'Return Order reference') + self.assertEqual(reference['required'], True) + self.assertEqual(reference['type'], 'string') + + def test_list(self): + """Tests for the list endpoint""" + + url = reverse('api-return-order-list') + + response = self.get(url, expected_code=200) + + self.assertEqual(len(response.data), 6) + + # Paginated query + data = self.get( + url, + { + 'limit': 1, + 'ordering': 'reference', + 'customer_detail': True, + }, + expected_code=200 + ).data + + self.assertEqual(data['count'], 6) + self.assertEqual(len(data['results']), 1) + result = data['results'][0] + self.assertEqual(result['reference'], 'RMA-001') + self.assertEqual(result['customer_detail']['name'], 'A customer') + + # Reverse ordering + data = self.get( + url, + { + 'ordering': '-reference', + }, + expected_code=200 + ).data + + self.assertEqual(data[0]['reference'], 'RMA-006') + + # Filter by customer + for cmp_id in [4, 5]: + data = self.get( + url, + { + 'customer': cmp_id, + }, + expected_code=200 + ).data + + self.assertEqual(len(data), 3) + + for result in data: + self.assertEqual(result['customer'], cmp_id) + + # Filter by status + data = self.get( + url, + { + 'status': 20, + }, + expected_code=200 + ).data + + self.assertEqual(len(data), 2) + + for result in data: + self.assertEqual(result['status'], 20) + + def test_create(self): + """Test creation of ReturnOrder via the API""" + + url = reverse('api-return-order-list') + + # Do not have required permissions yet + self.post( + url, + { + 'customer': 1, + 'description': 'a return order', + }, + expected_code=403 + ) + + self.assignRole('return_order.add') + + data = self.post( + url, + { + 'customer': 4, + 'customer_reference': 'cr', + 'description': 'a return order', + }, + expected_code=201 + ).data + + # Reference automatically generated + self.assertEqual(data['reference'], 'RMA-0007') + self.assertEqual(data['customer_reference'], 'cr') + + def test_update(self): + """Test that we can update a ReturnOrder via the API""" + + url = reverse('api-return-order-detail', kwargs={'pk': 1}) + + # Test detail endpoint + data = self.get(url, expected_code=200).data + + self.assertEqual(data['reference'], 'RMA-001') + + # Attempt to update, incorrect permissions + self.patch( + url, + { + 'customer_reference': 'My customer reference', + }, + expected_code=403 + ) + + self.assignRole('return_order.change') + + self.patch( + url, + { + 'customer_reference': 'customer ref', + }, + expected_code=200 + ) + + rma = models.ReturnOrder.objects.get(pk=1) + self.assertEqual(rma.customer_reference, 'customer ref') + + def test_ro_issue(self): + """Test the 'issue' order for a ReturnOrder""" + + order = models.ReturnOrder.objects.get(pk=1) + self.assertEqual(order.status, ReturnOrderStatus.PENDING) + self.assertIsNone(order.issue_date) + + url = reverse('api-return-order-issue', kwargs={'pk': 1}) + + # POST without required permissions + self.post(url, expected_code=403) + + self.assignRole('return_order.add') + + self.post(url, expected_code=201) + order.refresh_from_db() + self.assertEqual(order.status, ReturnOrderStatus.IN_PROGRESS) + self.assertIsNotNone(order.issue_date) + + def test_receive(self): + """Test that we can receive items against a ReturnOrder""" + + customer = Company.objects.get(pk=4) + + # Create an order + rma = models.ReturnOrder.objects.create( + customer=customer, + description='A return order', + ) + + self.assertEqual(rma.reference, 'RMA-0007') + + # Create some line items + part = Part.objects.get(pk=25) + for idx in range(3): + stock_item = StockItem.objects.create( + part=part, customer=customer, + quantity=1, serial=idx + ) + + line_item = models.ReturnOrderLineItem.objects.create( + order=rma, + item=stock_item, + ) + + self.assertEqual(line_item.outcome, ReturnOrderLineStatus.PENDING) + self.assertIsNone(line_item.received_date) + self.assertFalse(line_item.received) + + self.assertEqual(rma.lines.count(), 3) + + def receive(items, location=None, expected_code=400): + """Helper function to receive items against this ReturnOrder""" + url = reverse('api-return-order-receive', kwargs={'pk': rma.pk}) + + response = self.post( + url, + { + 'items': items, + 'location': location, + }, + expected_code=expected_code + ) + + return response.data + + # Receive without required permissions + receive([], expected_code=403) + + self.assignRole('return_order.add') + + # Receive, without any location + data = receive([], expected_code=400) + self.assertIn('This field may not be null', str(data['location'])) + + # Receive, with incorrect order code + data = receive([], 1, expected_code=400) + self.assertIn('Items can only be received against orders which are in progress', str(data)) + + # Issue the order (via the API) + self.assertIsNone(rma.issue_date) + self.post( + reverse("api-return-order-issue", kwargs={"pk": rma.pk}), + expected_code=201, + ) + + rma.refresh_from_db() + self.assertIsNotNone(rma.issue_date) + self.assertEqual(rma.status, ReturnOrderStatus.IN_PROGRESS) + + # Receive, without any items + data = receive([], 1, expected_code=400) + self.assertIn('Line items must be provided', str(data)) + + # Get a reference to one of the stock items + stock_item = rma.lines.first().item + + n_tracking = stock_item.tracking_info.count() + + # Receive items successfully + data = receive( + [{'item': line.pk} for line in rma.lines.all()], + 1, + expected_code=201 + ) + + # Check that all line items have been received + for line in rma.lines.all(): + self.assertTrue(line.received) + self.assertIsNotNone(line.received_date) + + # A single tracking entry should have been added to the item + self.assertEqual(stock_item.tracking_info.count(), n_tracking + 1) + + tracking_entry = stock_item.tracking_info.last() + deltas = tracking_entry.deltas + + self.assertEqual(deltas['status'], StockStatus.QUARANTINED) + self.assertEqual(deltas['customer'], customer.pk) + self.assertEqual(deltas['location'], 1) + self.assertEqual(deltas['returnorder'], rma.pk) diff --git a/InvenTree/order/test_views.py b/InvenTree/order/test_views.py index cadd2914ad..d878cbc604 100644 --- a/InvenTree/order/test_views.py +++ b/InvenTree/order/test_views.py @@ -16,6 +16,8 @@ class OrderViewTestCase(InvenTreeTestCase): 'supplier_part', 'stock', 'order', + 'sales_order', + 'return_order', ] roles = [ @@ -25,14 +27,17 @@ class OrderViewTestCase(InvenTreeTestCase): 'sales_order.change', 'sales_order.add', 'sales_order.delete', + 'return_order.change', + 'return_order.add', + 'return_order.delete', ] -class OrderListTest(OrderViewTestCase): +class PurchaseOrderListTest(OrderViewTestCase): """Unit tests for the PurchaseOrder index page""" def test_order_list(self): """Tests for the PurchaseOrder index page""" - response = self.client.get(reverse('po-index')) + response = self.client.get(reverse('purchase-order-index')) self.assertEqual(response.status_code, 200) @@ -53,3 +58,31 @@ class PurchaseOrderTests(OrderViewTestCase): # Response should be streaming-content (file download) self.assertIn('streaming_content', dir(response)) + + +class SalesOrderViews(OrderViewTestCase): + """Unit tests for the SalesOrder pages""" + + def test_index(self): + """Test the SalesOrder index page""" + response = self.client.get(reverse('sales-order-index')) + self.assertEqual(response.status_code, 200) + + def test_detail(self): + """Test SalesOrder detail view""" + response = self.client.get(reverse('so-detail', args=(1,))) + self.assertEqual(response.status_code, 200) + + +class ReturnOrderVIews(OrderViewTestCase): + """Unit tests for the ReturnOrder pages""" + + def test_index(self): + """Test the ReturnOrder index page""" + response = self.client.get(reverse('return-order-index')) + self.assertEqual(response.status_code, 200) + + def test_detail(self): + """Test ReturnOrder detail view""" + response = self.client.get(reverse('return-order-detail', args=(1,))) + self.assertEqual(response.status_code, 200) diff --git a/InvenTree/order/urls.py b/InvenTree/order/urls.py index 278914bd75..7305bf8543 100644 --- a/InvenTree/order/urls.py +++ b/InvenTree/order/urls.py @@ -4,7 +4,7 @@ - Detail view of Purchase Orders """ -from django.urls import include, re_path +from django.urls import include, path, re_path from . import views @@ -21,10 +21,10 @@ purchase_order_urls = [ re_path(r'^pricing/', views.LineItemPricing.as_view(), name='line-pricing'), # Display detail view for a single purchase order - re_path(r'^(?P\d+)/', include(purchase_order_detail_urls)), + path(r'/', include(purchase_order_detail_urls)), # Display complete list of purchase orders - re_path(r'^.*$', views.PurchaseOrderIndex.as_view(), name='po-index'), + re_path(r'^.*$', views.PurchaseOrderIndex.as_view(), name='purchase-order-index'), ] sales_order_detail_urls = [ @@ -35,13 +35,23 @@ sales_order_detail_urls = [ sales_order_urls = [ # Display detail view for a single SalesOrder - re_path(r'^(?P\d+)/', include(sales_order_detail_urls)), + path(r'/', include(sales_order_detail_urls)), # Display list of all sales orders - re_path(r'^.*$', views.SalesOrderIndex.as_view(), name='so-index'), + re_path(r'^.*$', views.SalesOrderIndex.as_view(), name='sales-order-index'), ] + +return_order_urls = [ + path(r'/', views.ReturnOrderDetail.as_view(), name='return-order-detail'), + + # Display list of all return orders + re_path(r'^.*$', views.ReturnOrderIndex.as_view(), name='return-order-index'), +] + + order_urls = [ re_path(r'^purchase-order/', include(purchase_order_urls)), re_path(r'^sales-order/', include(sales_order_urls)), + re_path(r'^return-order/', include(return_order_urls)), ] diff --git a/InvenTree/order/validators.py b/InvenTree/order/validators.py index 3ca3a58940..ee9c832e05 100644 --- a/InvenTree/order/validators.py +++ b/InvenTree/order/validators.py @@ -17,6 +17,14 @@ def generate_next_purchase_order_reference(): return PurchaseOrder.generate_reference() +def generate_next_return_order_reference(): + """Generate the next available ReturnOrder reference""" + + from order.models import ReturnOrder + + return ReturnOrder.generate_reference() + + def validate_sales_order_reference_pattern(pattern): """Validate the SalesOrder reference 'pattern' setting""" @@ -33,6 +41,14 @@ def validate_purchase_order_reference_pattern(pattern): PurchaseOrder.validate_reference_pattern(pattern) +def validate_return_order_reference_pattern(pattern): + """Validate the ReturnOrder reference 'pattern' setting""" + + from order.models import ReturnOrder + + ReturnOrder.validate_reference_pattern(pattern) + + def validate_sales_order_reference(value): """Validate that the SalesOrder reference field matches the required pattern""" @@ -47,3 +63,11 @@ def validate_purchase_order_reference(value): from order.models import PurchaseOrder PurchaseOrder.validate_reference_field(value) + + +def validate_return_order_reference(value): + """Validate that the ReturnOrder reference field matches the required pattern""" + + from order.models import ReturnOrder + + ReturnOrder.validate_reference_field(value) diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index b3909971e0..3d319a8d28 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -24,8 +24,8 @@ from plugin.views import InvenTreePluginViewMixin from . import forms as order_forms from .admin import PurchaseOrderLineItemResource, SalesOrderLineItemResource -from .models import (PurchaseOrder, PurchaseOrderLineItem, SalesOrder, - SalesOrderLineItem) +from .models import (PurchaseOrder, PurchaseOrderLineItem, ReturnOrder, + SalesOrder, SalesOrderLineItem) logger = logging.getLogger("inventree") @@ -51,6 +51,14 @@ class SalesOrderIndex(InvenTreeRoleMixin, ListView): context_object_name = 'orders' +class ReturnOrderIndex(InvenTreeRoleMixin, ListView): + """ReturnOrder index (list) view""" + + model = ReturnOrder + template_name = 'order/return_orders.html' + context_object_name = 'orders' + + class PurchaseOrderDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView): """Detail view for a PurchaseOrder object.""" @@ -67,6 +75,14 @@ class SalesOrderDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView) template_name = 'order/sales_order_detail.html' +class ReturnOrderDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView): + """Detail view for a ReturnOrder object""" + + context_object_name = 'order' + queryset = ReturnOrder.objects.all() + template_name = 'order/return_order_detail.html' + + class PurchaseOrderUpload(FileManagementFormView): """PurchaseOrder: Upload file, match to fields and parts (using multi-Step form)""" diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 055ac98a82..6ea2a3b673 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -39,34 +39,20 @@ from .models import (BomItem, BomItemSubstitute, Part, PartAttachment, PartStocktake, PartStocktakeReport, PartTestTemplate) -class CategoryList(APIDownloadMixin, ListCreateAPI): - """API endpoint for accessing a list of PartCategory objects. - - - GET: Return a list of PartCategory objects - - POST: Create a new PartCategory object - """ - - queryset = PartCategory.objects.all() +class CategoryMixin: + """Mixin class for PartCategory endpoints""" serializer_class = part_serializers.CategorySerializer - - def download_queryset(self, queryset, export_format): - """Download the filtered queryset as a data file""" - - dataset = PartCategoryResource().export(queryset=queryset) - filedata = dataset.export(export_format) - filename = f"InvenTree_Categories.{export_format}" - - return DownloadFile(filedata, filename) + queryset = PartCategory.objects.all() def get_queryset(self, *args, **kwargs): - """Return an annotated queryset for the CategoryList endpoint""" + """Return an annotated queryset for the CategoryDetail endpoint""" queryset = super().get_queryset(*args, **kwargs) queryset = part_serializers.CategorySerializer.annotate_queryset(queryset) return queryset def get_serializer_context(self): - """Add extra context data to the serializer for the PartCategoryList endpoint""" + """Add extra context to the serializer for the CategoryDetail endpoint""" ctx = super().get_serializer_context() try: @@ -77,6 +63,23 @@ class CategoryList(APIDownloadMixin, ListCreateAPI): return ctx + +class CategoryList(CategoryMixin, APIDownloadMixin, ListCreateAPI): + """API endpoint for accessing a list of PartCategory objects. + + - GET: Return a list of PartCategory objects + - POST: Create a new PartCategory object + """ + + def download_queryset(self, queryset, export_format): + """Download the filtered queryset as a data file""" + + dataset = PartCategoryResource().export(queryset=queryset) + filedata = dataset.export(export_format) + filename = f"InvenTree_Categories.{export_format}" + + return DownloadFile(filedata, filename) + def filter_queryset(self, queryset): """Custom filtering: @@ -184,31 +187,9 @@ class CategoryList(APIDownloadMixin, ListCreateAPI): ] -class CategoryDetail(CustomRetrieveUpdateDestroyAPI): +class CategoryDetail(CategoryMixin, CustomRetrieveUpdateDestroyAPI): """API endpoint for detail view of a single PartCategory object.""" - serializer_class = part_serializers.CategorySerializer - queryset = PartCategory.objects.all() - - def get_queryset(self, *args, **kwargs): - """Return an annotated queryset for the CategoryDetail endpoint""" - - queryset = super().get_queryset(*args, **kwargs) - queryset = part_serializers.CategorySerializer.annotate_queryset(queryset) - return queryset - - def get_serializer_context(self): - """Add extra context to the serializer for the CategoryDetail endpoint""" - ctx = super().get_serializer_context() - - try: - ctx['starred_categories'] = [star.category for star in self.request.user.starred_categories.all()] - except AttributeError: - # Error is thrown if the view does not have an associated request - ctx['starred_categories'] = [] - - return ctx - def update(self, request, *args, **kwargs): """Perform 'update' function and mark this part as 'starred' (or not)""" # Clean up input data @@ -234,6 +215,21 @@ class CategoryDetail(CustomRetrieveUpdateDestroyAPI): delete_child_categories=delete_child_categories)) +class CategoryTree(ListAPI): + """API endpoint for accessing a list of PartCategory objects ready for rendering a tree.""" + + queryset = PartCategory.objects.all() + serializer_class = part_serializers.CategoryTree + + filter_backends = [ + DjangoFilterBackend, + filters.OrderingFilter, + ] + + # Order by tree level (top levels first) and then name + ordering = ['level', 'name'] + + class CategoryMetadata(RetrieveUpdateAPI): """API endpoint for viewing / updating PartCategory metadata.""" @@ -292,21 +288,6 @@ class CategoryParameterDetail(RetrieveUpdateDestroyAPI): serializer_class = part_serializers.CategoryParameterTemplateSerializer -class CategoryTree(ListAPI): - """API endpoint for accessing a list of PartCategory objects ready for rendering a tree.""" - - queryset = PartCategory.objects.all() - serializer_class = part_serializers.CategoryTree - - filter_backends = [ - DjangoFilterBackend, - filters.OrderingFilter, - ] - - # Order by tree level (top levels first) and then name - ordering = ['level', 'name'] - - class PartSalePriceDetail(RetrieveUpdateDestroyAPI): """Detail endpoint for PartSellPriceBreak model.""" @@ -845,76 +826,6 @@ class PartValidateBOM(RetrieveUpdateAPI): }) -class PartDetail(RetrieveUpdateDestroyAPI): - """API endpoint for detail view of a single Part object.""" - - queryset = Part.objects.all() - serializer_class = part_serializers.PartSerializer - - starred_parts = None - - def get_queryset(self, *args, **kwargs): - """Return an annotated queryset object for the PartDetail endpoint""" - queryset = super().get_queryset(*args, **kwargs) - - queryset = part_serializers.PartSerializer.annotate_queryset(queryset) - - return queryset - - def get_serializer(self, *args, **kwargs): - """Return a serializer instance for the PartDetail endpoint""" - # By default, include 'category_detail' information in the detail view - try: - kwargs['category_detail'] = str2bool(self.request.query_params.get('category_detail', True)) - except AttributeError: - pass - - # Ensure the request context is passed through - kwargs['context'] = self.get_serializer_context() - - # Pass a list of "starred" parts of the current user to the serializer - # We do this to reduce the number of database queries required! - if self.starred_parts is None and self.request is not None: - self.starred_parts = [star.part for star in self.request.user.starred_parts.all()] - - kwargs['starred_parts'] = self.starred_parts - - return self.serializer_class(*args, **kwargs) - - def destroy(self, request, *args, **kwargs): - """Delete a Part instance via the API - - - If the part is 'active' it cannot be deleted - - It must first be marked as 'inactive' - """ - part = Part.objects.get(pk=int(kwargs['pk'])) - # Check if inactive - if not part.active: - # Delete - return super(PartDetail, self).destroy(request, *args, **kwargs) - else: - # Return 405 error - message = 'Part is active: cannot delete' - return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED, data=message) - - def update(self, request, *args, **kwargs): - """Custom update functionality for Part instance. - - - If the 'starred' field is provided, update the 'starred' status against current user - """ - # Clean input data - data = self.clean_data(request.data) - - if 'starred' in data: - starred = str2bool(data.get('starred', False)) - - self.get_object().set_starred(request.user, starred) - - response = super().update(request, *args, **kwargs) - - return response - - class PartFilter(rest_filters.FilterSet): """Custom filters for the PartList endpoint. @@ -1090,22 +1001,30 @@ class PartFilter(rest_filters.FilterSet): virtual = rest_filters.BooleanFilter() -class PartList(APIDownloadMixin, ListCreateAPI): - """API endpoint for accessing a list of Part objects, or creating a new Part instance""" - +class PartMixin: + """Mixin class for Part API endpoints""" serializer_class = part_serializers.PartSerializer queryset = Part.objects.all() - filterset_class = PartFilter starred_parts = None + is_create = False + + def get_queryset(self, *args, **kwargs): + """Return an annotated queryset object for the PartDetail endpoint""" + queryset = super().get_queryset(*args, **kwargs) + + queryset = part_serializers.PartSerializer.annotate_queryset(queryset) + + return queryset + def get_serializer(self, *args, **kwargs): """Return a serializer instance for this endpoint""" # Ensure the request context is passed through kwargs['context'] = self.get_serializer_context() # Indicate that we can create a new Part via this endpoint - kwargs['create'] = True + kwargs['create'] = self.is_create # Pass a list of "starred" parts to the current user to the serializer # We do this to reduce the number of database queries required! @@ -1132,6 +1051,13 @@ class PartList(APIDownloadMixin, ListCreateAPI): return context + +class PartList(PartMixin, APIDownloadMixin, ListCreateAPI): + """API endpoint for accessing a list of Part objects, or creating a new Part instance""" + + filterset_class = PartFilter + is_create = True + def download_queryset(self, queryset, export_format): """Download the filtered queryset as a data file""" dataset = PartResource().export(queryset=queryset) @@ -1169,13 +1095,6 @@ class PartList(APIDownloadMixin, ListCreateAPI): else: return Response(data) - def get_queryset(self, *args, **kwargs): - """Return an annotated queryset object""" - queryset = super().get_queryset(*args, **kwargs) - queryset = part_serializers.PartSerializer.annotate_queryset(queryset) - - return queryset - def filter_queryset(self, queryset): """Perform custom filtering of the queryset""" params = self.request.query_params @@ -1358,6 +1277,43 @@ class PartList(APIDownloadMixin, ListCreateAPI): ] +class PartDetail(PartMixin, RetrieveUpdateDestroyAPI): + """API endpoint for detail view of a single Part object.""" + + def destroy(self, request, *args, **kwargs): + """Delete a Part instance via the API + + - If the part is 'active' it cannot be deleted + - It must first be marked as 'inactive' + """ + part = Part.objects.get(pk=int(kwargs['pk'])) + # Check if inactive + if not part.active: + # Delete + return super(PartDetail, self).destroy(request, *args, **kwargs) + else: + # Return 405 error + message = 'Part is active: cannot delete' + return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED, data=message) + + def update(self, request, *args, **kwargs): + """Custom update functionality for Part instance. + + - If the 'starred' field is provided, update the 'starred' status against current user + """ + # Clean input data + data = self.clean_data(request.data) + + if 'starred' in data: + starred = str2bool(data.get('starred', False)) + + self.get_object().set_starred(request.user, starred) + + response = super().update(request, *args, **kwargs) + + return response + + class PartRelatedList(ListCreateAPI): """API endpoint for accessing a list of PartRelated objects.""" @@ -1674,42 +1630,11 @@ class BomFilter(rest_filters.FilterSet): return queryset -class BomList(ListCreateDestroyAPIView): - """API endpoint for accessing a list of BomItem objects. - - - GET: Return list of BomItem objects - - POST: Create a new BomItem object - """ +class BomMixin: + """Mixin class for BomItem API endpoints""" serializer_class = part_serializers.BomItemSerializer queryset = BomItem.objects.all() - filterset_class = BomFilter - - def list(self, request, *args, **kwargs): - """Return serialized list response for this endpoint""" - - queryset = self.filter_queryset(self.get_queryset()) - - page = self.paginate_queryset(queryset) - - if page is not None: - serializer = self.get_serializer(page, many=True) - else: - serializer = self.get_serializer(queryset, many=True) - - data = serializer.data - - """ - Determine the response type based on the request. - a) For HTTP requests (e.g. via the browseable API) return a DRF response - b) For AJAX requests, simply return a JSON rendered response. - """ - if page is not None: - return self.get_paginated_response(data) - elif request.is_ajax(): - return JsonResponse(data, safe=False) - else: - return Response(data) def get_serializer(self, *args, **kwargs): """Return the serializer instance for this API endpoint @@ -1744,6 +1669,42 @@ class BomList(ListCreateDestroyAPIView): return queryset + +class BomList(BomMixin, ListCreateDestroyAPIView): + """API endpoint for accessing a list of BomItem objects. + + - GET: Return list of BomItem objects + - POST: Create a new BomItem object + """ + + filterset_class = BomFilter + + def list(self, request, *args, **kwargs): + """Return serialized list response for this endpoint""" + + queryset = self.filter_queryset(self.get_queryset()) + + page = self.paginate_queryset(queryset) + + if page is not None: + serializer = self.get_serializer(page, many=True) + else: + serializer = self.get_serializer(queryset, many=True) + + data = serializer.data + + """ + Determine the response type based on the request. + a) For HTTP requests (e.g. via the browseable API) return a DRF response + b) For AJAX requests, simply return a JSON rendered response. + """ + if page is not None: + return self.get_paginated_response(data) + elif request.is_ajax(): + return JsonResponse(data, safe=False) + else: + return Response(data) + def filter_queryset(self, queryset): """Custom query filtering for the BomItem list API""" queryset = super().filter_queryset(queryset) @@ -1828,6 +1789,11 @@ class BomList(ListCreateDestroyAPIView): } +class BomDetail(BomMixin, RetrieveUpdateDestroyAPI): + """API endpoint for detail view of a single BomItem object.""" + pass + + class BomImportUpload(CreateAPI): """API endpoint for uploading a complete Bill of Materials. @@ -1866,22 +1832,6 @@ class BomImportSubmit(CreateAPI): serializer_class = part_serializers.BomImportSubmitSerializer -class BomDetail(RetrieveUpdateDestroyAPI): - """API endpoint for detail view of a single BomItem object.""" - - queryset = BomItem.objects.all() - serializer_class = part_serializers.BomItemSerializer - - def get_queryset(self, *args, **kwargs): - """Prefetch related fields for this queryset""" - queryset = super().get_queryset(*args, **kwargs) - - queryset = self.get_serializer_class().setup_eager_loading(queryset) - queryset = self.get_serializer_class().annotate_queryset(queryset) - - return queryset - - class BomItemValidate(UpdateAPI): """API endpoint for validating a BomItem.""" @@ -1958,7 +1908,7 @@ part_api_urls = [ ])), # Category detail endpoints - re_path(r'^(?P\d+)/', include([ + path(r'/', include([ re_path(r'^metadata/', CategoryMetadata.as_view(), name='api-part-category-metadata'), @@ -1971,31 +1921,31 @@ part_api_urls = [ # Base URL for PartTestTemplate API endpoints re_path(r'^test-template/', include([ - re_path(r'^(?P\d+)/', PartTestTemplateDetail.as_view(), name='api-part-test-template-detail'), + path(r'/', PartTestTemplateDetail.as_view(), name='api-part-test-template-detail'), path('', PartTestTemplateList.as_view(), name='api-part-test-template-list'), ])), # Base URL for PartAttachment API endpoints re_path(r'^attachment/', include([ - re_path(r'^(?P\d+)/', PartAttachmentDetail.as_view(), name='api-part-attachment-detail'), + path(r'/', PartAttachmentDetail.as_view(), name='api-part-attachment-detail'), path('', PartAttachmentList.as_view(), name='api-part-attachment-list'), ])), # Base URL for part sale pricing re_path(r'^sale-price/', include([ - re_path(r'^(?P\d+)/', PartSalePriceDetail.as_view(), name='api-part-sale-price-detail'), + path(r'/', PartSalePriceDetail.as_view(), name='api-part-sale-price-detail'), re_path(r'^.*$', PartSalePriceList.as_view(), name='api-part-sale-price-list'), ])), # Base URL for part internal pricing re_path(r'^internal-price/', include([ - re_path(r'^(?P\d+)/', PartInternalPriceDetail.as_view(), name='api-part-internal-price-detail'), + path(r'/', PartInternalPriceDetail.as_view(), name='api-part-internal-price-detail'), re_path(r'^.*$', PartInternalPriceList.as_view(), name='api-part-internal-price-list'), ])), # Base URL for PartRelated API endpoints re_path(r'^related/', include([ - re_path(r'^(?P\d+)/', PartRelatedDetail.as_view(), name='api-part-related-detail'), + path(r'/', PartRelatedDetail.as_view(), name='api-part-related-detail'), re_path(r'^.*$', PartRelatedList.as_view(), name='api-part-related-list'), ])), @@ -2009,7 +1959,7 @@ part_api_urls = [ re_path(r'^.*$', PartParameterTemplateList.as_view(), name='api-part-parameter-template-list'), ])), - re_path(r'^(?P\d+)/', PartParameterDetail.as_view(), name='api-part-parameter-detail'), + path(r'/', PartParameterDetail.as_view(), name='api-part-parameter-detail'), re_path(r'^.*$', PartParameterList.as_view(), name='api-part-parameter-list'), ])), @@ -2021,7 +1971,7 @@ part_api_urls = [ re_path(r'^.*$', PartStocktakeReportList.as_view(), name='api-part-stocktake-report-list'), ])), - re_path(r'^(?P\d+)/', PartStocktakeDetail.as_view(), name='api-part-stocktake-detail'), + path(r'/', PartStocktakeDetail.as_view(), name='api-part-stocktake-detail'), re_path(r'^.*$', PartStocktakeList.as_view(), name='api-part-stocktake-list'), ])), @@ -2033,7 +1983,7 @@ part_api_urls = [ # BOM template re_path(r'^bom_template/?', views.BomUploadTemplate.as_view(), name='api-bom-upload-template'), - re_path(r'^(?P\d+)/', include([ + path(r'/', include([ # Endpoint for extra serial number information re_path(r'^serial-numbers/', PartSerialNumberDetail.as_view(), name='api-part-serial-number-detail'), @@ -2073,14 +2023,14 @@ bom_api_urls = [ re_path(r'^substitute/', include([ # Detail view - re_path(r'^(?P\d+)/', BomItemSubstituteDetail.as_view(), name='api-bom-substitute-detail'), + path(r'/', BomItemSubstituteDetail.as_view(), name='api-bom-substitute-detail'), # Catch all re_path(r'^.*$', BomItemSubstituteList.as_view(), name='api-bom-substitute-list'), ])), # BOM Item Detail - re_path(r'^(?P\d+)/', include([ + path(r'/', include([ re_path(r'^validate/?', BomItemValidate.as_view(), name='api-bom-item-validate'), re_path(r'^metadata/?', BomItemMetadata.as_view(), name='api-bom-item-metadata'), re_path(r'^.*$', BomDetail.as_view(), name='api-bom-item-detail'), diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 9d103b0bcf..caaf46aa16 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -37,7 +37,6 @@ import common.settings import InvenTree.fields import InvenTree.ready import InvenTree.tasks -import part.filters as part_filters import part.settings as part_settings from build import models as BuildModels from common.models import InvenTreeSetting @@ -1223,6 +1222,9 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): @property def can_build(self): """Return the number of units that can be build with available stock.""" + + import part.filters + # If this part does NOT have a BOM, result is simply the currently available stock if not self.has_bom: return 0 @@ -1246,9 +1248,9 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): # Annotate the 'available stock' for each part in the BOM ref = 'sub_part__' queryset = queryset.alias( - total_stock=part_filters.annotate_total_stock(reference=ref), - so_allocations=part_filters.annotate_sales_order_allocations(reference=ref), - bo_allocations=part_filters.annotate_build_order_allocations(reference=ref), + total_stock=part.filters.annotate_total_stock(reference=ref), + so_allocations=part.filters.annotate_sales_order_allocations(reference=ref), + bo_allocations=part.filters.annotate_build_order_allocations(reference=ref), ) # Calculate the 'available stock' based on previous annotations @@ -1262,9 +1264,9 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): # Extract similar information for any 'substitute' parts ref = 'substitutes__part__' queryset = queryset.alias( - sub_total_stock=part_filters.annotate_total_stock(reference=ref), - sub_so_allocations=part_filters.annotate_sales_order_allocations(reference=ref), - sub_bo_allocations=part_filters.annotate_build_order_allocations(reference=ref), + sub_total_stock=part.filters.annotate_total_stock(reference=ref), + sub_so_allocations=part.filters.annotate_sales_order_allocations(reference=ref), + sub_bo_allocations=part.filters.annotate_build_order_allocations(reference=ref), ) queryset = queryset.annotate( @@ -1275,12 +1277,12 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): ) # Extract similar information for any 'variant' parts - variant_stock_query = part_filters.variant_stock_query(reference='sub_part__') + variant_stock_query = part.filters.variant_stock_query(reference='sub_part__') queryset = queryset.alias( - var_total_stock=part_filters.annotate_variant_quantity(variant_stock_query, reference='quantity'), - var_bo_allocations=part_filters.annotate_variant_quantity(variant_stock_query, reference='allocations__quantity'), - var_so_allocations=part_filters.annotate_variant_quantity(variant_stock_query, reference='sales_order_allocations__quantity'), + var_total_stock=part.filters.annotate_variant_quantity(variant_stock_query, reference='quantity'), + var_bo_allocations=part.filters.annotate_variant_quantity(variant_stock_query, reference='allocations__quantity'), + var_so_allocations=part.filters.annotate_variant_quantity(variant_stock_query, reference='sales_order_allocations__quantity'), ) queryset = queryset.annotate( @@ -2083,6 +2085,16 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): return tests + def getTestTemplateMap(self, **kwargs): + """Return a map of all test templates associated with this Part""" + + templates = {} + + for template in self.getTestTemplates(**kwargs): + templates[template.key] = template + + return templates + def getRequiredTests(self): """Return the tests which are required by this part""" return self.getTestTemplates(required=True) diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html index 408d261c29..7370a10db6 100644 --- a/InvenTree/part/templates/part/category.html +++ b/InvenTree/part/templates/part/category.html @@ -183,11 +183,6 @@
  • {% trans "Order Parts" %}
  • - {% if report_enabled %} -
  • - {% trans "Print Labels" %} -
  • - {% endif %}
    {% include "filter_list.html" with id="parts" %} diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index 372eecd076..f9ddf2652a 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -548,7 +548,7 @@ deleteManufacturerParts(selections, { success: function() { - $("#manufacturer-part-table").bootstrapTable("refresh"); + $("#manufacturer-part-table").bootstrapTable('refresh'); } }); }); @@ -558,7 +558,7 @@ createManufacturerPart({ part: {{ part.pk }}, onSuccess: function() { - $("#manufacturer-part-table").bootstrapTable("refresh"); + $("#manufacturer-part-table").bootstrapTable('refresh'); } }); }); @@ -677,7 +677,11 @@ {% if report_enabled %} $("#print-bom-report").click(function() { - printBomReports([{{ part.pk }}]); + printReports({ + items: [{{ part.pk }}], + key: 'part', + url: '{% url "api-bom-report-list" %}' + }); }); {% endif %} }); @@ -709,9 +713,7 @@ }, focus: 'part_2', title: '{% trans "Add Related Part" %}', - onSuccess: function() { - $('#related-parts-table').bootstrapTable('refresh'); - } + refreshTable: '#related-parts-table', }); }); @@ -797,9 +799,7 @@ part: {{ part.pk }} }), title: '{% trans "Add Test Result Template" %}', - onSuccess: function() { - $("#test-template-table").bootstrapTable("refresh"); - } + refreshTable: '#test-template-table', }); }); }); @@ -870,9 +870,7 @@ data: {}, }, title: '{% trans "Add Parameter" %}', - onSuccess: function() { - $('#parameter-table').bootstrapTable('refresh'); - } + refreshTable: '#parameter-table', }); }); {% endif %} @@ -906,20 +904,6 @@ } } }); - - enableDragAndDrop( - '#attachment-dropzone', - '{% url "api-part-attachment-list" %}', - { - data: { - part: {{ part.id }}, - }, - label: 'attachment', - success: function(data, status, xhr) { - reloadAttachmentTable(); - } - } - ); }); onPanelLoad('pricing', function() { diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index 691e3c20a7..f7be28565b 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -475,7 +475,11 @@ {% if labels_enabled %} $('#print-label').click(function() { - printPartLabels([{{ part.pk }}]); + printLabels({ + items: [{{ part.pk }}], + key: 'part', + url: '{% url "api-part-label-list" %}', + }); }); {% endif %} diff --git a/InvenTree/part/templatetags/status_codes.py b/InvenTree/part/templatetags/status_codes.py index d3811c38f5..9eef869678 100644 --- a/InvenTree/part/templatetags/status_codes.py +++ b/InvenTree/part/templatetags/status_codes.py @@ -4,7 +4,8 @@ from django import template from django.utils.safestring import mark_safe from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus, - SalesOrderStatus, StockStatus) + ReturnOrderStatus, SalesOrderStatus, + StockStatus) register = template.Library() @@ -21,6 +22,12 @@ def sales_order_status_label(key, *args, **kwargs): return mark_safe(SalesOrderStatus.render(key, large=kwargs.get('large', False))) +@register.simple_tag +def return_order_status_label(key, *args, **kwargs): + """Render a ReturnOrder status label""" + return mark_safe(ReturnOrderStatus.render(key, large=kwargs.get('large', False))) + + @register.simple_tag def stock_status_label(key, *args, **kwargs): """Render a StockItem status label.""" diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index 9cd5cafbec..6654c3a9ea 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -6,7 +6,7 @@ - Display / Create / Edit / Delete SupplierPart """ -from django.urls import include, re_path +from django.urls import include, path, re_path from . import views @@ -35,7 +35,7 @@ part_urls = [ re_path(r'^import-api/', views.PartImportAjax.as_view(), name='api-part-import'), # Individual part using pk - re_path(r'^(?P\d+)/', include(part_detail_urls)), + path(r'/', include(part_detail_urls)), # Part category re_path(r'^category/', include(category_urls)), diff --git a/InvenTree/plugin/api.py b/InvenTree/plugin/api.py index 029cf2b20e..aedc2195cc 100644 --- a/InvenTree/plugin/api.py +++ b/InvenTree/plugin/api.py @@ -1,6 +1,6 @@ """API for the plugin app.""" -from django.urls import include, re_path +from django.urls import include, path, re_path from django_filters.rest_framework import DjangoFilterBackend from rest_framework import filters, permissions, status @@ -255,7 +255,7 @@ plugin_api_urls = [ ])), # Detail views for a single PluginConfig item - re_path(r'^(?P\d+)/', include([ + path(r'/', include([ re_path(r'^settings/(?P\w+)/', PluginSettingDetail.as_view(), name='api-plugin-setting-detail-pk'), re_path(r'^activate/', PluginActivate.as_view(), name='api-plugin-detail-activate'), re_path(r'^.*$', PluginDetail.as_view(), name='api-plugin-detail'), diff --git a/InvenTree/report/admin.py b/InvenTree/report/admin.py index adf813aede..94acf89e98 100644 --- a/InvenTree/report/admin.py +++ b/InvenTree/report/admin.py @@ -3,7 +3,8 @@ from django.contrib import admin from .models import (BillOfMaterialsReport, BuildReport, PurchaseOrderReport, - ReportAsset, ReportSnippet, SalesOrderReport, TestReport) + ReportAsset, ReportSnippet, ReturnOrderReport, + SalesOrderReport, TestReport) class ReportTemplateAdmin(admin.ModelAdmin): @@ -28,4 +29,5 @@ admin.site.register(TestReport, ReportTemplateAdmin) admin.site.register(BuildReport, ReportTemplateAdmin) admin.site.register(BillOfMaterialsReport, ReportTemplateAdmin) admin.site.register(PurchaseOrderReport, ReportTemplateAdmin) +admin.site.register(ReturnOrderReport, ReportTemplateAdmin) admin.site.register(SalesOrderReport, ReportTemplateAdmin) diff --git a/InvenTree/report/api.py b/InvenTree/report/api.py index f4b9ac335c..e3c9820931 100644 --- a/InvenTree/report/api.py +++ b/InvenTree/report/api.py @@ -24,9 +24,10 @@ from plugin.serializers import MetadataSerializer from stock.models import StockItem, StockItemAttachment from .models import (BillOfMaterialsReport, BuildReport, PurchaseOrderReport, - SalesOrderReport, TestReport) + ReturnOrderReport, SalesOrderReport, TestReport) from .serializers import (BOMReportSerializer, BuildReportSerializer, PurchaseOrderReportSerializer, + ReturnOrderReportSerializer, SalesOrderReportSerializer, TestReportSerializer) @@ -423,6 +424,31 @@ class SalesOrderReportPrint(SalesOrderReportMixin, ReportPrintMixin, RetrieveAPI pass +class ReturnOrderReportMixin(ReportFilterMixin): + """Mixin for the ReturnOrderReport report template""" + + ITEM_MODEL = order.models.ReturnOrder + ITEM_KEY = 'order' + + queryset = ReturnOrderReport.objects.all() + serializer_class = ReturnOrderReportSerializer + + +class ReturnOrderReportList(ReturnOrderReportMixin, ReportListView): + """API list endpoint for the ReturnOrderReport model""" + pass + + +class ReturnOrderReportDetail(ReturnOrderReportMixin, RetrieveUpdateDestroyAPI): + """API endpoint for a single ReturnOrderReport object""" + pass + + +class ReturnOrderReportPrint(ReturnOrderReportMixin, ReportPrintMixin, RetrieveAPI): + """API endpoint for printing a ReturnOrderReport object""" + pass + + class ReportMetadata(RetrieveUpdateAPI): """API endpoint for viewing / updating Report metadata.""" MODEL_REF = 'reportmodel' @@ -453,7 +479,7 @@ report_api_urls = [ # Purchase order reports re_path(r'po/', include([ # Detail views - re_path(r'^(?P\d+)/', include([ + path(r'/', include([ re_path(r'print/', PurchaseOrderReportPrint.as_view(), name='api-po-report-print'), re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: PurchaseOrderReport}, name='api-po-report-metadata'), path('', PurchaseOrderReportDetail.as_view(), name='api-po-report-detail'), @@ -466,7 +492,7 @@ report_api_urls = [ # Sales order reports re_path(r'so/', include([ # Detail views - re_path(r'^(?P\d+)/', include([ + path(r'/', include([ re_path(r'print/', SalesOrderReportPrint.as_view(), name='api-so-report-print'), re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: SalesOrderReport}, name='api-so-report-metadata'), path('', SalesOrderReportDetail.as_view(), name='api-so-report-detail'), @@ -475,10 +501,19 @@ report_api_urls = [ path('', SalesOrderReportList.as_view(), name='api-so-report-list'), ])), + # Return order reports + re_path(r'return-order/', include([ + path(r'/', include([ + path(r'print/', ReturnOrderReportPrint.as_view(), name='api-return-order-report-print'), + path('', ReturnOrderReportDetail.as_view(), name='api-return-order-report-detail'), + ])), + path('', ReturnOrderReportList.as_view(), name='api-return-order-report-list'), + ])), + # Build reports re_path(r'build/', include([ # Detail views - re_path(r'^(?P\d+)/', include([ + path(r'/', include([ re_path(r'print/?', BuildReportPrint.as_view(), name='api-build-report-print'), re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: BuildReport}, name='api-build-report-metadata'), re_path(r'^.$', BuildReportDetail.as_view(), name='api-build-report-detail'), @@ -492,7 +527,7 @@ report_api_urls = [ re_path(r'bom/', include([ # Detail views - re_path(r'^(?P\d+)/', include([ + path(r'/', include([ re_path(r'print/?', BOMReportPrint.as_view(), name='api-bom-report-print'), re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: BillOfMaterialsReport}, name='api-bom-report-metadata'), re_path(r'^.*$', BOMReportDetail.as_view(), name='api-bom-report-detail'), @@ -505,7 +540,7 @@ report_api_urls = [ # Stock item test reports re_path(r'test/', include([ # Detail views - re_path(r'^(?P\d+)/', include([ + path(r'/', include([ re_path(r'print/?', StockItemTestReportPrint.as_view(), name='api-stockitem-testreport-print'), re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: TestReport}, name='api-stockitem-testreport-metadata'), re_path(r'^.*$', StockItemTestReportDetail.as_view(), name='api-stockitem-testreport-detail'), diff --git a/InvenTree/report/apps.py b/InvenTree/report/apps.py index 63c994fa87..64eb80702a 100644 --- a/InvenTree/report/apps.py +++ b/InvenTree/report/apps.py @@ -8,8 +8,6 @@ from pathlib import Path from django.apps import AppConfig from django.conf import settings -from InvenTree.ready import canAppAccessDatabase - logger = logging.getLogger("inventree") @@ -19,12 +17,21 @@ class ReportConfig(AppConfig): def ready(self): """This function is called whenever the report app is loaded.""" + + from InvenTree.ready import canAppAccessDatabase + + # Configure logging for PDF generation (disable "info" messages) + logging.getLogger('fontTools').setLevel(logging.WARNING) + logging.getLogger('weasyprint').setLevel(logging.WARNING) + + # Create entries for default report templates if canAppAccessDatabase(allow_test=True): self.create_default_test_reports() self.create_default_build_reports() self.create_default_bill_of_materials_reports() self.create_default_purchase_order_reports() self.create_default_sales_order_reports() + self.create_default_return_order_reports() def create_default_reports(self, model, reports): """Copy defualt report files across to the media directory.""" @@ -174,3 +181,23 @@ class ReportConfig(AppConfig): ] self.create_default_reports(SalesOrderReport, reports) + + def create_default_return_order_reports(self): + """Create database entries for the default ReturnOrderReport templates""" + + try: + from report.models import ReturnOrderReport + except Exception: # pragma: no cover + # Database not yet ready + return + + # List of templates to copy across + reports = [ + { + 'file': 'inventree_return_order_report.html', + 'name': 'InvenTree Return Order', + 'description': 'Return Order example report', + } + ] + + self.create_default_reports(ReturnOrderReport, reports) diff --git a/InvenTree/report/migrations/0018_returnorderreport.py b/InvenTree/report/migrations/0018_returnorderreport.py new file mode 100644 index 0000000000..8bdbb6ebe8 --- /dev/null +++ b/InvenTree/report/migrations/0018_returnorderreport.py @@ -0,0 +1,31 @@ +# Generated by Django 3.2.18 on 2023-03-15 11:17 + +import django.core.validators +from django.db import migrations, models +import report.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('report', '0017_auto_20230317_0816'), + ] + + operations = [ + migrations.CreateModel( + name='ReturnOrderReport', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='Template name', max_length=100, verbose_name='Name')), + ('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')), + ('description', models.CharField(help_text='Report template description', max_length=250, verbose_name='Description')), + ('revision', models.PositiveIntegerField(default=1, editable=False, help_text='Report revision number (auto-increments)', verbose_name='Revision')), + ('filename_pattern', models.CharField(default='report.pdf', help_text='Pattern for generating report filenames', max_length=100, verbose_name='Filename Pattern')), + ('enabled', models.BooleanField(default=True, help_text='Report template is enabled', verbose_name='Enabled')), + ('filters', models.CharField(blank=True, help_text='Return order query filters', max_length=250, validators=[report.models.validate_return_order_filters], verbose_name='Filters')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/InvenTree/report/migrations/0019_returnorderreport_metadata.py b/InvenTree/report/migrations/0019_returnorderreport_metadata.py new file mode 100644 index 0000000000..993f4045d5 --- /dev/null +++ b/InvenTree/report/migrations/0019_returnorderreport_metadata.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.18 on 2023-03-23 11:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('report', '0018_returnorderreport'), + ] + + operations = [ + migrations.AddField( + model_name='returnorderreport', + name='metadata', + field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'), + ), + ] diff --git a/InvenTree/report/models.py b/InvenTree/report/models.py index 4e645c1c6d..9776fb0077 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -68,6 +68,11 @@ def validate_sales_order_filters(filters): return validateFilterString(filters, model=order.models.SalesOrder) +def validate_return_order_filters(filters): + """Validate filter string against ReturnOrder model""" + return validateFilterString(filters, model=order.models.ReturnOrder) + + class WeasyprintReportMixin(WeasyTemplateResponseMixin): """Class for rendering a HTML template to a PDF.""" @@ -303,6 +308,30 @@ class TestReport(ReportTemplateBase): return items.exists() + def get_test_keys(self, stock_item): + """Construct a flattened list of test 'keys' for this StockItem: + + - First, any 'required' tests + - Second, any 'non required' tests + - Finally, any test results which do not match a test + """ + + keys = [] + + for test in stock_item.part.getTestTemplates(required=True): + if test.key not in keys: + keys.append(test.key) + + for test in stock_item.part.getTestTemplates(required=False): + if test.key not in keys: + keys.append(test.key) + + for result in stock_item.testResultList(include_installed=self.include_installed): + if result.key not in keys: + keys.append(result.key) + + return list(keys) + def get_context_data(self, request): """Return custom context data for the TestReport template""" stock_item = self.object_to_print @@ -312,6 +341,9 @@ class TestReport(ReportTemplateBase): 'serial': stock_item.serial, 'part': stock_item.part, 'parameters': stock_item.part.parameters_map(), + 'test_keys': self.get_test_keys(stock_item), + 'test_template_list': stock_item.part.getTestTemplates(), + 'test_template_map': stock_item.part.getTestTemplateMap(), 'results': stock_item.testResultMap(include_installed=self.include_installed), 'result_list': stock_item.testResultList(include_installed=self.include_installed), 'installed_items': stock_item.get_installed_items(cascade=True), @@ -468,6 +500,45 @@ class SalesOrderReport(ReportTemplateBase): } +class ReturnOrderReport(ReportTemplateBase): + """Render a custom report against a ReturnOrder object""" + + @staticmethod + def get_api_url(): + """Return the API URL associated with the ReturnOrderReport model""" + return reverse('api-return-order-report-list') + + @classmethod + def getSubdir(cls): + """Return the directory where the ReturnOrderReport templates are stored""" + return 'returnorder' + + filters = models.CharField( + blank=True, + max_length=250, + verbose_name=_('Filters'), + help_text=_('Return order query filters'), + validators=[ + validate_return_order_filters, + ] + ) + + def get_context_data(self, request): + """Return custom context data for the ReturnOrderReport template""" + + order = self.object_to_print + + return { + 'order': order, + 'description': order.description, + 'reference': order.reference, + 'customer': order.customer, + 'lines': order.lines, + 'extra_lines': order.extra_lines, + 'title': str(order), + } + + def rename_snippet(instance, filename): """Function to rename a report snippet once uploaded""" diff --git a/InvenTree/report/serializers.py b/InvenTree/report/serializers.py index 155da6e7cc..330279834f 100644 --- a/InvenTree/report/serializers.py +++ b/InvenTree/report/serializers.py @@ -4,99 +4,83 @@ from InvenTree.serializers import (InvenTreeAttachmentSerializerField, InvenTreeModelSerializer) from .models import (BillOfMaterialsReport, BuildReport, PurchaseOrderReport, - SalesOrderReport, TestReport) + ReturnOrderReport, SalesOrderReport, TestReport) -class TestReportSerializer(InvenTreeModelSerializer): +class ReportSerializerBase(InvenTreeModelSerializer): + """Base class for report serializer""" + + template = InvenTreeAttachmentSerializerField(required=True) + + @staticmethod + def report_fields(): + """Generic serializer fields for a report template""" + + return [ + 'pk', + 'name', + 'description', + 'template', + 'filters', + 'enabled', + ] + + +class TestReportSerializer(ReportSerializerBase): """Serializer class for the TestReport model""" class Meta: """Metaclass options.""" model = TestReport - fields = [ - 'pk', - 'name', - 'description', - 'template', - 'filters', - 'enabled', - ] - - template = InvenTreeAttachmentSerializerField(required=True) + fields = ReportSerializerBase.report_fields() -class BuildReportSerializer(InvenTreeModelSerializer): +class BuildReportSerializer(ReportSerializerBase): """Serializer class for the BuildReport model""" class Meta: """Metaclass options.""" model = BuildReport - fields = [ - 'pk', - 'name', - 'description', - 'template', - 'filters', - 'enabled', - ] - - template = InvenTreeAttachmentSerializerField(required=True) + fields = ReportSerializerBase.report_fields() -class BOMReportSerializer(InvenTreeModelSerializer): +class BOMReportSerializer(ReportSerializerBase): """Serializer class for the BillOfMaterialsReport model""" class Meta: """Metaclass options.""" model = BillOfMaterialsReport - fields = [ - 'pk', - 'name', - 'description', - 'template', - 'filters', - 'enabled', - ] - - template = InvenTreeAttachmentSerializerField(required=True) + fields = ReportSerializerBase.report_fields() -class PurchaseOrderReportSerializer(InvenTreeModelSerializer): +class PurchaseOrderReportSerializer(ReportSerializerBase): """Serializer class for the PurchaseOrdeReport model""" class Meta: """Metaclass options.""" model = PurchaseOrderReport - fields = [ - 'pk', - 'name', - 'description', - 'template', - 'filters', - 'enabled', - ] - - template = InvenTreeAttachmentSerializerField(required=True) + fields = ReportSerializerBase.report_fields() -class SalesOrderReportSerializer(InvenTreeModelSerializer): +class SalesOrderReportSerializer(ReportSerializerBase): """Serializer class for the SalesOrderReport model""" class Meta: """Metaclass options.""" model = SalesOrderReport - fields = [ - 'pk', - 'name', - 'description', - 'template', - 'filters', - 'enabled', - ] + fields = ReportSerializerBase.report_fields() - template = InvenTreeAttachmentSerializerField(required=True) + +class ReturnOrderReportSerializer(ReportSerializerBase): + """Serializer class for the ReturnOrderReport model""" + + class Meta: + """Metaclass options""" + + model = ReturnOrderReport + fields = ReportSerializerBase.report_fields() diff --git a/InvenTree/report/templates/report/inventree_order_report_base.html b/InvenTree/report/templates/report/inventree_order_report_base.html new file mode 100644 index 0000000000..f9adc114cd --- /dev/null +++ b/InvenTree/report/templates/report/inventree_order_report_base.html @@ -0,0 +1,70 @@ +{% extends "report/inventree_report_base.html" %} + +{% load i18n %} +{% load report %} +{% load barcode %} +{% load inventree_extras %} +{% load markdownify %} + +{% block page_margin %} +margin: 2cm; +margin-top: 4cm; +{% endblock %} + +{% block bottom_left %} +content: "v{{report_revision}} - {{ date.isoformat }}"; +{% endblock %} + +{% block bottom_center %} +content: "{% inventree_version shortstring=True %}"; +{% endblock %} + +{% block style %} + +.header-right { + text-align: right; + float: right; +} + +.logo { + height: 20mm; + vertical-align: middle; +} + +.thumb-container { + width: 32px; + display: inline; +} + + +.part-thumb { + max-width: 32px; + max-height: 32px; + display: inline; +} + +.part-text { + display: inline; +} + +table { + border: 1px solid #eee; + border-radius: 3px; + border-collapse: collapse; + width: 100%; + font-size: 80%; +} + +table td { + border: 1px solid #eee; +} + +table td.shrink { + white-space: nowrap +} + +table td.expand { + width: 99% +} + +{% endblock %} diff --git a/InvenTree/report/templates/report/inventree_po_report_base.html b/InvenTree/report/templates/report/inventree_po_report_base.html index 0aa77f6c3b..d6c5ee3ee3 100644 --- a/InvenTree/report/templates/report/inventree_po_report_base.html +++ b/InvenTree/report/templates/report/inventree_po_report_base.html @@ -1,72 +1,10 @@ -{% extends "report/inventree_report_base.html" %} +{% extends "report/inventree_order_report_base.html" %} {% load i18n %} {% load report %} {% load barcode %} {% load inventree_extras %} - -{% block page_margin %} -margin: 2cm; -margin-top: 4cm; -{% endblock %} - -{% block bottom_left %} -content: "v{{report_revision}} - {{ date.isoformat }}"; -{% endblock %} - -{% block bottom_center %} -content: "{% inventree_version shortstring=True %}"; -{% endblock %} - -{% block style %} - -.header-right { - text-align: right; - float: right; -} - -.logo { - height: 20mm; - vertical-align: middle; -} - -.thumb-container { - width: 32px; - display: inline; -} - - -.part-thumb { - max-width: 32px; - max-height: 32px; - display: inline; -} - -.part-text { - display: inline; -} - -table { - border: 1px solid #eee; - border-radius: 3px; - border-collapse: collapse; - width: 100%; - font-size: 80%; -} - -table td { - border: 1px solid #eee; -} - -table td.shrink { - white-space: nowrap -} - -table td.expand { - width: 99% -} - -{% endblock %} +{% load markdownify %} {% block header_content %} diff --git a/InvenTree/report/templates/report/inventree_return_order_report.html b/InvenTree/report/templates/report/inventree_return_order_report.html new file mode 100644 index 0000000000..cece937a0e --- /dev/null +++ b/InvenTree/report/templates/report/inventree_return_order_report.html @@ -0,0 +1 @@ +{% extends "report/inventree_return_order_report_base.html" %} diff --git a/InvenTree/report/templates/report/inventree_return_order_report_base.html b/InvenTree/report/templates/report/inventree_return_order_report_base.html new file mode 100644 index 0000000000..93b2f30c06 --- /dev/null +++ b/InvenTree/report/templates/report/inventree_return_order_report_base.html @@ -0,0 +1,62 @@ +{% extends "report/inventree_order_report_base.html" %} + +{% load i18n %} +{% load report %} +{% load barcode %} +{% load inventree_extras %} +{% load markdownify %} + +{% block header_content %} + + +
    +

    {% trans "Return Order" %} {{ prefix }}{{ reference }}

    + {% if customer %}{{ customer.name }}{% endif %} +
    +{% endblock header_content %} + +{% block page_content %} +

    {% trans "Line Items" %}

    + + + + + + + + + + + + {% for line in lines.all %} + + + + + + + {% endfor %} + + {% if extra_lines %} + + {% for line in extra_lines.all %} + + + + + + + {% endfor %} + {% endif %} + + +
    {% trans "Part" %}{% trans "Serial Number" %}{% trans "Reference" %}{% trans "Note" %}
    +
    + +
    +
    + {{ line.item.part.full_name }} +
    +
    {{ line.item.serial }}{{ line.reference }}{{ line.notes }}
    {% trans "Extra Line Items" %}
    {{ line.reference }}{{ line.notes }}
    + +{% endblock page_content %} diff --git a/InvenTree/report/templates/report/inventree_so_report_base.html b/InvenTree/report/templates/report/inventree_so_report_base.html index 4869a5453c..7ee4ca0edd 100644 --- a/InvenTree/report/templates/report/inventree_so_report_base.html +++ b/InvenTree/report/templates/report/inventree_so_report_base.html @@ -1,4 +1,4 @@ -{% extends "report/inventree_report_base.html" %} +{% extends "report/inventree_order_report_base.html" %} {% load i18n %} {% load report %} @@ -6,69 +6,6 @@ {% load inventree_extras %} {% load markdownify %} -{% block page_margin %} -margin: 2cm; -margin-top: 4cm; -{% endblock %} - -{% block bottom_left %} -content: "v{{report_revision}} - {{ date.isoformat }}"; -{% endblock %} - -{% block bottom_center %} -content: "{% inventree_version shortstring=True %}"; -{% endblock %} - -{% block style %} - -.header-right { - text-align: right; - float: right; -} - -.logo { - height: 20mm; - vertical-align: middle; -} - -.thumb-container { - width: 32px; - display: inline; -} - - -.part-thumb { - max-width: 32px; - max-height: 32px; - display: inline; -} - -.part-text { - display: inline; -} - -table { - border: 1px solid #eee; - border-radius: 3px; - border-collapse: collapse; - width: 100%; - font-size: 80%; -} - -table td { - border: 1px solid #eee; -} - -table td.shrink { - white-space: nowrap -} - -table td.expand { - width: 99% -} - -{% endblock %} - {% block header_content %} diff --git a/InvenTree/report/templates/report/inventree_test_report_base.html b/InvenTree/report/templates/report/inventree_test_report_base.html index 7074cb0ca6..fbb5f34d3f 100644 --- a/InvenTree/report/templates/report/inventree_test_report_base.html +++ b/InvenTree/report/templates/report/inventree_test_report_base.html @@ -33,6 +33,15 @@ content: "{% trans 'Stock Item Test Report' %}"; color: #F55; } +.test-not-found { + color: #33A; +} + +.required-test-not-found { + color: #EEE; + background-color: #F55; +} + .container { padding: 5px; border: 1px solid; @@ -84,7 +93,7 @@ content: "{% trans 'Stock Item Test Report' %}"; -{% if resul_list|length > 0 %} +{% if test_keys|length > 0 %}

    {% trans "Test Results" %}

    @@ -101,22 +110,44 @@ content: "{% trans 'Stock Item Test Report' %}"; - {% for test in result_list %} + {% for key in test_keys %} + + {% getkey test_template_map key as test_template %} + {% getkey results key as test_result %} - - {% if test.result %} + + {% if test_result %} + {% if test_result.result %} {% else %} {% endif %} - - - + + + + {% else %} + {% if test_template.required %} + + {% else %} + + {% endif %} + {% endif %} {% endfor %}

    {{ test.test }} + {% if test_template %} + {% render_html_text test_template.test_name bold=test_template.required %} + {% elif test_result %} + {% render_html_text test_result.test italic=True %} + {% else %} + + {{ key }} + {% endif %} + {% trans "Pass" %}{% trans "Fail" %}{{ test.value }}{{ test.user.username }}{{ test.date.date.isoformat }}{{ test_result.value }}{{ test_result.user.username }}{{ test_result.date.date.isoformat }}{% trans "No result (required)" %}{% trans "No result" %}
    +{% else %} +No tests defined for this stock item {% endif %} {% if installed_items|length > 0 %} diff --git a/InvenTree/report/templatetags/report.py b/InvenTree/report/templatetags/report.py index 474cb8fa02..c7f3365c04 100644 --- a/InvenTree/report/templatetags/report.py +++ b/InvenTree/report/templatetags/report.py @@ -19,17 +19,52 @@ logger = logging.getLogger('inventree') @register.simple_tag() -def getkey(value: dict, arg): +def getindex(container: list, index: int): + """Return the value contained at the specified index of the list. + + This function is provideed to get around template rendering limitations. + + Arguments: + container: A python list object + index: The index to retrieve from the list + """ + + # Index *must* be an integer + try: + index = int(index) + except ValueError: + return None + + if index < 0 or index >= len(container): + return None + + try: + value = container[index] + except IndexError: + value = None + + return value + + +@register.simple_tag() +def getkey(container: dict, key): """Perform key lookup in the provided dict object. This function is provided to get around template rendering limitations. Ref: https://stackoverflow.com/questions/1906129/dict-keys-with-spaces-in-django-templates Arguments: - value: A python dict object - arg: The 'key' to be found within the dict + container: A python dict object + key: The 'key' to be found within the dict """ - return value[arg] + if type(container) is not dict: + logger.warning("getkey() called with non-dict object") + return None + + if key in container: + return container[key] + else: + return None @register.simple_tag() @@ -215,3 +250,31 @@ def render_currency(money, **kwargs): """Render a currency / Money object""" return InvenTree.helpers.render_currency(money, **kwargs) + + +@register.simple_tag +def render_html_text(text: str, **kwargs): + """Render a text item with some simple html tags. + + kwargs: + bold: Boolean, whether bold (or not) + italic: Boolean, whether italic (or not) + heading: str, heading level e.g. 'h3' + """ + + tags = [] + + if kwargs.get('bold', False): + tags.append('strong') + + if kwargs.get('italic', False): + tags.append('em') + + if heading := kwargs.get('heading', ''): + tags.append(heading) + + output = ''.join([f'<{tag}>' for tag in tags]) + output += text + output += ''.join([f'' for tag in tags]) + + return mark_safe(output) diff --git a/InvenTree/report/tests.py b/InvenTree/report/tests.py index 27327c1d22..90b72f3980 100644 --- a/InvenTree/report/tests.py +++ b/InvenTree/report/tests.py @@ -29,6 +29,20 @@ class ReportTagTest(TestCase): """Enable or disable debug mode for reports""" InvenTreeSetting.set_setting('REPORT_DEBUG_MODE', value, change_user=None) + def test_getindex(self): + """Tests for the 'getindex' template tag""" + + fn = report_tags.getindex + data = [1, 2, 3, 4, 5, 6] + + # Out of bounds or invalid + self.assertEqual(fn(data, -1), None) + self.assertEqual(fn(data, 99), None) + self.assertEqual(fn(data, 'xx'), None) + + for idx in range(len(data)): + self.assertEqual(fn(data, idx), data[idx]) + def test_getkey(self): """Tests for the 'getkey' template tag""" @@ -419,7 +433,7 @@ class BOMReportTest(ReportTest): class PurchaseOrderReportTest(ReportTest): - """Unit test class fort he PurchaseOrderReport model""" + """Unit test class for the PurchaseOrderReport model""" model = report_models.PurchaseOrderReport list_url = 'api-po-report-list' @@ -446,3 +460,18 @@ class SalesOrderReportTest(ReportTest): self.copyReportTemplate('inventree_so_report.html', 'sales order report') return super().setUp() + + +class ReturnOrderReportTest(ReportTest): + """Unit tests for the ReturnOrderReport model""" + + model = report_models.ReturnOrderReport + list_url = 'api-return-order-report-list' + detail_url = 'api-return-order-report-detail' + print_url = 'api-return-order-report-print' + + def setUp(self): + """Setup function for the ReturnOrderReport tests""" + self.copyReportTemplate('inventree_return_order_report.html', 'return order report') + + return super().setUp() diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 1c1429cf0e..36355881c5 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -30,8 +30,10 @@ from InvenTree.helpers import (DownloadFile, extract_serial_numbers, isNull, from InvenTree.mixins import (CreateAPI, CustomRetrieveUpdateDestroyAPI, ListAPI, ListCreateAPI, RetrieveAPI, RetrieveUpdateAPI, RetrieveUpdateDestroyAPI) -from order.models import PurchaseOrder, SalesOrder, SalesOrderAllocation -from order.serializers import PurchaseOrderSerializer +from order.models import (PurchaseOrder, ReturnOrder, SalesOrder, + SalesOrderAllocation) +from order.serializers import (PurchaseOrderSerializer, ReturnOrderSerializer, + SalesOrderSerializer) from part.models import BomItem, Part, PartCategory from part.serializers import PartBriefSerializer from plugin.serializers import MetadataSerializer @@ -1262,7 +1264,7 @@ class StockTrackingList(ListAPI): except Exception: pass - # Add purchaseorder detail + # Add PurchaseOrder detail if 'purchaseorder' in deltas: try: order = PurchaseOrder.objects.get(pk=deltas['purchaseorder']) @@ -1271,6 +1273,24 @@ class StockTrackingList(ListAPI): except Exception: pass + # Add SalesOrder detail + if 'salesorder' in deltas: + try: + order = SalesOrder.objects.get(pk=deltas['salesorder']) + serializer = SalesOrderSerializer(order) + deltas['salesorder_detail'] = serializer.data + except Exception: + pass + + # Add ReturnOrder detail + if 'returnorder' in deltas: + try: + order = ReturnOrder.objects.get(pk=deltas['returnorder']) + serializer = ReturnOrderSerializer(order) + deltas['returnorder_detail'] = serializer.data + except Exception: + pass + if request.is_ajax(): return JsonResponse(data, safe=False) else: @@ -1368,7 +1388,7 @@ stock_api_urls = [ re_path(r'^tree/', StockLocationTree.as_view(), name='api-location-tree'), # Stock location detail endpoints - re_path(r'^(?P\d+)/', include([ + path(r'/', include([ re_path(r'^metadata/', LocationMetadata.as_view(), name='api-location-metadata'), @@ -1388,24 +1408,24 @@ stock_api_urls = [ # StockItemAttachment API endpoints re_path(r'^attachment/', include([ - re_path(r'^(?P\d+)/', StockAttachmentDetail.as_view(), name='api-stock-attachment-detail'), + path(r'/', StockAttachmentDetail.as_view(), name='api-stock-attachment-detail'), path('', StockAttachmentList.as_view(), name='api-stock-attachment-list'), ])), # StockItemTestResult API endpoints re_path(r'^test/', include([ - re_path(r'^(?P\d+)/', StockItemTestResultDetail.as_view(), name='api-stock-test-result-detail'), + path(r'/', StockItemTestResultDetail.as_view(), name='api-stock-test-result-detail'), re_path(r'^.*$', StockItemTestResultList.as_view(), name='api-stock-test-result-list'), ])), # StockItemTracking API endpoints re_path(r'^track/', include([ - re_path(r'^(?P\d+)/', StockTrackingDetail.as_view(), name='api-stock-tracking-detail'), + path(r'/', StockTrackingDetail.as_view(), name='api-stock-tracking-detail'), re_path(r'^.*$', StockTrackingList.as_view(), name='api-stock-tracking-list'), ])), # Detail views for a single stock item - re_path(r'^(?P\d+)/', include([ + path(r'/', include([ re_path(r'^convert/', StockItemConvert.as_view(), name='api-stock-item-convert'), re_path(r'^install/', StockItemInstall.as_view(), name='api-stock-item-install'), re_path(r'^metadata/', StockMetadata.as_view(), name='api-stock-item-metadata'), diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 2f25b84354..ed3f727514 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -457,8 +457,6 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, common.models.MetaMixin, M if old.status != self.status: deltas['status'] = self.status - # TODO - Other interesting changes we are interested in... - if add_note and len(deltas) > 0: self.add_tracking_entry( StockHistoryCode.EDITED, @@ -960,17 +958,22 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, common.models.MetaMixin, M item.customer = customer item.location = None - item.save() + item.save(add_note=False) - # TODO - Remove any stock item allocations from this stock item + code = StockHistoryCode.SENT_TO_CUSTOMER + deltas = { + 'customer': customer.pk, + 'customer_name': customer.pk, + } + + if order: + code = StockHistoryCode.SHIPPED_AGAINST_SALES_ORDER + deltas['salesorder'] = order.pk item.add_tracking_entry( - StockHistoryCode.SENT_TO_CUSTOMER, + code, user, - { - 'customer': customer.id, - 'customer_name': customer.name, - }, + deltas, notes=notes, ) @@ -992,7 +995,9 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, common.models.MetaMixin, M """ notes = kwargs.get('notes', '') - tracking_info = {} + tracking_info = { + 'location': location.pk, + } if self.customer: tracking_info['customer'] = self.customer.id diff --git a/InvenTree/stock/templates/stock/item.html b/InvenTree/stock/templates/stock/item.html index 3bf9799c56..71df55c5b1 100644 --- a/InvenTree/stock/templates/stock/item.html +++ b/InvenTree/stock/templates/stock/item.html @@ -222,30 +222,18 @@ ); }); - enableDragAndDrop( - '#attachment-dropzone', - "{% url 'api-stock-attachment-list' %}", - { - data: { - stock_item: {{ item.id }}, - }, - label: 'attachment', - success: function(data, status, xhr) { - reloadAttachmentTable(); + onPanelLoad('attachments', function() { + loadAttachmentTable('{% url "api-stock-attachment-list" %}', { + filters: { + stock_item: {{ item.pk }}, + }, + fields: { + stock_item: { + value: {{ item.pk }}, + hidden: true, } } - ); - - loadAttachmentTable('{% url "api-stock-attachment-list" %}', { - filters: { - stock_item: {{ item.pk }}, - }, - fields: { - stock_item: { - value: {{ item.pk }}, - hidden: true, - } - } + }); }); loadStockTestResultsTable( @@ -255,12 +243,12 @@ } ); - function reloadTable() { - $("#test-result-table").bootstrapTable("refresh"); - } - $("#test-report").click(function() { - printTestReports([{{ item.pk }}]); + printReports({ + items: [{{ item.pk }}], + key: 'item', + url: '{% url "api-stockitem-testreport-list" %}', + }); }); {% if user.is_staff %} @@ -299,7 +287,7 @@ method: 'DELETE', title: '{% trans "Delete Test Data" %}', preFormContent: html, - onSuccess: reloadTable, + refreshTable: '#test-result-table', }); } } @@ -315,7 +303,7 @@ stock_item: {{ item.pk }}, }), title: '{% trans "Add Test Result" %}', - onSuccess: reloadTable, + refreshTable: '#test-result-table', }); }); diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 049c0973c7..f897d7405c 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -493,11 +493,19 @@ $('#stock-uninstall').click(function() { }); $("#stock-test-report").click(function() { - printTestReports([{{ item.pk }}]); + printReports({ + items: [{{ item.pk }}], + key: 'item', + url: '{% url "api-stockitem-testreport-list" %}', + }); }); $("#print-label").click(function() { - printStockItemLabels([{{ item.pk }}]); + printLabels({ + items: [{{ item.pk }}], + url: '{% url "api-stockitem-label-list" %}', + key: 'item', + }); }); {% if roles.stock.change %} diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 716e23bdf9..de742fa7ee 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -228,17 +228,6 @@
    - - {% if labels_enabled %} - - {% endif %} {% include "filter_list.html" with id="location" %}
    @@ -299,21 +288,11 @@ var locs = [{{ location.pk }}]; - printStockLocationLabels(locs); - - }); - - $('#multi-location-print-label').click(function() { - - var selections = getTableData('#sublocation-table'); - - var locations = []; - - selections.forEach(function(loc) { - locations.push(loc.pk); + printLabels({ + items: locs, + key: 'location', + url: '{% url "api-stocklocation-label-list" %}', }); - - printStockLocationLabels(locations); }); {% endif %} diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index bb3fe10ce8..c3d870b605 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -491,7 +491,7 @@ class StockTest(StockTestBase): # Check that a tracking item was added track = StockItemTracking.objects.filter(item=ait).latest('id') - self.assertEqual(track.tracking_type, StockHistoryCode.SENT_TO_CUSTOMER) + self.assertEqual(track.tracking_type, StockHistoryCode.SHIPPED_AGAINST_SALES_ORDER) self.assertIn('Allocated some stock', track.notes) def test_return_from_customer(self): diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index b8912b3ca2..37ea1ce6d9 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -1,12 +1,12 @@ """URL lookup for Stock app.""" -from django.urls import include, re_path +from django.urls import include, path, re_path from stock import views location_urls = [ - re_path(r'^(?P\d+)/', include([ + path(r'/', include([ # Anything else - direct to the location detail view re_path('^.*$', views.StockLocationDetail.as_view(), name='stock-location-detail'), ])), diff --git a/InvenTree/templates/InvenTree/notifications/notifications.html b/InvenTree/templates/InvenTree/notifications/notifications.html index 460f747191..db9593cf23 100644 --- a/InvenTree/templates/InvenTree/notifications/notifications.html +++ b/InvenTree/templates/InvenTree/notifications/notifications.html @@ -75,9 +75,7 @@ $('#history-delete').click(function() { multi_delete: true, preFormContent: html, title: '{% trans "Delete Notifications" %}', - onSuccess: function() { - $('#history-table').bootstrapTable('refresh'); - }, + refreshTable: '#history-table', form_data: { filters: { read: true, @@ -88,7 +86,7 @@ $('#history-delete').click(function() { }); $("#history-table").on('click', '.notification-delete', function() { - constructForm(`/api/notifications/${$(this).attr('pk')}/`, { + constructForm(`{% url "api-notifications-list" %}${$(this).attr('pk')}/`, { method: 'DELETE', title: '{% trans "Delete Notification" %}', onSuccess: function(data) { diff --git a/InvenTree/templates/InvenTree/settings/returns.html b/InvenTree/templates/InvenTree/settings/returns.html new file mode 100644 index 0000000000..48092c0e7a --- /dev/null +++ b/InvenTree/templates/InvenTree/settings/returns.html @@ -0,0 +1,20 @@ +{% extends "panel.html" %} +{% load i18n %} + +{% block label %}return-order{% endblock %} + +{% block heading %} +{% trans "Return Order Settings" %} +{% endblock %} + +{% block content %} + + + + {% include "InvenTree/settings/setting.html" with key="RETURNORDER_ENABLED" icon="fa-check-circle" %} + {% include "InvenTree/settings/setting.html" with key="RETURNORDER_REFERENCE_PATTERN" %} + {% include "InvenTree/settings/setting.html" with key="RETURNORDER_EDIT_COMPLETED_ORDERS" icon="fa-edit" %} + +
    + +{% endblock %} diff --git a/InvenTree/templates/InvenTree/settings/settings.html b/InvenTree/templates/InvenTree/settings/settings.html index b427da7c44..128efa932b 100644 --- a/InvenTree/templates/InvenTree/settings/settings.html +++ b/InvenTree/templates/InvenTree/settings/settings.html @@ -42,6 +42,7 @@ {% include "InvenTree/settings/build.html" %} {% include "InvenTree/settings/po.html" %} {% include "InvenTree/settings/so.html" %} +{% include "InvenTree/settings/returns.html" %} {% include "InvenTree/settings/plugin.html" %} {% plugin_list as pl_list %} diff --git a/InvenTree/templates/InvenTree/settings/settings_staff_js.html b/InvenTree/templates/InvenTree/settings/settings_staff_js.html index 3da3bd804f..3e6ccd1f0e 100644 --- a/InvenTree/templates/InvenTree/settings/settings_staff_js.html +++ b/InvenTree/templates/InvenTree/settings/settings_staff_js.html @@ -284,9 +284,7 @@ onPanelLoad('parts', function() { }, method: 'POST', title: '{% trans "Create Part Parameter Template" %}', - onSuccess: function() { - $("#param-table").bootstrapTable('refresh'); - }, + refreshTable: '#param-table', }); }); @@ -303,9 +301,7 @@ onPanelLoad('parts', function() { description: {}, }, title: '{% trans "Edit Part Parameter Template" %}', - onSuccess: function() { - $("#param-table").bootstrapTable('refresh'); - }, + refreshTable: '#param-table', } ); }); @@ -325,9 +321,7 @@ onPanelLoad('parts', function() { method: 'DELETE', preFormContent: html, title: '{% trans "Delete Part Parameter Template" %}', - onSuccess: function() { - $("#param-table").bootstrapTable('refresh'); - }, + refreshTable: '#param-table', } ); }); diff --git a/InvenTree/templates/InvenTree/settings/sidebar.html b/InvenTree/templates/InvenTree/settings/sidebar.html index cbfe275de0..3ba4aa1d47 100644 --- a/InvenTree/templates/InvenTree/settings/sidebar.html +++ b/InvenTree/templates/InvenTree/settings/sidebar.html @@ -52,6 +52,8 @@ {% include "sidebar_item.html" with label='purchase-order' text=text icon="fa-shopping-cart" %} {% trans "Sales Orders" as text %} {% include "sidebar_item.html" with label='sales-order' text=text icon="fa-truck" %} +{% trans "Return Orders" as text %} +{% include "sidebar_item.html" with label='return-order' text=text icon="fa-undo" %} {% trans "Plugin Settings" as text %} {% include "sidebar_header.html" with text=text %} diff --git a/InvenTree/templates/InvenTree/settings/user_search.html b/InvenTree/templates/InvenTree/settings/user_search.html index 2cd18f9641..25a507c682 100644 --- a/InvenTree/templates/InvenTree/settings/user_search.html +++ b/InvenTree/templates/InvenTree/settings/user_search.html @@ -28,6 +28,8 @@ {% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_EXCLUDE_INACTIVE_PURCHASE_ORDERS" user_setting=True icon='fa-eye-slash' %} {% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_SHOW_SALES_ORDERS" user_setting=True icon='fa-truck' %} {% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_EXCLUDE_INACTIVE_SALES_ORDERS" user_setting=True icon='fa-eye-slash' %} + {% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_SHOW_RETURN_ORDERS" user_setting=True icon='fa-truck' %} + {% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_EXCLUDE_INACTIVE_RETURN_ORDERS" user_setting=True icon='fa-eye-slash' %} {% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_RESULTS" user_setting=True icon='fa-search' %} diff --git a/InvenTree/templates/base.html b/InvenTree/templates/base.html index e2dcdaa768..7dcf7ea7ba 100644 --- a/InvenTree/templates/base.html +++ b/InvenTree/templates/base.html @@ -5,6 +5,7 @@ {% plugins_enabled as plugins_enabled %} {% settings_value 'BARCODE_ENABLE' as barcodes %} {% settings_value 'REPORT_ENABLE_TEST_REPORT' as test_report_enabled %} +{% settings_value 'RETURNORDER_ENABLED' as return_order_enabled %} {% settings_value "REPORT_ENABLE" as report_enabled %} {% settings_value "SERVER_RESTART_REQUIRED" as server_restart_required %} {% settings_value "LABEL_ENABLE" as labels_enabled %} @@ -164,8 +165,12 @@ + + + + diff --git a/InvenTree/templates/email/return_order_received.html b/InvenTree/templates/email/return_order_received.html new file mode 100644 index 0000000000..46ff982e90 --- /dev/null +++ b/InvenTree/templates/email/return_order_received.html @@ -0,0 +1,11 @@ +{% extends "email/email.html" %} + +{% load i18n %} +{% load inventree_extras %} + +{% block title %} +{{ message }} +{% if link %} +

    {% trans "Click on the following link to view this order" %}: {{ link }}

    +{% endif %} +{% endblock title %} diff --git a/InvenTree/templates/js/dynamic/calendar.js b/InvenTree/templates/js/dynamic/calendar.js index 0b0ccc2915..4041f9a952 100644 --- a/InvenTree/templates/js/dynamic/calendar.js +++ b/InvenTree/templates/js/dynamic/calendar.js @@ -14,18 +14,24 @@ * Helper functions for calendar display */ +/* + * Extract the first displayed date on the calendar + */ function startDate(calendar) { - // Extract the first displayed date on the calendar return calendar.currentData.dateProfile.activeRange.start.toISOString().split('T')[0]; } +/* + * Extract the last display date on the calendar + */ function endDate(calendar) { - // Extract the last display date on the calendar return calendar.currentData.dateProfile.activeRange.end.toISOString().split('T')[0]; } +/* + * Remove all events from the calendar + */ function clearEvents(calendar) { - // Remove all events from the calendar var events = calendar.getEvents(); diff --git a/InvenTree/templates/js/translated/api.js b/InvenTree/templates/js/translated/api.js index 0bb8b2b2ea..89203eda66 100644 --- a/InvenTree/templates/js/translated/api.js +++ b/InvenTree/templates/js/translated/api.js @@ -40,8 +40,17 @@ function getCookie(name) { return cookieValue; } + +/* + * Perform a GET request to the InvenTree server + */ function inventreeGet(url, filters={}, options={}) { + if (!url) { + console.error('inventreeGet called without url'); + return; + } + // Middleware token required for data update var csrftoken = getCookie('csrftoken'); @@ -78,14 +87,20 @@ function inventreeGet(url, filters={}, options={}) { }); } + +/* Upload via AJAX using the FormData approach. + * + * Note that the following AJAX parameters are required for FormData upload + * + * processData: false + * contentType: false + */ function inventreeFormDataUpload(url, data, options={}) { - /* Upload via AJAX using the FormData approach. - * - * Note that the following AJAX parameters are required for FormData upload - * - * processData: false - * contentType: false - */ + + if (!url) { + console.error('inventreeFormDataUpload called without url'); + return; + } // CSRF cookie token var csrftoken = getCookie('csrftoken'); @@ -116,8 +131,17 @@ function inventreeFormDataUpload(url, data, options={}) { }); } + +/* + * Perform a PUT or PATCH request to the InvenTree server + */ function inventreePut(url, data={}, options={}) { + if (!url) { + console.error('inventreePut called without url'); + return; + } + var method = options.method || 'PUT'; // Middleware token required for data update @@ -164,6 +188,11 @@ function inventreePut(url, data={}, options={}) { */ function inventreeDelete(url, options={}) { + if (!url) { + console.error('inventreeDelete called without url'); + return; + } + options = options || {}; options.method = 'DELETE'; diff --git a/InvenTree/templates/js/translated/attachment.js b/InvenTree/templates/js/translated/attachment.js index 2ef3018123..5435db98dc 100644 --- a/InvenTree/templates/js/translated/attachment.js +++ b/InvenTree/templates/js/translated/attachment.js @@ -1,15 +1,14 @@ {% load i18n %} /* globals - makeIconButton, renderLink, + wrapButtons, */ /* exported attachmentLink, addAttachmentButtonCallbacks, - loadAttachmentTable, - reloadAttachmentTable, + loadAttachmentTable */ @@ -35,7 +34,7 @@ function addAttachmentButtonCallbacks(url, fields={}) { constructForm(url, { fields: file_fields, method: 'POST', - onSuccess: reloadAttachmentTable, + refreshTable: '#attachment-table', title: '{% trans "Add Attachment" %}', }); }); @@ -57,7 +56,7 @@ function addAttachmentButtonCallbacks(url, fields={}) { constructForm(url, { fields: link_fields, method: 'POST', - onSuccess: reloadAttachmentTable, + refreshTable: '#attachment-table', title: '{% trans "Add Link" %}', }); }); @@ -79,9 +78,9 @@ function deleteAttachments(attachments, url, options={}) { var icon = ''; if (attachment.filename) { - icon = ``; + icon = makeIcon(attachmentIcon(attachment.filename), ''); } else if (attachment.link) { - icon = ``; + icon = makeIcon('fa-link', ''); } return ` @@ -123,29 +122,15 @@ function deleteAttachments(attachments, url, options={}) { items: ids, filters: options.filters, }, - onSuccess: function() { - // Refresh the table once all attachments are deleted - $('#attachment-table').bootstrapTable('refresh'); - } + refreshTable: '#attachment-table', }); } -function reloadAttachmentTable() { - - $('#attachment-table').bootstrapTable('refresh'); -} - - /* - * Render a link (with icon) to an internal attachment (file) + * Return a particular icon based on filename extension */ -function attachmentLink(filename) { - - if (!filename) { - return null; - } - +function attachmentIcon(filename) { // Default file icon (if no better choice is found) let icon = 'fa-file-alt'; let fn = filename.toLowerCase(); @@ -171,10 +156,25 @@ function attachmentLink(filename) { }); } - let split = filename.split('/'); - fn = split[split.length - 1]; + return icon; +} - let html = ` ${fn}`; + +/* + * Render a link (with icon) to an internal attachment (file) + */ +function attachmentLink(filename) { + + if (!filename) { + return null; + } + + let split = filename.split('/'); + let fn = split[split.length - 1]; + + let icon = attachmentIcon(filename); + + let html = makeIcon(icon) + ` ${fn}`; return renderLink(html, filename, {download: true}); @@ -271,7 +271,7 @@ function loadAttachmentTable(url, options) { delete opts.fields.link; } }, - onSuccess: reloadAttachmentTable, + refreshTable: '#attachment-table', title: '{% trans "Edit Attachment" %}', }); }); @@ -299,7 +299,7 @@ function loadAttachmentTable(url, options) { if (row.attachment) { return attachmentLink(row.attachment); } else if (row.link) { - var html = ` ${row.link}`; + let html = makeIcon('fa-link') + ` ${row.link}`; return renderLink(html, row.link); } else { return '-'; @@ -327,13 +327,10 @@ function loadAttachmentTable(url, options) { { field: 'actions', formatter: function(value, row) { - var html = ''; - - html = `
    `; + let buttons = ''; if (permissions.change) { - html += makeIconButton( - 'fa-edit icon-blue', + buttons += makeEditButton( 'button-attachment-edit', row.pk, '{% trans "Edit attachment" %}', @@ -341,19 +338,30 @@ function loadAttachmentTable(url, options) { } if (permissions.delete) { - html += makeIconButton( - 'fa-trash-alt icon-red', + buttons += makeDeleteButton( 'button-attachment-delete', row.pk, '{% trans "Delete attachment" %}', ); } - html += `
    `; - - return html; + return wrapButtons(buttons); } } ] }); + + // Enable drag-and-drop functionality + enableDragAndDrop( + '#attachment-dropzone', + url, + { + data: options.filters, + label: 'attachment', + method: 'POST', + success: function() { + reloadBootstrapTable('#attachment-table'); + } + } + ); } diff --git a/InvenTree/templates/js/translated/barcode.js b/InvenTree/templates/js/translated/barcode.js index 8e75e9efb6..c8213e41da 100644 --- a/InvenTree/templates/js/translated/barcode.js +++ b/InvenTree/templates/js/translated/barcode.js @@ -3,7 +3,6 @@ /* globals imageHoverIcon, inventreePut, - makeIconButton, modalEnable, modalSetContent, modalSetTitle, @@ -43,11 +42,11 @@ function makeBarcodeInput(placeholderText='', hintText='') {
    - + ${makeIcon('fa-qrcode')}
    ${hintText}
    @@ -132,7 +131,7 @@ function makeNotesField(options={}) {
    - + ${makeIcon('fa-sticky-note')}
    @@ -149,7 +148,7 @@ function postBarcodeData(barcode_data, options={}) { var modal = options.modal || '#modal-form'; - var url = options.url || '/api/barcode/'; + var url = options.url || '{% url "api-barcode-scan" %}'; var data = options.data || {}; @@ -462,7 +461,7 @@ function unlinkBarcode(data, options={}) { accept_text: '{% trans "Unlink" %}', accept: function() { inventreePut( - '/api/barcode/unlink/', + '{% url "api-barcode-unlink" %}', data, { method: 'POST', @@ -521,7 +520,7 @@ function barcodeCheckInStockItems(location_id, options={}) { ${imageHoverIcon(item.part_detail.thumbnail)} ${item.part_detail.name} ${location_info} ${item.quantity} - ${makeIconButton('fa-times-circle icon-red', 'button-item-remove', item.pk, '{% trans "Remove stock item" %}')} + ${makeRemoveButton('button-item-remove', item.pk, '{% trans "Remove stock item" %}')} `; }); @@ -691,7 +690,7 @@ function barcodeCheckInStockLocations(location_id, options={}) { if ('stocklocation' in response) { var pk = response.stocklocation.pk; - var url = `/api/stock/location/${pk}/`; + var url = `{% url "api-location-list" %}${pk}/`; // Move the scanned location into *this* location inventreePut( @@ -812,7 +811,7 @@ function scanItemsIntoLocation(item_list, options={}) { var pk = response.stocklocation.pk; - inventreeGet(`/api/stock/location/${pk}/`, {}, { + inventreeGet(`{% url "api-location-list" %}${pk}/`, {}, { success: function(response) { stock_location = response; diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js index 9ee63099b4..23e6a028c8 100644 --- a/InvenTree/templates/js/translated/bom.js +++ b/InvenTree/templates/js/translated/bom.js @@ -96,12 +96,12 @@ function constructBomUploadTable(data, options={}) { var optional = constructRowField('optional'); var note = constructRowField('note'); - var buttons = `
    `; + let buttons = ''; - buttons += makeIconButton('fa-info-circle', 'button-row-data', idx, '{% trans "Display row data" %}'); - buttons += makeIconButton('fa-times icon-red', 'button-row-remove', idx, '{% trans "Remove row" %}'); + buttons += makeInfoButton('button-row-data', idx, '{% trans "Display row data" %}'); + buttons += makeRemoveButton('button-row-remove', idx, '{% trans "Remove row" %}'); - buttons += `
    `; + buttons = wrapButtons(buttons); var html = ` @@ -557,7 +557,7 @@ function bomSubstitutesDialog(bom_item_id, substitutes, options={}) { var buttons = ''; - buttons += makeIconButton('fa-times icon-red', 'button-row-remove', pk, '{% trans "Remove substitute part" %}'); + buttons += makeRemoveButton('button-row-remove', pk, '{% trans "Remove substitute part" %}'); // Render a single row var html = ` @@ -626,7 +626,7 @@ function bomSubstitutesDialog(bom_item_id, substitutes, options={}) {
    `; - constructForm(`/api/bom/substitute/${pk}/`, { + constructForm(`{% url "api-bom-substitute-list" %}${pk}/`, { method: 'DELETE', title: '{% trans "Remove Substitute Part" %}', preFormContent: pre, @@ -785,9 +785,7 @@ function loadBomTable(table, options={}) { filters = loadTableFilters('bom'); } - for (var key in params) { - filters[key] = params[key]; - } + Object.assign(filters, params); setupFilterList('bom', $(table)); @@ -1142,7 +1140,7 @@ function loadBomTable(table, options={}) { } if (available_stock <= 0) { - text += ``; + text += makeIconBadge('fa-times-circle icon-red', '{% trans "No Stock Available" %}'); } else { var extra = ''; @@ -1160,7 +1158,10 @@ function loadBomTable(table, options={}) { } if (row.on_order && row.on_order > 0) { - text += ``; + text += makeIconBadge( + 'fa-shopping-cart', + `{% trans "On Order" %}: ${row.on_order}`, + ); } return renderLink(text, url); @@ -1242,11 +1243,10 @@ function loadBomTable(table, options={}) { var bSubs = makeIconButton('fa-exchange-alt icon-blue', 'bom-substitutes-button', row.pk, '{% trans "Edit substitute parts" %}'); - var bEdit = makeIconButton('fa-edit icon-blue', 'bom-edit-button', row.pk, '{% trans "Edit BOM Item" %}'); + var bEdit = makeEditButton('bom-edit-button', row.pk, '{% trans "Edit BOM Item" %}'); - var bDelt = makeIconButton('fa-trash-alt icon-red', 'bom-delete-button', row.pk, '{% trans "Delete BOM Item" %}'); + var bDelt = makeDeleteButton('bom-delete-button', row.pk, '{% trans "Delete BOM Item" %}'); - var html = `
    `; if (!row.validated) { html += bValidate; @@ -1254,13 +1254,13 @@ function loadBomTable(table, options={}) { html += bValid; } - html += bEdit; - html += bSubs; - html += bDelt; + var buttons = ''; + buttons += bEdit; + buttons += bSubs; + buttons += bDelt; - html += `
    `; + return wrapButtons(buttons); - return html; } else { // Return a link to the external BOM @@ -1273,7 +1273,7 @@ function loadBomTable(table, options={}) { footerFormatter: function(data) { return ` `; } @@ -1436,7 +1436,7 @@ function loadBomTable(table, options={}) { var fields = bomItemFields(); - constructForm(`/api/bom/${pk}/`, { + constructForm(`{% url "api-bom-list" %}${pk}/`, { fields: fields, title: '{% trans "Edit BOM Item" %}', focus: 'sub_part', @@ -1508,15 +1508,7 @@ function loadUsedInTable(table, part_id, options={}) { params.part_detail = true; params.sub_part_detail = true; - var filters = {}; - - if (!options.disableFilters) { - filters = loadTableFilters('usedin'); - } - - for (var key in params) { - filters[key] = params[key]; - } + var filters = loadTableFilters('usedin', params); setupFilterList('usedin', $(table), options.filterTarget || '#filter-list-usedin'); diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index 8e91ce3855..9af3d66299 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -90,7 +90,7 @@ function editBuildOrder(pk) { var fields = buildFormFields(); - constructForm(`/api/build/${pk}/`, { + constructForm(`{% url "api-build-list" %}${pk}/`, { fields: fields, reload: true, title: '{% trans "Edit Build Order" %}', @@ -147,7 +147,7 @@ function newBuildOrder(options={}) { */ function duplicateBuildOrder(build_id, options={}) { - inventreeGet(`/api/build/${build_id}/`, {}, { + inventreeGet(`{% url "api-build-list" %}${build_id}/`, {}, { success: function(data) { // Clear out data we do not want to be duplicated delete data['pk']; @@ -166,7 +166,7 @@ function duplicateBuildOrder(build_id, options={}) { function cancelBuildOrder(build_id, options={}) { constructForm( - `/api/build/${build_id}/cancel/`, + `{% url "api-build-list" %}${build_id}/cancel/`, { method: 'POST', title: '{% trans "Cancel Build Order" %}', @@ -208,7 +208,7 @@ function cancelBuildOrder(build_id, options={}) { /* Construct a form to "complete" (finish) a build order */ function completeBuildOrder(build_id, options={}) { - constructForm(`/api/build/${build_id}/finish/`, { + constructForm(`{% url "api-build-list" %}${build_id}/finish/`, { fieldsFunction: function(opts) { var ctx = opts.context || {}; @@ -287,7 +287,7 @@ function createBuildOutput(build_id, options) { // Request build order information from the server inventreeGet( - `/api/build/${build_id}/`, + `{% url "api-build-list" %}${build_id}/`, {}, { success: function(build) { @@ -312,7 +312,7 @@ function createBuildOutput(build_id, options) { }; // Work out the next available serial numbers - inventreeGet(`/api/part/${build.part}/serial-numbers/`, {}, { + inventreeGet(`{% url "api-part-list" %}${build.part}/serial-numbers/`, {}, { success: function(data) { if (data.next) { fields.serial_numbers.placeholder = `{% trans "Next available serial number" %}: ${data.next}`; @@ -341,7 +341,7 @@ function createBuildOutput(build_id, options) { `; } - constructForm(`/api/build/${build_id}/create-output/`, { + constructForm(`{% url "api-build-list" %}${build_id}/create-output/`, { method: 'POST', title: '{% trans "Create Build Output" %}', confirm: true, @@ -364,7 +364,7 @@ function createBuildOutput(build_id, options) { */ function makeBuildOutputButtons(output_id, build_info, options={}) { - var html = `
    `; + var html = ''; // Tracked parts? Must be individually allocated if (options.has_bom_items) { @@ -398,17 +398,13 @@ function makeBuildOutputButtons(output_id, build_info, options={}) { ); // Add a button to "delete" this build output - html += makeIconButton( - 'fa-trash-alt icon-red', + html += makeDeleteButton( 'button-output-delete', output_id, '{% trans "Delete build output" %}', ); - html += `
    `; - - return html; - + return wrapButtons(html); } @@ -421,7 +417,7 @@ function makeBuildOutputButtons(output_id, build_info, options={}) { */ function unallocateStock(build_id, options={}) { - var url = `/api/build/${build_id}/unallocate/`; + var url = `{% url "api-build-list" %}${build_id}/unallocate/`; var html = `
    @@ -486,7 +482,7 @@ function completeBuildOutputs(build_id, outputs, options={}) { var buttons = `
    `; - buttons += makeIconButton('fa-times icon-red', 'button-row-remove', pk, '{% trans "Remove row" %}'); + buttons += makeRemoveButton('button-row-remove', pk, '{% trans "Remove row" %}'); buttons += '
    '; @@ -529,7 +525,7 @@ function completeBuildOutputs(build_id, outputs, options={}) { `; - constructForm(`/api/build/${build_id}/complete/`, { + constructForm(`{% url "api-build-list" %}${build_id}/complete/`, { method: 'POST', preFormContent: html, fields: { @@ -647,7 +643,7 @@ function deleteBuildOutputs(build_id, outputs, options={}) { var buttons = `
    `; - buttons += makeIconButton('fa-times icon-red', 'button-row-remove', pk, '{% trans "Remove row" %}'); + buttons += makeRemoveButton('button-row-remove', pk, '{% trans "Remove row" %}'); buttons += '
    '; @@ -690,7 +686,7 @@ function deleteBuildOutputs(build_id, outputs, options={}) { `; - constructForm(`/api/build/${build_id}/delete-outputs/`, { + constructForm(`{% url "api-build-list" %}${build_id}/delete-outputs/`, { method: 'POST', preFormContent: html, fields: {}, @@ -768,11 +764,7 @@ function loadBuildOrderAllocationTable(table, options={}) { options.params['location_detail'] = true; options.params['stock_detail'] = true; - var filters = loadTableFilters('buildorderallocation'); - - for (var key in options.params) { - filters[key] = options.params[key]; - } + var filters = loadTableFilters('buildorderallocation', options.params); setupFilterList('buildorderallocation', $(table)); @@ -893,7 +885,12 @@ function loadBuildOutputTable(build_info, options={}) { filters[key] = params[key]; } - setupFilterList('builditems', $(table), options.filterTarget || '#filter-list-incompletebuilditems'); + setupFilterList('builditems', $(table), options.filterTarget || '#filter-list-incompletebuilditems', { + labels: { + url: '{% url "api-stockitem-label-list" %}', + key: 'item', + } + }); function setupBuildOutputButtonCallbacks() { @@ -1407,19 +1404,6 @@ function loadBuildOutputTable(build_info, options={}) { ); }); - // Print stock item labels - $('#incomplete-output-print-label').click(function() { - var outputs = getTableData(table); - - var stock_id_values = []; - - outputs.forEach(function(output) { - stock_id_values.push(output.pk); - }); - - printStockItemLabels(stock_id_values); - }); - $('#outputs-expand').click(function() { $(table).bootstrapTable('expandAllRows'); }); @@ -1482,13 +1466,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { } // Filters - var filters = loadTableFilters('builditems'); - - var params = options.params || {}; - - for (var key in params) { - filters[key] = params[key]; - } + let filters = loadTableFilters('builditems', options.params); setupFilterList('builditems', $(table), options.filterTarget); @@ -1703,6 +1681,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { name: 'build-allocation', uniqueId: 'sub_part', search: options.search || false, + queryParams: filters, + original: options.params, onPostBody: function(data) { // Setup button callbacks setupCallbacks(); @@ -1796,15 +1776,13 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { var pk = row.pk; - var html = `
    `; + var html = ''; - html += makeIconButton('fa-edit icon-blue', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}'); + html += makeEditButton('button-allocation-edit', pk, '{% trans "Edit stock allocation" %}'); - html += makeIconButton('fa-trash-alt icon-red', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}'); + html += makeDeleteButton('button-allocation-delete', pk, '{% trans "Delete stock allocation" %}'); - html += `
    `; - - return html; + return wrapButtons(html); } } ] @@ -1814,7 +1792,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { subTable.find('.button-allocation-edit').click(function() { var pk = $(this).attr('pk'); - constructForm(`/api/build/item/${pk}/`, { + constructForm(`{% url "api-build-item-list" %}${pk}/`, { fields: { quantity: {}, }, @@ -1826,7 +1804,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { subTable.find('.button-allocation-delete').click(function() { var pk = $(this).attr('pk'); - constructForm(`/api/build/item/${pk}/`, { + constructForm(`{% url "api-build-item-list" %}${pk}/`, { method: 'DELETE', title: '{% trans "Remove Allocation" %}', onSuccess: reloadAllocationData, @@ -1935,9 +1913,9 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { icons += ``; } else { if (available_stock < (required - allocated)) { - icons += ``; + icons += makeIconBadge('fa-times-circle icon-red', '{% trans "Insufficient stock available" %}'); } else { - icons += ``; + icons += makeIconBadge('fa-check-circle icon-green', '{% trans "Sufficient stock available" %}'); } if (available_stock <= 0) { @@ -1953,13 +1931,15 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { } if (extra) { - icons += ``; + icons += makeInfoButton('fa-info-circle icon-blue', extra); } } } if (row.on_order && row.on_order > 0) { - icons += ``; + makeIconBadge('fa-shopping-cart', '{% trans "On Order" %}', { + content: row.on_order, + }); } return renderLink(text, url) + icons; @@ -2027,7 +2007,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { } // Generate action buttons for this build output - var html = `
    `; + let html = ''; if (allocatedQuantity(row) < requiredQuantity(row)) { if (row.sub_part_detail.assembly) { @@ -2041,17 +2021,16 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { html += makeIconButton('fa-sign-in-alt icon-green', 'button-add', row.sub_part, '{% trans "Allocate stock" %}'); } - html += makeIconButton( - 'fa-minus-circle icon-red', 'button-unallocate', row.sub_part, + html += makeRemoveButton( + 'button-unallocate', + row.sub_part, '{% trans "Unallocate stock" %}', { disabled: allocatedQuantity(row) == 0, } ); - html += '
    '; - - return html; + return wrapButtons(html); } }, ] @@ -2093,7 +2072,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { if (output_id) { // Request information on the particular build output (stock item) - inventreeGet(`/api/stock/${output_id}/`, {}, { + inventreeGet(`{% url "api-stock-list" %}${output_id}/`, {}, { success: function(output) { if (output.quantity == 1 && output.serial != null) { auto_fill_filters.serial = output.serial; @@ -2112,8 +2091,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { var delete_button = `
    `; - delete_button += makeIconButton( - 'fa-times icon-red', + delete_button += makeRemoveButton( 'button-row-remove', pk, '{% trans "Remove row" %}', @@ -2245,7 +2223,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { `; - constructForm(`/api/build/${build_id}/allocate/`, { + constructForm(`{% url "api-build-list" %}${build_id}/allocate/`, { method: 'POST', fields: {}, preFormContent: html, @@ -2459,7 +2437,7 @@ function autoAllocateStockToBuild(build_id, bom_items=[], options={}) { }, }; - constructForm(`/api/build/${build_id}/auto-allocate/`, { + constructForm(`{% url "api-build-list" %}${build_id}/auto-allocate/`, { method: 'POST', fields: fields, title: '{% trans "Allocate Stock Items" %}', @@ -2484,21 +2462,19 @@ function loadBuildTable(table, options) { var params = options.params || {}; - var filters = {}; - params['part_detail'] = true; - if (!options.disableFilters) { - filters = loadTableFilters('build'); - } - - for (var key in params) { - filters[key] = params[key]; - } + var filters = loadTableFilters('build', params); var filterTarget = options.filterTarget || null; - setupFilterList('build', table, filterTarget, {download: true}); + setupFilterList('build', table, filterTarget, { + download: true, + report: { + url: '{% url "api-build-report-list" %}', + key: 'build', + } + }); // Which display mode to use for the build table? var display_mode = inventreeLoad('build-table-display-mode', 'list'); diff --git a/InvenTree/templates/js/translated/company.js b/InvenTree/templates/js/translated/company.js index 7d50a5d6c6..f5393909d2 100644 --- a/InvenTree/templates/js/translated/company.js +++ b/InvenTree/templates/js/translated/company.js @@ -4,23 +4,26 @@ constructForm, imageHoverIcon, loadTableFilters, - makeIconButton, renderLink, setupFilterList, */ /* exported createCompany, + createContact, createManufacturerPart, createSupplierPart, createSupplierPartPriceBreak, + deleteContacts, deleteManufacturerParts, deleteManufacturerPartParameters, deleteSupplierParts, duplicateSupplierPart, editCompany, + editContact, editSupplierPartPriceBreak, loadCompanyTable, + loadContactTable, loadManufacturerPartTable, loadManufacturerPartParameterTable, loadSupplierPartTable, @@ -197,7 +200,7 @@ function createSupplierPart(options={}) { var header = ''; if (options.part) { var part_model = {}; - inventreeGet(`/api/part/${options.part}/.*`, {}, { + inventreeGet(`{% url "api-part-list" %}${options.part}/.*`, {}, { async: false, success: function(response) { part_model = response; @@ -226,7 +229,7 @@ function duplicateSupplierPart(part, options={}) { var fields = options.fields || supplierPartFields(); // Retrieve information for the supplied part - inventreeGet(`/api/company/part/${part}/`, {}, { + inventreeGet(`{% url "api-supplier-part-list" %}${part}/`, {}, { success: function(data) { // Remove fields which we do not want to duplicate @@ -234,7 +237,7 @@ function duplicateSupplierPart(part, options={}) { delete data['available']; delete data['availability_updated']; - constructForm(`/api/company/part/`, { + constructForm('{% url "api-supplier-part-list" %}', { method: 'POST', fields: fields, title: '{% trans "Duplicate Supplier Part" %}', @@ -260,7 +263,7 @@ function editSupplierPart(part, options={}) { fields.part.hidden = true; } - constructForm(`/api/company/part/${part}/`, { + constructForm(`{% url "api-supplier-part-list" %}${part}/`, { fields: fields, title: options.title || '{% trans "Edit Supplier Part" %}', onSuccess: options.onSuccess @@ -443,24 +446,18 @@ function createCompany(options={}) { } +/* + * Load company listing data into specified table. + * + * Args: + * - table: Table element on the page + * - url: Base URL for the API query + * - options: table options. + */ function loadCompanyTable(table, url, options={}) { - /* - * Load company listing data into specified table. - * - * Args: - * - table: Table element on the page - * - url: Base URL for the API query - * - options: table options. - */ - // Query parameters - var params = options.params || {}; - - var filters = loadTableFilters('company'); - - for (var key in params) { - filters[key] = params[key]; - } + let params = options.params || {}; + let filters = loadTableFilters('company', params); setupFilterList('company', $(table)); @@ -547,6 +544,230 @@ function loadCompanyTable(table, url, options={}) { } +/* + * Construct a set of form fields for the Contact model + */ +function contactFields(options={}) { + + let fields = { + company: { + icon: 'fa-building', + }, + name: { + icon: 'fa-user', + }, + phone: { + icon: 'fa-phone' + }, + email: { + icon: 'fa-at', + }, + role: { + icon: 'fa-user-tag', + }, + }; + + if (options.company) { + fields.company.value = options.company; + } + + return fields; +} + + +/* + * Launches a form to create a new Contact + */ +function createContact(options={}) { + let fields = options.fields || contactFields(options); + + constructForm('{% url "api-contact-list" %}', { + method: 'POST', + fields: fields, + title: '{% trans "Create New Contact" %}', + onSuccess: function(response) { + handleFormSuccess(response, options); + } + }); +} + + +/* + * Launches a form to edit an existing Contact + */ +function editContact(pk, options={}) { + let fields = options.fields || contactFields(options); + + constructForm(`{% url "api-contact-list" %}${pk}/`, { + fields: fields, + title: '{% trans "Edit Contact" %}', + onSuccess: function(response) { + handleFormSuccess(response, options); + } + }); +} + + +/* + * Launches a form to delete one (or more) contacts + */ +function deleteContacts(contacts, options={}) { + + if (contacts.length == 0) { + return; + } + + function renderContact(contact) { + return ` + + ${contact.name} + ${contact.email} + ${contact.role} + `; + } + + let rows = ''; + let ids = []; + + contacts.forEach(function(contact) { + rows += renderContact(contact); + ids.push(contact.pk); + }); + + let html = ` +
    + {% trans "All selected contacts will be deleted" %} +
    + + + + + + + ${rows} +
    {% trans "Name" %}{% trans "Email" %}{% trans "Role" %}
    `; + + constructForm('{% url "api-contact-list" %}', { + method: 'DELETE', + multi_delete: true, + title: '{% trans "Delete Contacts" %}', + preFormContent: html, + form_data: { + items: ids, + }, + onSuccess: function(response) { + handleFormSuccess(response, options); + } + }); +} + + +/* + * Load table listing company contacts + */ +function loadContactTable(table, options={}) { + + var params = options.params || {}; + + var filters = loadTableFilters('contact', params); + + setupFilterList('contact', $(table), '#filter-list-contacts'); + + $(table).inventreeTable({ + url: '{% url "api-contact-list" %}', + queryParams: filters, + original: params, + idField: 'pk', + uniqueId: 'pk', + sidePagination: 'server', + formatNoMatches: function() { + return '{% trans "No contacts found" %}'; + }, + showColumns: true, + name: 'contacts', + columns: [ + { + field: 'name', + title: '{% trans "Name" %}', + sortable: true, + switchable: false, + }, + { + field: 'phone', + title: '{% trans "Phone Number" %}', + sortable: false, + switchable: true, + }, + { + field: 'email', + title: '{% trans "Email Address" %}', + sortable: false, + switchable: true, + }, + { + field: 'role', + title: '{% trans "Role" %}', + sortable: false, + switchable: false, + }, + { + field: 'actions', + title: '', + sortable: false, + switchable: false, + visible: options.allow_edit || options.allow_delete, + formatter: function(value, row) { + var pk = row.pk; + + let html = ''; + + if (options.allow_edit) { + html += makeEditButton('btn-contact-edit', pk, '{% trans "Edit Contact" %}'); + } + + if (options.allow_delete) { + html += makeDeleteButton('btn-contact-delete', pk, '{% trans "Delete Contact" %}'); + } + + return wrapButtons(html); + } + } + ], + onPostBody: function() { + // Edit button callback + if (options.allow_edit) { + $(table).find('.btn-contact-edit').click(function() { + var pk = $(this).attr('pk'); + editContact(pk, { + onSuccess: function() { + $(table).bootstrapTable('refresh'); + } + }); + }); + } + + // Delete button callback + if (options.allow_delete) { + $(table).find('.btn-contact-delete').click(function() { + var pk = $(this).attr('pk'); + + var row = $(table).bootstrapTable('getRowByUniqueId', pk); + + if (row && row.pk) { + + deleteContacts([row], { + onSuccess: function() { + $(table).bootstrapTable('refresh'); + } + }); + } + }); + } + } + }); +} + + /* Delete one or more ManufacturerPart objects from the database. * - User will be provided with a modal form, showing all the parts to be deleted. * - Delete operations are performed sequentialy, not simultaneously @@ -653,21 +874,16 @@ function deleteManufacturerPartParameters(selections, options={}) { } +/* + * Load manufacturer part table + */ function loadManufacturerPartTable(table, url, options) { - /* - * Load manufacturer part table - * - */ // Query parameters var params = options.params || {}; // Load filters - var filters = loadTableFilters('manufacturer-part'); - - for (var key in params) { - filters[key] = params[key]; - } + var filters = loadTableFilters('manufacturer-part', params); var filterTarget = options.filterTarget || '#filter-list-manufacturer-part'; @@ -703,11 +919,11 @@ function loadManufacturerPartTable(table, url, options) { var html = imageHoverIcon(row.part_detail.thumbnail) + renderLink(value, url); if (row.part_detail.is_template) { - html += ``; + html += makeIconBadge('fa-clone', '{% trans "Template part" %}'); } if (row.part_detail.assembly) { - html += ``; + html += makeIconBadge('fa-tools', '{% trans "Assembled part" %}'); } if (!row.part_detail.active) { @@ -764,16 +980,13 @@ function loadManufacturerPartTable(table, url, options) { sortable: false, switchable: false, formatter: function(value, row) { - var pk = row.pk; + let pk = row.pk; + let html = ''; - var html = `
    `; + html += makeEditButton('button-manufacturer-part-edit', pk, '{% trans "Edit manufacturer part" %}'); + html += makeDeleteButton('button-manufacturer-part-delete', pk, '{% trans "Delete manufacturer part" %}'); - html += makeIconButton('fa-edit icon-blue', 'button-manufacturer-part-edit', pk, '{% trans "Edit manufacturer part" %}'); - html += makeIconButton('fa-trash-alt icon-red', 'button-manufacturer-part-delete', pk, '{% trans "Delete manufacturer part" %}'); - - html += '
    '; - - return html; + return wrapButtons(html); } } ], @@ -810,20 +1023,15 @@ function loadManufacturerPartTable(table, url, options) { } +/* + * Load table of ManufacturerPartParameter objects + */ function loadManufacturerPartParameterTable(table, url, options) { - /* - * Load table of ManufacturerPartParameter objects - */ var params = options.params || {}; // Load filters - var filters = loadTableFilters('manufacturer-part-parameters'); - - // Overwrite explicit parameters - for (var key in params) { - filters[key] = params[key]; - } + var filters = loadTableFilters('manufacturer-part-parameters', params); setupFilterList('manufacturer-part-parameters', $(table)); @@ -867,17 +1075,13 @@ function loadManufacturerPartParameterTable(table, url, options) { switchable: false, sortable: false, formatter: function(value, row) { + let pk = row.pk; + let html = ''; - var pk = row.pk; + html += makeEditButton('button-parameter-edit', pk, '{% trans "Edit parameter" %}'); + html += makeDeleteButton('button-parameter-delete', pk, '{% trans "Delete parameter" %}'); - var html = `
    `; - - html += makeIconButton('fa-edit icon-blue', 'button-parameter-edit', pk, '{% trans "Edit parameter" %}'); - html += makeIconButton('fa-trash-alt icon-red', 'button-parameter-delete', pk, '{% trans "Delete parameter" %}'); - - html += `
    `; - - return html; + return wrapButtons(html); } } ], @@ -886,27 +1090,23 @@ function loadManufacturerPartParameterTable(table, url, options) { $(table).find('.button-parameter-edit').click(function() { var pk = $(this).attr('pk'); - constructForm(`/api/company/part/manufacturer/parameter/${pk}/`, { + constructForm(`{% url "api-manufacturer-part-parameter-list" %}${pk}/`, { fields: { name: {}, value: {}, units: {}, }, title: '{% trans "Edit Parameter" %}', - onSuccess: function() { - $(table).bootstrapTable('refresh'); - } + refreshTable: table, }); }); $(table).find('.button-parameter-delete').click(function() { var pk = $(this).attr('pk'); - constructForm(`/api/company/part/manufacturer/parameter/${pk}/`, { + constructForm(`{% url "api-manufacturer-part-parameter-list" %}${pk}/`, { method: 'DELETE', title: '{% trans "Delete Parameter" %}', - onSuccess: function() { - $(table).bootstrapTable('refresh'); - } + refreshTable: table, }); }); } @@ -914,21 +1114,16 @@ function loadManufacturerPartParameterTable(table, url, options) { } +/* + * Load supplier part table + */ function loadSupplierPartTable(table, url, options) { - /* - * Load supplier part table - * - */ // Query parameters var params = options.params || {}; // Load filters - var filters = loadTableFilters('supplier-part'); - - for (var key in params) { - filters[key] = params[key]; - } + var filters = loadTableFilters('supplier-part', params); setupFilterList('supplier-part', $(table)); @@ -964,11 +1159,11 @@ function loadSupplierPartTable(table, url, options) { var html = imageHoverIcon(row.part_detail.thumbnail) + renderLink(value, url); if (row.part_detail.is_template) { - html += ``; + html += makeIconBadge('fa-clone', '{% trans "Template part" %}'); } if (row.part_detail.assembly) { - html += ``; + html += makeIconBadge('fa-tools', '{% trans "Assembled part" %}'); } if (!row.part_detail.active) { @@ -1088,9 +1283,13 @@ function loadSupplierPartTable(table, url, options) { sortable: true, formatter: function(value, row) { if (row.availability_updated) { - var html = formatDecimal(value); - var date = renderDate(row.availability_updated, {showTime: true}); - html += ``; + let html = formatDecimal(value); + let date = renderDate(row.availability_updated, {showTime: true}); + + html += makeIconBadge( + 'fa-info-circle', + `{% trans "Last Updated" %}: ${date}` + ); return html; } else { return '-'; @@ -1108,16 +1307,13 @@ function loadSupplierPartTable(table, url, options) { sortable: false, switchable: false, formatter: function(value, row) { - var pk = row.pk; + let pk = row.pk; + let html = ''; - var html = `
    `; + html += makeEditButton('button-supplier-part-edit', pk, '{% trans "Edit supplier part" %}'); + html += makeDeleteButton('button-supplier-part-delete', pk, '{% trans "Delete supplier part" %}'); - html += makeIconButton('fa-edit icon-blue', 'button-supplier-part-edit', pk, '{% trans "Edit supplier part" %}'); - html += makeIconButton('fa-trash-alt icon-red', 'button-supplier-part-delete', pk, '{% trans "Delete supplier part" %}'); - - html += '
    '; - - return html; + return wrapButtons(html); } } ], @@ -1166,24 +1362,20 @@ function loadSupplierPriceBreakTable(options={}) { table.find('.button-price-break-delete').click(function() { var pk = $(this).attr('pk'); - constructForm(`/api/company/price-break/${pk}/`, { + constructForm(`{% url "api-part-supplier-price-list" %}${pk}/`, { method: 'DELETE', title: '{% trans "Delete Price Break" %}', - onSuccess: function() { - table.bootstrapTable('refresh'); - }, + refreshTable: table, }); }); table.find('.button-price-break-edit').click(function() { var pk = $(this).attr('pk'); - constructForm(`/api/company/price-break/${pk}/`, { + constructForm(`{% url "api-part-supplier-price-list" %}${pk}/`, { fields: supplierPartPriceBreakFields(), title: '{% trans "Edit Price Break" %}', - onSuccess: function() { - table.bootstrapTable('refresh'); - } + refreshTable: table, }); }); } @@ -1231,10 +1423,12 @@ function loadSupplierPriceBreakTable(options={}) { formatter: function(value, row) { var html = renderDate(value); - html += `
    `; - html += makeIconButton('fa-edit icon-blue', 'button-price-break-edit', row.pk, '{% trans "Edit price break" %}'); - html += makeIconButton('fa-trash-alt icon-red', 'button-price-break-delete', row.pk, '{% trans "Delete price break" %}'); - html += `
    `; + let buttons = ''; + + buttons += makeEditButton('button-price-break-edit', row.pk, '{% trans "Edit price break" %}'); + buttons += makeDeleteButton('button-price-break-delete', row.pk, '{% trans "Delete price break" %}'); + + html += wrapButtons(buttons); return html; } diff --git a/InvenTree/templates/js/translated/filters.js b/InvenTree/templates/js/translated/filters.js index 5038391ffe..31a121fcb9 100644 --- a/InvenTree/templates/js/translated/filters.js +++ b/InvenTree/templates/js/translated/filters.js @@ -43,7 +43,7 @@ function defaultFilters() { * @param tableKey - String key for the particular table * @param defaults - Default filters for this table e.g. 'cascade=1&location=5' */ -function loadTableFilters(tableKey) { +function loadTableFilters(tableKey, query={}) { var lookup = 'table-filters-' + tableKey.toLowerCase(); @@ -67,6 +67,9 @@ function loadTableFilters(tableKey) { } }); + // Override configurable filters with hard-coded query + Object.assign(filters, query); + return filters; } @@ -258,6 +261,18 @@ function generateFilterInput(tableKey, filterKey) { } +/* + * Helper function to make a 'filter' style button + */ +function makeFilterButton(options={}) { + + return ` + `; +} + + /** * Configure a filter list for a given table * @@ -290,21 +305,58 @@ function setupFilterList(tableKey, table, target, options={}) { // One blank slate, please element.empty(); + // Construct a set of buttons var buttons = ''; + // Add 'print reports' button + if (options.report && global_settings.REPORT_ENABLE) { + buttons += makeFilterButton({ + id: `print-report-${tableKey}`, + title: options.report.title || '{% trans "Print reports for selected items" %}', + icon: 'fa-print', + }); + } + + // Add 'print labels' button + if (options.labels && global_settings.LABEL_ENABLE) { + buttons += makeFilterButton({ + id: `print-labels-${tableKey}`, + title: options.labels.title || '{% trans "Print labels for selected items" %}', + icon: 'fa-tag', + }); + } + // Add download button if (options.download) { - buttons += ``; + buttons += makeFilterButton({ + id: `download-${tableKey}`, + title: '{% trans "Download table data" %}', + icon: 'fa-download', + }); } - buttons += ``; + buttons += makeFilterButton({ + id: `reload-${tableKey}`, + title: '{% trans "Reload table data" %}', + icon: 'fa-redo-alt', + }); // If there are filters defined for this table, add more buttons if (!jQuery.isEmptyObject(getAvailableTableFilters(tableKey))) { - buttons += ``; + + buttons += makeFilterButton({ + id: add, + title: '{% trans "Add new filter" %}', + icon: 'fa-filter', + }); + if (Object.keys(filters).length > 0) { - buttons += ``; + buttons += makeFilterButton({ + id: clear, + title: '{% trans "Clear all filters" %}', + icon: 'fa-backspace icon-red', + }); } } @@ -331,6 +383,42 @@ function setupFilterList(tableKey, table, target, options={}) { element.append(filter_tag); } + // Callback for printing reports + if (options.report && global_settings.REPORT_ENABLE) { + element.find(`#print-report-${tableKey}`).click(function() { + let data = getTableData(table); + let items = []; + + data.forEach(function(row) { + items.push(row.pk); + }); + + printReports({ + items: items, + url: options.report.url, + key: options.report.key + }); + }); + } + + // Callback for printing labels + if (options.labels && global_settings.LABEL_ENABLE) { + element.find(`#print-labels-${tableKey}`).click(function() { + let data = getTableData(table); + let items = []; + + data.forEach(function(row) { + items.push(row.pk); + }); + + printLabels({ + items: items, + url: options.labels.url, + key: options.labels.key, + }); + }); + } + // Callback for reloading the table element.find(`#reload-${tableKey}`).click(function() { reloadTableFilters(table); diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index a445d66b08..d656ccc77b 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -308,7 +308,7 @@ function constructDeleteForm(fields, options) { * - confirmText: Text for confirm button (default = "Confirm") * */ -function constructForm(url, options) { +function constructForm(url, options={}) { // An "empty" form will be defined locally if (url == null) { @@ -1169,6 +1169,11 @@ function handleFormSuccess(response, options) { $(options.modal).modal('hide'); } + // Refresh a table + if (options.refreshTable) { + reloadBootstrapTable(options.refreshTable); + } + if (options.onSuccess) { // Callback function options.onSuccess(response, options); diff --git a/InvenTree/templates/js/translated/helpers.js b/InvenTree/templates/js/translated/helpers.js index e245c5b204..e400e33707 100644 --- a/InvenTree/templates/js/translated/helpers.js +++ b/InvenTree/templates/js/translated/helpers.js @@ -6,9 +6,14 @@ editButton, formatDecimal, imageHoverIcon, + makeCopyButton, + makeDeleteButton, + makeEditButton, makeIconBadge, makeIconButton, + makeInfoButton, makeProgressBar, + makeRemoveButton, renderLink, sanitizeInputString, select2Thumbnail, @@ -17,8 +22,14 @@ thumbnailImage yesNoLabel, withTitle, + wrapButtons, */ +/* exported + makeIcon, +*/ + + function yesNoLabel(value, options={}) { var text = ''; var color = ''; @@ -147,17 +158,47 @@ function select2Thumbnail(image) { } +/* + * Construct a simple FontAwesome icon span + */ +function makeIcon(icon, title='', options={}) { + + let classes = options.classes || 'fas'; + + return ``; +} + + /* * Construct an 'icon badge' which floats to the right of an object */ -function makeIconBadge(icon, title) { +function makeIconBadge(icon, title='', options={}) { - var html = ``; + let content = options.content || ''; + + let html = ` + + ${content} + `; return html; } +/* + * Wrap list of buttons in a button group
    + */ +function wrapButtons(buttons) { + + if (!buttons) { + // Return empty element if no buttons are provided + return ''; + } + + return `
    ${buttons}
    `; +} + + /* * Construct an 'icon button' using the fontawesome set */ @@ -187,6 +228,46 @@ function makeIconButton(icon, cls, pk, title, options={}) { } +/* + * Helper function for making a common 'info' button + */ +function makeInfoButton(cls, pk, title, options={}) { + return makeIconButton('fa-info-circle', cls, pk, title, options); +} + + +/* + * Helper function for making a common 'edit' button + */ +function makeEditButton(cls, pk, title, options={}) { + return makeIconButton('fa-edit icon-blue', cls, pk, title, options); +} + + +/* + * Helper function for making a common 'copy' button + */ +function makeCopyButton(cls, pk, title, options={}) { + return makeIconButton('fa-clone', cls, pk, title, options); +} + + +/* + * Helper function for making a common 'delete' button + */ +function makeDeleteButton(cls, pk, title, options={}) { + return makeIconButton('fa-trash-alt icon-red', cls, pk, title, options); +} + + +/* + * Helper function for making a common 'remove' button + */ +function makeRemoveButton(cls, pk, title, options={}) { + return makeIconButton('fa-times-circle icon-red', cls, pk, title, options); +} + + /* * Render a progessbar! * diff --git a/InvenTree/templates/js/translated/label.js b/InvenTree/templates/js/translated/label.js index 4373f0d23b..f714e0f861 100644 --- a/InvenTree/templates/js/translated/label.js +++ b/InvenTree/templates/js/translated/label.js @@ -16,218 +16,16 @@ /* exported printLabels, - printPartLabels, - printStockItemLabels, - printStockLocationLabels, */ - -/* - * Perform the "print" action. +/** + * Present the user with the available labels, + * and allow them to select which label to print. + * + * The intent is that the available labels have been requested + * (via AJAX) from the server. */ -function printLabels(url, plugin=null) { - - if (plugin) { - // If a plugin is provided, do not redirect the browser. - // Instead, perform an API request and display a message - - url = url + `plugin=${plugin}`; - - inventreeGet(url, {}, { - success: function(response) { - showMessage( - '{% trans "Labels sent to printer" %}', - { - style: 'success', - } - ); - } - }); - } else { - window.open(url); - } - -} - - -function printStockItemLabels(items) { - /** - * Print stock item labels for the given stock items - */ - - if (items.length == 0) { - showAlertDialog( - '{% trans "Select Stock Items" %}', - '{% trans "Stock item(s) must be selected before printing labels" %}' - ); - - return; - } - - // Request available labels from the server - inventreeGet( - '{% url "api-stockitem-label-list" %}', - { - enabled: true, - items: items, - }, - { - success: function(response) { - - if (response.length == 0) { - showAlertDialog( - '{% trans "No Labels Found" %}', - '{% trans "No labels found which match selected stock item(s)" %}', - ); - - return; - } - - // Select label to print - selectLabel( - response, - items, - { - success: function(data) { - - var pk = data.label; - - var href = `/api/label/stock/${pk}/print/?`; - - items.forEach(function(item) { - href += `items[]=${item}&`; - }); - - printLabels(href, data.plugin); - } - } - ); - } - } - ); -} - - -function printStockLocationLabels(locations) { - - if (locations.length == 0) { - showAlertDialog( - '{% trans "Select Stock Locations" %}', - '{% trans "Stock location(s) must be selected before printing labels" %}' - ); - - return; - } - - // Request available labels from the server - inventreeGet( - '{% url "api-stocklocation-label-list" %}', - { - enabled: true, - locations: locations, - }, - { - success: function(response) { - if (response.length == 0) { - showAlertDialog( - '{% trans "No Labels Found" %}', - '{% trans "No labels found which match selected stock location(s)" %}', - ); - - return; - } - - // Select label to print - selectLabel( - response, - locations, - { - success: function(data) { - - var pk = data.label; - - var href = `/api/label/location/${pk}/print/?`; - - locations.forEach(function(location) { - href += `locations[]=${location}&`; - }); - - printLabels(href, data.plugin); - } - } - ); - } - } - ); -} - - -function printPartLabels(parts) { - /** - * Print labels for the provided parts - */ - - if (parts.length == 0) { - showAlertDialog( - '{% trans "Select Parts" %}', - '{% trans "Part(s) must be selected before printing labels" %}', - ); - - return; - } - - // Request available labels from the server - inventreeGet( - '{% url "api-part-label-list" %}', - { - enabled: true, - parts: parts, - }, - { - success: function(response) { - - if (response.length == 0) { - showAlertDialog( - '{% trans "No Labels Found" %}', - '{% trans "No labels found which match the selected part(s)" %}', - ); - - return; - } - - // Select label to print - selectLabel( - response, - parts, - { - success: function(data) { - - var pk = data.label; - - var href = `/api/label/part/${pk}/print/?`; - - parts.forEach(function(part) { - href += `parts[]=${part}&`; - }); - - printLabels(href, data.plugin); - } - } - ); - } - } - ); -} - - function selectLabel(labels, items, options={}) { - /** - * Present the user with the available labels, - * and allow them to select which label to print. - * - * The intent is that the available labels have been requested - * (via AJAX) from the server. - */ // Array of available plugins for label printing var plugins = []; @@ -347,3 +145,71 @@ function selectLabel(labels, items, options={}) { } }); } + + +/* + * Print label(s) for the selected items: + * + * - Retrieve a list of matching label templates from the server + * - Present the available templates to the user (if more than one available) + * - Request printed labels + * + * Required options: + * - url: The list URL for the particular template type + * - items: The list of items to be printed + * - key: The key to use in the query parameters + */ +function printLabels(options) { + + if (!options.items || options.items.length == 0) { + showAlertDialog( + '{% trans "Select Items" %}', + '{% trans "No items selected for printing" %}', + ); + return; + } + + let params = { + enabled: true, + }; + + params[options.key] = options.items; + + // Request a list of available label templates + inventreeGet(options.url, params, { + success: function(response) { + if (response.length == 0) { + showAlertDialog( + '{% trans "No Labels Found" %}', + '{% trans "No label templates found which match the selected items" %}', + ); + return; + } + + // Select label template for printing + selectLabel(response, options.items, { + success: function(data) { + let href = `${options.url}${data.label}/print/?`; + + options.items.forEach(function(item) { + href += `${options.key}=${item}&`; + }); + + if (data.plugin) { + href += `plugin=${data.plugin}`; + + inventreeGet(href, {}, { + success: function(response) { + showMessage('{% trans "Labels sent to printer" %}', { + style: 'success', + }); + } + }); + } else { + window.open(href); + } + } + }); + } + }); +} diff --git a/InvenTree/templates/js/translated/model_renderers.js b/InvenTree/templates/js/translated/model_renderers.js index 033986e7d2..4edb13d165 100644 --- a/InvenTree/templates/js/translated/model_renderers.js +++ b/InvenTree/templates/js/translated/model_renderers.js @@ -10,11 +10,13 @@ getModelRenderer, renderBuild, renderCompany, + renderContact, renderGroup, renderManufacturerPart, renderOwner, renderPart, renderPartCategory, + renderReturnOrder, renderStockItem, renderStockLocation, renderSupplierPart, @@ -44,6 +46,8 @@ function getModelRenderer(model) { switch (model) { case 'company': return renderCompany; + case 'contact': + return renderContact; case 'stockitem': return renderStockItem; case 'stocklocation': @@ -58,6 +62,8 @@ function getModelRenderer(model) { return renderPurchaseOrder; case 'salesorder': return renderSalesOrder; + case 'returnorder': + return renderReturnOrder; case 'salesordershipment': return renderSalesOrderShipment; case 'manufacturerpart': @@ -150,6 +156,17 @@ function renderCompany(data, parameters={}) { } +// Renderer for "Contact" model +function renderContact(data, parameters={}) { + return renderModel( + { + text: data.name, + }, + parameters + ); +} + + // Renderer for "StockItem" model function renderStockItem(data, parameters={}) { @@ -158,9 +175,6 @@ function renderStockItem(data, parameters={}) { let render_location_detail = ('render_location_detail' in parameters) ? parameters.render_location_detail : false; let render_available_quantity = ('render_available_quantity' in parameters) ? parameters.render_available_quantity : false; - if (render_part_detail) { - } - let text = ''; let stock_detail = ''; @@ -360,6 +374,26 @@ function renderSalesOrder(data, parameters={}) { } +// Renderer for "ReturnOrder" model +function renderReturnOrder(data, parameters={}) { + let image = blankImage(); + + if (data.customer_detail) { + image = data.customer_detail.thumbnail || data.customer_detail.image || blankImage(); + } + + return renderModel( + { + image: image, + text: `${data.reference} - ${data.customer_detail.name}`, + textSecondary: shortenString(data.description), + url: data.url || `/order/return-order/${data.pk}/`, + }, + parameters, + ); +} + + // Renderer for "SalesOrderShipment" model function renderSalesOrderShipment(data, parameters={}) { diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index b3880cfef5..01aa6b49f9 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -2,829 +2,26 @@ {% load inventree_extras %} /* globals - companyFormFields, - constructForm, - createSupplierPart, - global_settings, - imageHoverIcon, inventreeGet, - launchModalForm, - loadTableFilters, - makeIconBadge, - purchaseOrderStatusDisplay, - receivePurchaseOrderItems, - renderLink, - salesOrderStatusDisplay, - setupFilterList, - supplierPartFields, */ /* exported - allocateStockToSalesOrder, - cancelPurchaseOrder, - cancelSalesOrder, - completePurchaseOrder, - completeSalesOrder, - completeShipment, - completePendingShipments, - createPurchaseOrder, - createPurchaseOrderLineItem, - createSalesOrder, - createSalesOrderLineItem, - createSalesOrderShipment, - duplicatePurchaseOrder, - editPurchaseOrder, - editPurchaseOrderLineItem, - editSalesOrder, + createExtraLineItem, + editExtraLineItem, exportOrder, issuePurchaseOrder, - loadPurchaseOrderLineItemTable, - loadPurchaseOrderExtraLineTable - loadPurchaseOrderTable, - loadSalesOrderAllocationTable, - loadSalesOrderLineItemTable, - loadSalesOrderExtraLineTable - loadSalesOrderShipmentTable, - loadSalesOrderTable, newPurchaseOrderFromOrderWizard, newSupplierPartFromOrderWizard, orderParts, removeOrderRowFromOrderWizard, removePurchaseOrderLineItem, loadOrderTotal, + loadExtraLineTable, extraLineFields, + reloadTotal, */ -/* - * Form field definitions for a SalesOrderShipment - */ -function salesOrderShipmentFields(options={}) { - var fields = { - order: {}, - reference: {}, - tracking_number: { - icon: 'fa-hashtag', - }, - invoice_number: { - icon: 'fa-dollar-sign', - }, - link: { - icon: 'fa-link', - } - }; - - // If order is specified, hide the order field - if (options.order) { - fields.order.value = options.order; - fields.order.hidden = true; - } - - return fields; -} - - -/* - * Complete a shipment - */ -function completeShipment(shipment_id, options={}) { - - // Request the list of stock items which will be shipped - inventreeGet(`/api/order/so/shipment/${shipment_id}/`, {}, { - success: function(shipment) { - var allocations = shipment.allocations; - - var html = ''; - - if (!allocations || allocations.length == 0) { - html = ` -
    - {% trans "No stock items have been allocated to this shipment" %} -
    - `; - } else { - html = ` - {% trans "The following stock items will be shipped" %} - - - - - - - - - `; - - allocations.forEach(function(allocation) { - - var part = allocation.part_detail; - var thumb = thumbnailImage(part.thumbnail || part.image); - - var stock = ''; - - if (allocation.serial) { - stock = `{% trans "Serial Number" %}: ${allocation.serial}`; - } else { - stock = `{% trans "Quantity" %}: ${allocation.quantity}`; - } - - html += ` - - - - - `; - }); - - html += ` - -
    {% trans "Part" %}{% trans "Stock Item" %}
    ${thumb} ${part.full_name}${stock}
    - `; - } - - constructForm(`/api/order/so/shipment/${shipment_id}/ship/`, { - method: 'POST', - title: `{% trans "Complete Shipment" %} ${shipment.reference}`, - fields: { - shipment_date: { - value: moment().format('YYYY-MM-DD'), - }, - tracking_number: { - value: shipment.tracking_number, - icon: 'fa-hashtag', - }, - invoice_number: { - value: shipment.invoice_number, - icon: 'fa-dollar-sign', - }, - link: { - value: shipment.link, - icon: 'fa-link', - } - }, - preFormContent: html, - confirm: true, - confirmMessage: '{% trans "Confirm Shipment" %}', - buttons: options.buttons, - onSuccess: function(data) { - // Reload tables - $('#so-lines-table').bootstrapTable('refresh'); - $('#pending-shipments-table').bootstrapTable('refresh'); - $('#completed-shipments-table').bootstrapTable('refresh'); - - if (options.onSuccess instanceof Function) { - options.onSuccess(data); - } - }, - reload: options.reload - }); - } - }); -} - -/* - * Launches a modal to mark all allocated pending shipments as complete - */ -function completePendingShipments(order_id, options={}) { - var pending_shipments = null; - - // Request the list of stock items which will be shipped - inventreeGet(`/api/order/so/shipment/.*`, - { - order: order_id, - shipped: false - }, - { - async: false, - success: function(shipments) { - pending_shipments = shipments; - } - } - ); - - var allocated_shipments = []; - - for (var idx = 0; idx < pending_shipments.length; idx++) { - if (pending_shipments[idx].allocations.length > 0) { - allocated_shipments.push(pending_shipments[idx]); - } - } - - if (allocated_shipments.length > 0) { - completePendingShipmentsHelper(allocated_shipments, 0, options); - - } else { - html = ` -
    - `; - - if (!pending_shipments.length) { - html += ` - {% trans "No pending shipments found" %} - `; - } else { - html += ` - {% trans "No stock items have been allocated to pending shipments" %} - `; - } - - html += ` -
    - `; - - constructForm(`/api/order/so/shipment/0/ship/`, { - method: 'POST', - title: '{% trans "Complete Shipments" %}', - preFormContent: html, - onSubmit: function(fields, options) { - handleFormSuccess(fields, options); - }, - closeText: 'Close', - hideSubmitButton: true, - }); - } -} - - -/* - * Recursive helper for opening shipment completion modals - */ -function completePendingShipmentsHelper(shipments, shipment_idx, options={}) { - if (shipment_idx < shipments.length) { - completeShipment(shipments[shipment_idx].pk, - { - buttons: [ - { - name: 'skip', - title: `{% trans "Skip" %}`, - onClick: function(form_options) { - if (form_options.modal) { - $(form_options.modal).modal('hide'); - } - - completePendingShipmentsHelper(shipments, shipment_idx + 1, options); - } - } - ], - onSuccess: function(data) { - completePendingShipmentsHelper(shipments, shipment_idx + 1, options); - }, - } - ); - - } else if (options.reload) { - location.reload(); - } -} - -/* - * Launches a modal form to mark a PurchaseOrder as "complete" - */ -function completePurchaseOrder(order_id, options={}) { - - constructForm( - `/api/order/po/${order_id}/complete/`, - { - method: 'POST', - title: '{% trans "Complete Purchase Order" %}', - confirm: true, - fieldsFunction: function(opts) { - var fields = { - accept_incomplete: {}, - }; - - if (opts.context.is_complete) { - delete fields['accept_incomplete']; - } - - return fields; - }, - preFormContent: function(opts) { - - var html = ` -
    - {% trans "Mark this order as complete?" %} -
    `; - - if (opts.context.is_complete) { - html += ` -
    - {% trans "All line items have been received" %} -
    `; - } else { - html += ` -
    - {% trans 'This order has line items which have not been marked as received.' %}
    - {% trans 'Completing this order means that the order and line items will no longer be editable.' %} -
    `; - } - - return html; - }, - onSuccess: function(response) { - handleFormSuccess(response, options); - } - } - ); -} - - -/* - * Launches a modal form to mark a PurchaseOrder as 'cancelled' - */ -function cancelPurchaseOrder(order_id, options={}) { - - constructForm( - `/api/order/po/${order_id}/cancel/`, - { - method: 'POST', - title: '{% trans "Cancel Purchase Order" %}', - confirm: true, - preFormContent: function(opts) { - var html = ` -
    - {% trans "Are you sure you wish to cancel this purchase order?" %} -
    `; - - if (!opts.context.can_cancel) { - html += ` -
    - {% trans "This purchase order can not be cancelled" %} -
    `; - } - - return html; - }, - onSuccess: function(response) { - handleFormSuccess(response, options); - } - } - ); -} - - -/* - * Launches a modal form to mark a PurchaseOrder as "issued" - */ -function issuePurchaseOrder(order_id, options={}) { - - constructForm( - `/api/order/po/${order_id}/issue/`, - { - method: 'POST', - title: '{% trans "Issue Purchase Order" %}', - confirm: true, - preFormContent: function(opts) { - var html = ` -
    - {% trans 'After placing this purchase order, line items will no longer be editable.' %} -
    `; - - return html; - }, - onSuccess: function(response) { - handleFormSuccess(response, options); - } - } - ); -} - - -/* - * Launches a modal form to mark a SalesOrder as "complete" - */ -function completeSalesOrder(order_id, options={}) { - - constructForm( - `/api/order/so/${order_id}/complete/`, - { - method: 'POST', - title: '{% trans "Complete Sales Order" %}', - confirm: true, - fieldsFunction: function(opts) { - var fields = { - accept_incomplete: {}, - }; - - if (opts.context.is_complete) { - delete fields['accept_incomplete']; - } - - return fields; - }, - preFormContent: function(opts) { - var html = ` -
    - {% trans "Mark this order as complete?" %} -
    `; - - if (opts.context.pending_shipments) { - html += ` -
    - {% trans "Order cannot be completed as there are incomplete shipments" %}
    -
    `; - } - - if (!opts.context.is_complete) { - html += ` -
    - {% trans "This order has line items which have not been completed." %}
    - {% trans "Completing this order means that the order and line items will no longer be editable." %} -
    `; - } - - return html; - }, - onSuccess: function(response) { - handleFormSuccess(response, options); - } - } - ); -} - - -/* - * Launches a modal form to mark a SalesOrder as "cancelled" - */ -function cancelSalesOrder(order_id, options={}) { - - constructForm( - `/api/order/so/${order_id}/cancel/`, - { - method: 'POST', - title: '{% trans "Cancel Sales Order" %}', - confirm: true, - preFormContent: function(opts) { - var html = ` -
    - {% trans "Cancelling this order means that the order will no longer be editable." %} -
    `; - - return html; - }, - onSuccess: function(response) { - handleFormSuccess(response, options); - } - } - ); -} - -// Open a dialog to create a new sales order shipment -function createSalesOrderShipment(options={}) { - - // Work out the next shipment number for the given order - inventreeGet( - '{% url "api-so-shipment-list" %}', - { - order: options.order, - }, - { - success: function(results) { - // "predict" the next reference number - var ref = results.length + 1; - - var found = false; - - while (!found) { - - var no_match = true; - - for (var ii = 0; ii < results.length; ii++) { - if (ref.toString() == results[ii].reference.toString()) { - no_match = false; - break; - } - } - - if (no_match) { - break; - } else { - ref++; - } - } - - var fields = salesOrderShipmentFields(options); - - fields.reference.value = ref; - fields.reference.prefix = options.reference; - - constructForm('{% url "api-so-shipment-list" %}', { - method: 'POST', - fields: fields, - title: '{% trans "Create New Shipment" %}', - onSuccess: function(data) { - if (options.onSuccess) { - options.onSuccess(data); - } - } - }); - } - } - ); -} - - -function salesOrderFields(options={}) { - let fields = { - reference: { - icon: 'fa-hashtag', - }, - description: {}, - customer: { - icon: 'fa-user-tie', - secondary: { - title: '{% trans "Add Customer" %}', - fields: function() { - var fields = companyFormFields(); - - fields.is_customer.value = true; - - return fields; - } - } - }, - customer_reference: {}, - target_date: { - icon: 'fa-calendar-alt', - }, - link: { - icon: 'fa-link', - }, - responsible: { - icon: 'fa-user', - } - }; - - return fields; -} - - -/* - * Create a new SalesOrder - */ -function createSalesOrder(options={}) { - - let fields = salesOrderFields(options); - - if (options.customer) { - fields.customer.value = options.customer; - } - - constructForm('{% url "api-so-list" %}', { - method: 'POST', - fields: fields, - onSuccess: function(data) { - location.href = `/order/sales-order/${data.pk}/`; - }, - title: '{% trans "Create Sales Order" %}', - }); -} - - -/* - * Edit an existing SalesOrder - */ -function editSalesOrder(order_id, options={}) { - - constructForm(`/api/order/so/${order_id}/`, { - fields: salesOrderFields(options), - title: '{% trans "Edit Sales Order" %}', - onSuccess: function(response) { - handleFormSuccess(response, options); - } - }); -} - - -/* - * Launch a modal form to create a new SalesOrderLineItem - */ -function createSalesOrderLineItem(options={}) { - - let fields = soLineItemFields(options); - - constructForm('{% url "api-so-line-list" %}', { - fields: fields, - method: 'POST', - title: '{% trans "Add Line Item" %}', - onSuccess: function(response) { - handleFormSuccess(response, options); - }, - }); -} - - -/* - * Construct a set of fields for a purchase order form - */ -function purchaseOrderFields(options={}) { - - var fields = { - reference: { - icon: 'fa-hashtag', - }, - description: {}, - supplier: { - icon: 'fa-building', - secondary: { - title: '{% trans "Add Supplier" %}', - fields: function() { - var fields = companyFormFields(); - - fields.is_supplier.value = true; - - return fields; - } - } - }, - supplier_reference: {}, - target_date: { - icon: 'fa-calendar-alt', - }, - link: { - icon: 'fa-link', - }, - responsible: { - icon: 'fa-user', - }, - }; - - if (options.supplier) { - fields.supplier.value = options.supplier; - } - - if (options.hide_supplier) { - fields.supplier.hidden = true; - } - - // Add fields for order duplication (only if required) - if (options.duplicate_order) { - fields.duplicate_order = { - value: options.duplicate_order, - group: 'duplicate', - required: 'true', - type: 'related field', - model: 'purchaseorder', - filters: { - supplier_detail: true, - }, - api_url: '{% url "api-po-list" %}', - label: '{% trans "Purchase Order" %}', - help_text: '{% trans "Select purchase order to duplicate" %}', - }; - - fields.duplicate_line_items = { - value: true, - group: 'duplicate', - type: 'boolean', - label: '{% trans "Duplicate Line Items" %}', - help_text: '{% trans "Duplicate all line items from the selected order" %}', - }; - - fields.duplicate_extra_lines = { - value: true, - group: 'duplicate', - type: 'boolean', - label: '{% trans "Duplicate Extra Lines" %}', - help_text: '{% trans "Duplicate extra line items from the selected order" %}', - }; - } - - return fields; -} - - -/* - * Edit an existing PurchaseOrder - */ -function editPurchaseOrder(pk, options={}) { - - var fields = purchaseOrderFields(options); - - constructForm(`/api/order/po/${pk}/`, { - fields: fields, - title: '{% trans "Edit Purchase Order" %}', - onSuccess: function(response) { - handleFormSuccess(response, options); - } - }); -} - - -// Create a new PurchaseOrder -function createPurchaseOrder(options={}) { - - var fields = purchaseOrderFields(options); - - var groups = {}; - - if (options.duplicate_order) { - groups.duplicate = { - title: '{% trans "Duplication Options" %}', - collapsible: false, - }; - }; - - constructForm('{% url "api-po-list" %}', { - method: 'POST', - fields: fields, - groups: groups, - data: options.data, - onSuccess: function(data) { - - if (options.onSuccess) { - options.onSuccess(data); - } else { - // Default action is to redirect browser to the new PurchaseOrder - location.href = `/order/purchase-order/${data.pk}/`; - } - }, - title: options.title || '{% trans "Create Purchase Order" %}', - }); -} - -/* - * Duplicate an existing PurchaseOrder - * Provides user with option to duplicate line items for the order also. - */ -function duplicatePurchaseOrder(order_id, options={}) { - - options.duplicate_order = order_id; - - inventreeGet(`/api/order/po/${order_id}/`, {}, { - success: function(data) { - - // Clear out data we do not want to be duplicated - delete data['pk']; - delete data['reference']; - - options.data = data; - - createPurchaseOrder(options); - } - }); -} - - -// Create a new PurchaseOrderLineItem -function createPurchaseOrderLineItem(order, options={}) { - - var fields = poLineItemFields({ - order: order, - supplier: options.supplier, - currency: options.currency, - target_date: options.target_date, - }); - - constructForm('{% url "api-po-line-list" %}', { - fields: fields, - method: 'POST', - title: '{% trans "Add Line Item" %}', - onSuccess: function(response) { - handleFormSuccess(response, options); - } - }); -} - - -/* Construct a set of fields for the SalesOrderLineItem form */ -function soLineItemFields(options={}) { - - let fields = { - order: { - hidden: true, - }, - part: { - icon: 'fa-shapes', - }, - quantity: {}, - reference: {}, - sale_price: { - icon: 'fa-dollar-sign', - }, - sale_price_currency: { - icon: 'fa-coins', - }, - target_date: { - icon: 'fa-calendar-alt', - }, - notes: { - icon: 'fa-sticky-note', - }, - }; - - if (options.order) { - fields.order.value = options.order; - } - - if (options.target_date) { - fields.target_date.value = options.target_date; - } - - return fields; -} - - /* Construct a set of fields for a OrderExtraLine form */ function extraLineFields(options={}) { @@ -853,153 +50,34 @@ function extraLineFields(options={}) { } -/* Construct a set of fields for the PurchaseOrderLineItem form */ -function poLineItemFields(options={}) { +/* + * Create a new ExtraLineItem + */ +function createExtraLineItem(options={}) { - var fields = { - order: { - filters: { - supplier_detail: true, - } - }, - part: { - icon: 'fa-shapes', - filters: { - part_detail: true, - supplier_detail: true, - supplier: options.supplier, - }, - onEdit: function(value, name, field, opts) { - // If the pack_size != 1, add a note to the field - var pack_size = 1; - var units = ''; - var supplier_part_id = value; - var quantity = getFormFieldValue('quantity', {}, opts); - - // Remove any existing note fields - $(opts.modal).find('#info-pack-size').remove(); - - if (value == null) { - return; - } - - // Request information about the particular supplier part - inventreeGet(`/api/company/part/${value}/`, - { - part_detail: true, - }, - { - success: function(response) { - // Extract information from the returned query - pack_size = response.pack_size || 1; - units = response.part_detail.units || ''; - }, - } - ).then(function() { - // Update pack size information - if (pack_size != 1) { - var txt = ` {% trans "Pack Quantity" %}: ${pack_size} ${units}`; - $(opts.modal).find('#hint_id_quantity').after(`
    ${txt}
    `); - } - }).then(function() { - // Update pricing data (if available) - inventreeGet( - '{% url "api-part-supplier-price-list" %}', - { - part: supplier_part_id, - ordering: 'quantity', - }, - { - success: function(response) { - // Returned prices are in increasing order of quantity - if (response.length > 0) { - var idx = 0; - var index = 0; - - for (var idx = 0; idx < response.length; idx++) { - if (response[idx].quantity > quantity) { - break; - } - - index = idx; - } - - // Update price and currency data in the form - updateFieldValue('purchase_price', response[index].price, {}, opts); - updateFieldValue('purchase_price_currency', response[index].price_currency, {}, opts); - } - } - } - ); - }); - }, - secondary: { - method: 'POST', - title: '{% trans "Add Supplier Part" %}', - fields: function(data) { - var fields = supplierPartFields({ - part: data.part, - }); - - fields.supplier.value = options.supplier; - - // Adjust manufacturer part query based on selected part - fields.manufacturer_part.adjustFilters = function(query, opts) { - - var part = getFormFieldValue('part', {}, opts); - - if (part) { - query.part = part; - } - - return query; - }; - - return fields; - } - } - }, - quantity: {}, - reference: {}, - purchase_price: { - icon: 'fa-dollar-sign', - }, - purchase_price_currency: { - icon: 'fa-coins', - }, - target_date: { - icon: 'fa-calendar-alt', - }, - destination: { - icon: 'fa-sitemap', - filters: { - structural: false, - } - }, - notes: { - icon: 'fa-sticky-note', - }, - }; - - if (options.order) { - fields.order.value = options.order; - fields.order.hidden = true; - } + let fields = extraLineFields({ + order: options.order, + }); if (options.currency) { - fields.purchase_price_currency.value = options.currency; + fields.price_currency.value = options.currency; } - if (options.target_date) { - fields.target_date.value = options.target_date; - } - - return fields; + constructForm(options.url, { + fields: fields, + method: 'POST', + title: '{% trans "Add Extra Line Item" %}', + onSuccess: function(response) { + if (options.table) { + reloadBootstrapTable(options.table); + } + } + }); } +/* Remove a part selection from an order form. */ function removeOrderRowFromOrderWizard(e) { - /* Remove a part selection from an order form. */ e = e || window.event; @@ -1010,66 +88,10 @@ function removeOrderRowFromOrderWizard(e) { $('#' + row).remove(); } - -function newSupplierPartFromOrderWizard(e) { - /* Create a new supplier part directly from an order form. - * Launches a secondary modal and (if successful), - * back-populates the selected row. - */ - - e = e || window.event; - - var src = e.srcElement || e.target; - - var part = $(src).attr('part'); - - if (!part) { - part = $(src).closest('button').attr('part'); - } - - createSupplierPart({ - part: part, - onSuccess: function(data) { - - // TODO: 2021-08-23 - This whole form wizard needs to be refactored. - // In the future, use the API forms functionality to add the new item - // For now, this hack will have to do... - - var dropdown = `#id_supplier_part_${part}`; - - var pk = data.pk; - - inventreeGet( - `/api/company/part/${pk}/`, - { - supplier_detail: true, - }, - { - success: function(response) { - var text = ''; - - if (response.supplier_detail) { - text += response.supplier_detail.name; - text += ' | '; - } - - text += response.SKU; - - var option = new Option(text, pk, true, true); - - $('#modal-form').find(dropdown).append(option).trigger('change'); - } - } - ); - } - }); -} - /** * Export an order (PurchaseOrder or SalesOrder) * * - Display a simple form which presents the user with export options - * */ function exportOrder(redirect_url, options={}) { @@ -1109,2827 +131,6 @@ function exportOrder(redirect_url, options={}) { } -/* - * Create a new form to order parts based on the list of provided parts. - */ -function orderParts(parts_list, options) { - - var parts = []; - - var parts_seen = {}; - - parts_list.forEach(function(part) { - if (part.purchaseable) { - - // Prevent duplicates - if (!(part.pk in parts_seen)) { - parts_seen[part.pk] = true; - parts.push(part); - } - } - }); - - if (parts.length == 0) { - showAlertDialog( - '{% trans "Select Parts" %}', - '{% trans "At least one purchaseable part must be selected" %}', - ); - return; - } - - // Render a single part within the dialog - function renderPart(part, opts={}) { - - var pk = part.pk; - - var thumb = thumbnailImage(part.thumbnail || part.image); - - // Default quantity value - var quantity = part.quantity || 1; - - if (quantity < 0) { - quantity = 0; - } - - var quantity_input = constructField( - `quantity_${pk}`, - { - type: 'decimal', - min_value: 0, - value: quantity, - title: '{% trans "Quantity to order" %}', - required: true, - }, - { - hideLabels: true, - } - ); - - var supplier_part_prefix = ` - - `; - - var supplier_part_input = constructField( - `part_${pk}`, - { - type: 'related field', - required: true, - prefixRaw: supplier_part_prefix, - }, - { - hideLabels: true, - } - ); - - var purchase_order_prefix = ` - - `; - - var purchase_order_input = constructField( - `order_${pk}`, - { - type: 'related field', - required: true, - prefixRaw: purchase_order_prefix, - }, - { - hideLabels: 'true', - } - ); - - var buttons = `
    `; - - if (parts.length > 1) { - buttons += makeIconButton( - 'fa-times icon-red', - 'button-row-remove', - pk, - '{% trans "Remove row" %}', - ); - } - - // Button to add row to purchase order - buttons += makeIconButton( - 'fa-shopping-cart icon-blue', - 'button-row-add', - pk, - '{% trans "Add to purchase order" %}', - ); - - buttons += `
    `; - - var html = ` - - ${thumb} ${part.full_name} - ${supplier_part_input} - ${purchase_order_input} - ${quantity_input} - ${buttons} - `; - - return html; - } - - // Remove a single row form this dialog - function removeRow(pk, opts) { - // Remove the row - $(opts.modal).find(`#order_row_${pk}`).remove(); - - // If the modal is now "empty", dismiss it - if (!($(opts.modal).find('.part-order-row').exists())) { - closeModal(opts.modal); - // If there is a onSuccess callback defined, call it - if (options && options.onSuccess) { - options.onSuccess(); - } - } - } - - var table_entries = ''; - - parts.forEach(function(part) { - table_entries += renderPart(part); - }); - - var html = ''; - - // Add table - html += ` - - - - - - - - - - - - ${table_entries} - -
    {% trans "Part" %}{% trans "Supplier Part" %}{% trans "Purchase Order" %}{% trans "Quantity" %}
    - `; - - // Construct API filters for the SupplierPart field - var supplier_part_filters = { - supplier_detail: true, - part_detail: true, - }; - - if (options.supplier) { - supplier_part_filters.supplier = options.supplier; - } - - if (options.manufacturer) { - supplier_part_filters.manufacturer = options.manufacturer; - } - - if (options.manufacturer_part) { - supplier_part_filters.manufacturer_part = options.manufacturer_part; - } - - // Construct API filtres for the PurchaseOrder field - var order_filters = { - status: {{ PurchaseOrderStatus.PENDING }}, - supplier_detail: true, - }; - - if (options.supplier) { - order_filters.supplier = options.supplier; - } - - constructFormBody({}, { - preFormContent: html, - title: '{% trans "Order Parts" %}', - hideSubmitButton: true, - closeText: '{% trans "Close" %}', - afterRender: function(fields, opts) { - parts.forEach(function(part) { - - var pk = part.pk; - - // Filter by base part - supplier_part_filters.part = pk; - - if (part.manufacturer_part) { - // Filter by manufacturer part - supplier_part_filters.manufacturer_part = part.manufacturer_part; - } - - // Callback function when supplier part is changed - // This is used to update the "pack size" attribute - var onSupplierPartChanged = function(value, name, field, opts) { - var pack_size = 1; - var units = ''; - - $(opts.modal).find(`#info-pack-size-${pk}`).remove(); - - if (value != null) { - inventreeGet( - `/api/company/part/${value}/`, - { - part_detail: true, - }, - { - success: function(response) { - pack_size = response.pack_size || 1; - units = response.part_detail.units || ''; - } - } - ).then(function() { - if (pack_size != 1) { - var txt = ` {% trans "Pack Quantity" %}: ${pack_size} ${units}`; - $(opts.modal).find(`#id_quantity_${pk}`).after(`
    ${txt}
    `); - } - }); - } - }; - - var supplier_part_field = { - name: `part_${part.pk}`, - model: 'supplierpart', - api_url: '{% url "api-supplier-part-list" %}', - required: true, - type: 'related field', - auto_fill: true, - value: options.supplier_part, - filters: supplier_part_filters, - onEdit: onSupplierPartChanged, - noResults: function(query) { - return '{% trans "No matching supplier parts" %}'; - } - }; - - // Configure the "supplier part" field - initializeRelatedField(supplier_part_field, null, opts); - addFieldCallback(`part_${part.pk}`, supplier_part_field, opts); - - // Configure the "purchase order" field - initializeRelatedField({ - name: `order_${part.pk}`, - model: 'purchaseorder', - api_url: '{% url "api-po-list" %}', - required: true, - type: 'related field', - auto_fill: false, - value: options.order, - filters: order_filters, - noResults: function(query) { - return '{% trans "No matching purchase orders" %}'; - } - }, null, opts); - - // Request 'requirements' information for each part - inventreeGet(`/api/part/${part.pk}/requirements/`, {}, { - success: function(response) { - var required = response.required || 0; - var allocated = response.allocated || 0; - var available = response.available_stock || 0; - - // Based on what we currently 'have' on hand, what do we need to order? - var deficit = Math.max(required - allocated, 0); - - if (available < deficit) { - var q = deficit - available; - - updateFieldValue( - `quantity_${part.pk}`, - q, - {}, - opts - ); - } - } - }); - }); - - // Add callback for "add to purchase order" button - $(opts.modal).find('.button-row-add').click(function() { - var pk = $(this).attr('pk'); - - opts.field_suffix = null; - - // Extract information from the row - var data = { - quantity: getFormFieldValue(`quantity_${pk}`, {type: 'decimal'}, opts), - part: getFormFieldValue(`part_${pk}`, {}, opts), - order: getFormFieldValue(`order_${pk}`, {}, opts), - }; - - // Duplicate the form options, to prevent 'field_suffix' override - var row_opts = Object.assign(opts); - row_opts.field_suffix = `_${pk}`; - - inventreePut( - '{% url "api-po-line-list" %}', - data, - { - method: 'POST', - success: function(response) { - removeRow(pk, opts); - }, - error: function(xhr) { - switch (xhr.status) { - case 400: - handleFormErrors(xhr.responseJSON, fields, row_opts); - break; - default: - console.error(`Error adding line to purchase order`); - showApiError(xhr, options.url); - break; - } - } - } - ); - }); - - // Add callback for "remove row" button - $(opts.modal).find('.button-row-remove').click(function() { - var pk = $(this).attr('pk'); - - removeRow(pk, opts); - }); - - // Add callback for "new supplier part" button - $(opts.modal).find('.button-row-new-sp').click(function() { - var pk = $(this).attr('pk'); - - // Launch dialog to create new supplier part - createSupplierPart({ - part: pk, - onSuccess: function(response) { - setRelatedFieldData( - `part_${pk}`, - response, - opts - ); - } - }); - }); - - // Add callback for "new purchase order" button - $(opts.modal).find('.button-row-new-po').click(function() { - var pk = $(this).attr('pk'); - - // Launch dialog to create new purchase order - createPurchaseOrder({ - onSuccess: function(response) { - setRelatedFieldData( - `order_${pk}`, - response, - opts - ); - } - }); - }); - } - }); - -} - -function newPurchaseOrderFromOrderWizard(e) { - /* Create a new purchase order directly from an order form. - * Launches a secondary modal and (if successful), - * back-fills the newly created purchase order. - */ - - e = e || window.event; - - var src = e.target || e.srcElement; - - var supplier = $(src).attr('supplierid'); - - createPurchaseOrder({ - supplier: supplier, - onSuccess: function(data) { - - // TODO: 2021-08-23 - The whole form wizard needs to be refactored - // In the future, the drop-down should be using a dynamic AJAX request - // to fill out the select2 options! - - var pk = data.pk; - - inventreeGet( - `/api/order/po/${pk}/`, - { - supplier_detail: true, - }, - { - success: function(response) { - var text = response.reference; - - if (response.supplier_detail) { - text += ` ${response.supplier_detail.name}`; - } - - var dropdown = `#id-purchase-order-${supplier}`; - - var option = new Option(text, pk, true, true); - - $('#modal-form').find(dropdown).append(option).trigger('change'); - } - } - ); - } - }); -} - - -/** - * Receive stock items against a PurchaseOrder - * Uses the PurchaseOrderReceive 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={}) { - - // Zero items selected? - 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; - } - - // Prepend toggles to the quantity input - var toggle_batch = ` - - - - `; - - var toggle_serials = ` - - - - `; - - var units = line_item.part_detail.units || ''; - var pack_size = line_item.supplier_part_detail.pack_size || 1; - var pack_size_div = ''; - - var received = quantity * pack_size; - - if (pack_size != 1) { - pack_size_div = ` -
    - {% trans "Pack Quantity" %}: ${pack_size} ${units}
    - {% trans "Received Quantity" %}: ${received} ${units} -
    `; - } - - // 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, - } - ); - - // Add in options for "batch code" and "serial numbers" - var batch_input = constructField( - `items_batch_code_${pk}`, - { - type: 'string', - required: false, - label: '{% trans "Batch Code" %}', - help_text: '{% trans "Enter batch code for incoming stock items" %}', - prefixRaw: toggle_batch, - } - ); - - var sn_input = constructField( - `items_serial_numbers_${pk}`, - { - type: 'string', - required: false, - label: '{% trans "Serial Numbers" %}', - help_text: '{% trans "Enter serial numbers for incoming stock items" %}', - prefixRaw: toggle_serials, - } - ); - - // Hidden inputs below the "quantity" field - var quantity_input_group = `${quantity_input}${pack_size_div}
    ${batch_input}
    `; - - if (line_item.part_detail.trackable) { - quantity_input_group += `
    ${sn_input}
    `; - } - - // 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 buttons = `
    `; - - buttons += makeIconButton( - 'fa-layer-group', - 'button-row-add-batch', - pk, - '{% trans "Add batch code" %}', - { - collapseTarget: `div-batch-${pk}` - } - ); - - if (line_item.part_detail.trackable) { - buttons += makeIconButton( - 'fa-hashtag', - 'button-row-add-serials', - pk, - '{% trans "Add serial numbers" %}', - { - collapseTarget: `div-serials-${pk}`, - } - ); - } - - if (line_items.length > 1) { - buttons += makeIconButton( - 'fa-times icon-red', - 'button-row-remove', - pk, - '{% trans "Remove row" %}', - ); - } - - buttons += '
    '; - - var html = ` - - - ${thumb} ${line_item.part_detail.full_name} - - - ${line_item.supplier_part_detail.SKU} - - - ${line_item.quantity} - - - ${line_item.received} - - - ${quantity_input_group} - - - ${status_input} - - - ${destination_input} - - - ${buttons} - - `; - - return html; - } - - var table_entries = ''; - - line_items.forEach(function(item) { - if (item.received < item.quantity) { - table_entries += renderLineItem(item); - } - }); - - var html = ``; - - // Add table - html += ` - - - - - - - - - - - - - - - ${table_entries} - -
    {% trans "Part" %}{% trans "Order Code" %}{% trans "Ordered" %}{% trans "Received" %}{% trans "Quantity to Receive" %}{% trans "Status" %}{% trans "Destination" %}
    - `; - - constructForm(`/api/order/po/${order_id}/receive/`, { - method: 'POST', - fields: { - location: { - filters: { - structural: false, - } - }, - }, - preFormContent: html, - confirm: true, - confirmMessage: '{% trans "Confirm receipt of items" %}', - title: '{% trans "Receive Purchase Order Items" %}', - afterRender: function(fields, opts) { - - // Run initialization routines for each line in the form - 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, - }; - - // Initialize the location field - initializeRelatedField( - field_details, - null, - opts, - ); - - // Add 'clear' button callback for the location field - addClearCallback( - name, - field_details, - opts - ); - - // Setup stock item status field - initializeChoiceField( - { - name: `items_status_${pk}`, - }, - null, - opts - ); - - // Add change callback for quantity field - if (item.supplier_part_detail.pack_size != 1) { - $(opts.modal).find(`#id_items_quantity_${pk}`).change(function() { - var value = $(opts.modal).find(`#id_items_quantity_${pk}`).val(); - - var el = $(opts.modal).find(`#quantity_${pk}`).find('.pack_received_quantity'); - - var actual = value * item.supplier_part_detail.pack_size; - actual = formatDecimal(actual); - el.text(actual); - }); - } - }); - - // 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) { - - var line = { - line_item: pk, - quantity: quantity, - status: status, - location: location, - }; - - if (getFormFieldElement(`items_batch_code_${pk}`).exists()) { - line.batch_code = getFormFieldValue(`items_batch_code_${pk}`); - } - - if (getFormFieldElement(`items_serial_numbers_${pk}`).exists()) { - line.serial_numbers = getFormFieldValue(`items_serial_numbers_${pk}`); - } - - data.items.push(line); - 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, opts.url); - break; - } - } - } - ); - } - }); -} - - -function editPurchaseOrderLineItem(e) { - - /* Edit a purchase order line item in a modal form. - */ - - e = e || window.event; - - var src = e.target || e.srcElement; - - var url = $(src).attr('url'); - - // TODO: Migrate this to the API forms - launchModalForm(url, { - reload: true, - }); -} - -function removePurchaseOrderLineItem(e) { - - /* Delete a purchase order line item in a modal form - */ - - e = e || window.event; - - var src = e.target || e.srcElement; - - var url = $(src).attr('url'); - - // TODO: Migrate this to the API forms - launchModalForm(url, { - reload: true, - }); -} - - -/* - * Load a table displaying list of purchase orders - */ -function loadPurchaseOrderTable(table, options) { - // Ensure the table starts in a known state - $(table).bootstrapTable('destroy'); - - options.params = options.params || {}; - - options.params['supplier_detail'] = true; - - var filters = loadTableFilters('purchaseorder'); - - for (var key in options.params) { - filters[key] = options.params[key]; - } - - var target = '#filter-list-purchaseorder'; - - setupFilterList('purchaseorder', $(table), target, {download: true}); - - var display_mode = inventreeLoad('purchaseorder-table-display-mode', 'list'); - - // Function for rendering PurchaseOrder calendar display - function buildEvents(calendar) { - - var start = startDate(calendar); - var end = endDate(calendar); - - clearEvents(calendar); - - // Extract current filters from table - var table_options = $(table).bootstrapTable('getOptions'); - var filters = table_options.query_params || {}; - - filters.supplier_detail = true; - filters.min_date = start; - filters.max_date = end; - - // Request purchase orders from the server within specified date range - inventreeGet( - '{% url "api-po-list" %}', - filters, - { - success: function(response) { - for (var idx = 0; idx < response.length; idx++) { - - var order = response[idx]; - - var date = order.creation_date; - - if (order.complete_date) { - date = order.complete_date; - } else if (order.target_date) { - date = order.target_date; - } - - var title = `${order.reference} - ${order.supplier_detail.name}`; - - var color = '#4c68f5'; - - if (order.complete_date) { - color = '#25c235'; - } else if (order.overdue) { - color = '#c22525'; - } else { - color = '#4c68f5'; - } - - var event = { - title: title, - start: date, - end: date, - url: `/order/purchase-order/${order.pk}/`, - backgroundColor: color, - }; - - calendar.addEvent(event); - } - } - } - ); - } - - $(table).inventreeTable({ - url: '{% url "api-po-list" %}', - queryParams: filters, - name: 'purchaseorder', - groupBy: false, - sidePagination: 'server', - original: options.params, - showColumns: display_mode == 'list', - disablePagination: display_mode == 'calendar', - showCustomViewButton: false, - showCustomView: display_mode == 'calendar', - search: display_mode != 'calendar', - formatNoMatches: function() { - return '{% trans "No purchase orders found" %}'; - }, - buttons: constructOrderTableButtons({ - prefix: 'purchaseorder', - disableTreeView: true, - callback: function() { - // Reload the entire table - loadPurchaseOrderTable(table, options); - } - }), - columns: [ - { - title: '', - visible: true, - checkbox: true, - switchable: false, - }, - { - field: 'reference', - title: '{% trans "Purchase Order" %}', - sortable: true, - switchable: false, - formatter: function(value, row) { - - var html = renderLink(value, `/order/purchase-order/${row.pk}/`); - - if (row.overdue) { - html += makeIconBadge('fa-calendar-times icon-red', '{% trans "Order is overdue" %}'); - } - - return html; - } - }, - { - field: 'supplier_detail', - title: '{% trans "Supplier" %}', - sortable: true, - sortName: 'supplier__name', - formatter: function(value, row) { - return imageHoverIcon(row.supplier_detail.image) + renderLink(row.supplier_detail.name, `/company/${row.supplier}/?display=purchase-orders`); - } - }, - { - field: 'supplier_reference', - title: '{% trans "Supplier Reference" %}', - }, - { - field: 'description', - title: '{% trans "Description" %}', - }, - { - field: 'status', - title: '{% trans "Status" %}', - switchable: true, - sortable: true, - formatter: function(value, row) { - return purchaseOrderStatusDisplay(row.status); - } - }, - { - field: 'creation_date', - title: '{% trans "Date" %}', - sortable: true, - formatter: function(value) { - return renderDate(value); - } - }, - { - field: 'target_date', - title: '{% trans "Target Date" %}', - sortable: true, - formatter: function(value) { - return renderDate(value); - } - }, - { - field: 'line_items', - title: '{% trans "Items" %}', - sortable: true, - }, - { - field: 'total_price', - title: '{% trans "Total Cost" %}', - switchable: true, - sortable: true, - formatter: function(value, row) { - return formatCurrency(value, { - currency: row.total_price_currency, - }); - }, - }, - { - field: 'responsible', - title: '{% trans "Responsible" %}', - switchable: true, - sortable: true, - formatter: function(value, row) { - - if (!row.responsible_detail) { - return '-'; - } - - var html = row.responsible_detail.name; - - if (row.responsible_detail.label == 'group') { - html += ``; - } else { - html += ``; - } - - return html; - } - }, - ], - customView: function(data) { - return `
    `; - }, - onRefresh: function() { - loadPurchaseOrderTable(table, options); - }, - onLoadSuccess: function() { - - if (display_mode == 'calendar') { - var el = document.getElementById('purchase-order-calendar'); - - calendar = new FullCalendar.Calendar(el, { - initialView: 'dayGridMonth', - nowIndicator: true, - aspectRatio: 2.5, - locale: options.locale, - datesSet: function() { - buildEvents(calendar); - } - }); - - calendar.render(); - } - } - }); -} - - -/* - * Delete the selected Purchase Order Line Items from the database - */ -function deletePurchaseOrderLineItems(items, options={}) { - - function renderItem(item, opts={}) { - - var part = item.part_detail; - var thumb = thumbnailImage(item.part_detail.thumbnail || item.part_detail.image); - var MPN = item.supplier_part_detail.manufacturer_part_detail ? item.supplier_part_detail.manufacturer_part_detail.MPN : '-'; - - var html = ` - - ${thumb} ${part.full_name} - ${part.description} - ${item.supplier_part_detail.SKU} - ${MPN} - ${item.quantity} - - `; - - return html; - } - - var rows = ''; - var ids = []; - - items.forEach(function(item) { - rows += renderItem(item); - ids.push(item.pk); - }); - - var html = ` -
    - {% trans "All selected Line items will be deleted" %} -
    - - - - - - - - - - ${rows} -
    {% trans "Part" %}{% trans "Description" %}{% trans "SKU" %}{% trans "MPN" %}{% trans "Quantity" %}
    - `; - - constructForm('{% url "api-po-line-list" %}', { - method: 'DELETE', - multi_delete: true, - title: '{% trans "Delete selected Line items?" %}', - form_data: { - items: ids, - }, - preFormContent: html, - onSuccess: function() { - // Refresh the table once the line items are deleted - $('#po-line-table').bootstrapTable('refresh'); - }, - }); -} - - -/** - * Load a table displaying line items for a particular PurchasesOrder - * @param {String} table - HTML ID tag e.g. '#table' - * @param {Object} options - options which must provide: - * - order (integer PK) - * - supplier (integer PK) - * - allow_edit (boolean) - * - allow_receive (boolean) - */ -function loadPurchaseOrderLineItemTable(table, options={}) { - - options.params = options.params || {}; - - options.params['order'] = options.order; - options.params['part_detail'] = true; - - // Override 'editing' if order is not pending - if (!options.pending && !global_settings.PURCHASEORDER_EDIT_COMPLETED_ORDERS) { - options.allow_edit = false; - } - - 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, - { - download: true - } - ); - - function setupCallbacks() { - if (options.allow_edit) { - - // Callback for "duplicate" button - $(table).find('.button-line-duplicate').click(function() { - var pk = $(this).attr('pk'); - - inventreeGet(`/api/order/po-line/${pk}/`, {}, { - success: function(data) { - - var fields = poLineItemFields({ - supplier: options.supplier, - }); - - constructForm('{% url "api-po-line-list" %}', { - method: 'POST', - fields: fields, - data: data, - title: '{% trans "Duplicate Line Item" %}', - onSuccess: function(response) { - $(table).bootstrapTable('refresh'); - } - }); - } - }); - }); - - // Callback for "edit" button - $(table).find('.button-line-edit').click(function() { - var pk = $(this).attr('pk'); - - var fields = poLineItemFields(options); - - constructForm(`/api/order/po-line/${pk}/`, { - fields: fields, - title: '{% trans "Edit Line Item" %}', - onSuccess: function() { - $(table).bootstrapTable('refresh'); - } - }); - }); - - // Callback for "delete" button - $(table).find('.button-line-delete').click(function() { - var pk = $(this).attr('pk'); - - constructForm(`/api/order/po-line/${pk}/`, { - method: 'DELETE', - title: '{% trans "Delete Line Item" %}', - onSuccess: function() { - $(table).bootstrapTable('refresh'); - } - }); - }); - - // Callback for bulk deleting mutliple lines - $('#po-lines-bulk-delete').off('click').on('click', function() { - var rows = getTableData(' #po-line-table'); - - deletePurchaseOrderLineItems(rows); - }); - } - - if (options.allow_receive) { - $(table).find('.button-line-receive').click(function() { - var pk = $(this).attr('pk'); - - var line_item = $(table).bootstrapTable('getRowByUniqueId', pk); - - if (!line_item) { - console.warn('getRowByUniqueId returned null'); - return; - } - - receivePurchaseOrderItems( - options.order, - [ - line_item, - ], - { - success: function() { - // Reload the line item table - $(table).bootstrapTable('refresh'); - - // Reload the "received stock" table - $('#stock-table').bootstrapTable('refresh'); - } - } - ); - }); - } - } - - $(table).inventreeTable({ - onPostBody: setupCallbacks, - name: 'purchaseorderlines', - sidePagination: 'server', - formatNoMatches: function() { - return '{% trans "No line items found" %}'; - }, - queryParams: filters, - original: options.params, - url: '{% url "api-po-line-list" %}', - showFooter: true, - uniqueId: 'pk', - columns: [ - { - checkbox: true, - visible: true, - switchable: false, - }, - { - field: 'part', - sortable: true, - sortName: 'part_name', - title: '{% trans "Part" %}', - switchable: false, - formatter: function(value, row, index, field) { - if (row.part) { - return imageHoverIcon(row.part_detail.thumbnail) + renderLink(row.part_detail.full_name, `/part/${row.part_detail.pk}/`); - } else { - return '-'; - } - }, - footerFormatter: function() { - return '{% trans "Total" %}'; - } - }, - { - field: 'part_detail.description', - title: '{% trans "Description" %}', - }, - { - sortable: true, - sortName: 'SKU', - field: 'supplier_part_detail.SKU', - title: '{% trans "SKU" %}', - formatter: function(value, row, index, field) { - if (value) { - return renderLink(value, `/supplier-part/${row.part}/`); - } else { - return '-'; - } - }, - }, - { - sortable: false, - field: 'supplier_part_detail.link', - title: '{% trans "Link" %}', - formatter: function(value, row, index, field) { - if (value) { - return renderLink(value, value); - } else { - return ''; - } - }, - }, - { - sortable: true, - sortName: 'MPN', - field: 'supplier_part_detail.manufacturer_part_detail.MPN', - title: '{% trans "MPN" %}', - formatter: function(value, row, index, field) { - if (row.supplier_part_detail && row.supplier_part_detail.manufacturer_part) { - return renderLink(value, `/manufacturer-part/${row.supplier_part_detail.manufacturer_part}/`); - } else { - return '-'; - } - }, - }, - { - sortable: true, - field: 'reference', - title: '{% trans "Reference" %}', - }, - { - sortable: true, - switchable: false, - field: 'quantity', - title: '{% trans "Quantity" %}', - formatter: function(value, row) { - var units = ''; - - if (row.part_detail.units) { - units = ` ${row.part_detail.units}`; - } - - var data = value; - - if (row.supplier_part_detail.pack_size != 1.0) { - var pack_size = row.supplier_part_detail.pack_size; - var total = value * pack_size; - data += ``; - } - - return data; - }, - footerFormatter: function(data) { - return data.map(function(row) { - return +row['quantity']; - }).reduce(function(sum, i) { - return sum + i; - }, 0); - } - }, - { - sortable: false, - switchable: true, - field: 'supplier_part_detail.pack_size', - title: '{% trans "Pack Quantity" %}', - formatter: function(value, row) { - var units = row.part_detail.units; - - if (units) { - value += ` ${units}`; - } - - return value; - } - }, - { - sortable: true, - field: 'purchase_price', - title: '{% trans "Unit Price" %}', - formatter: function(value, row) { - return formatCurrency(row.purchase_price, { - currency: row.purchase_price_currency, - }); - } - }, - { - field: 'total_price', - sortable: true, - title: '{% trans "Total Price" %}', - formatter: function(value, row) { - return formatCurrency(row.purchase_price * row.quantity, { - currency: row.purchase_price_currency - }); - }, - footerFormatter: function(data) { - return calculateTotalPrice( - data, - function(row) { - return row.purchase_price ? row.purchase_price * row.quantity : null; - }, - function(row) { - return row.purchase_price_currency; - } - ); - } - }, - { - sortable: true, - field: 'target_date', - switchable: true, - title: '{% trans "Target Date" %}', - formatter: function(value, row) { - if (row.target_date) { - var html = renderDate(row.target_date); - - if (row.overdue) { - html += ``; - } - - return html; - - } else if (row.order_detail && row.order_detail.target_date) { - return `${renderDate(row.order_detail.target_date)}`; - } else { - return '-'; - } - } - }, - { - sortable: false, - field: 'received', - switchable: false, - 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: 'destination', - title: '{% trans "Destination" %}', - formatter: function(value, row) { - if (value) { - return renderLink(row.destination_detail.pathstring, `/stock/location/${value}/`); - } else { - return '-'; - } - } - }, - { - field: 'notes', - title: '{% trans "Notes" %}', - }, - { - switchable: false, - field: 'buttons', - title: '', - formatter: function(value, row, index, field) { - var html = `
    `; - - var pk = row.pk; - - if (options.allow_receive && row.received < row.quantity) { - html += makeIconButton('fa-sign-in-alt icon-green', 'button-line-receive', pk, '{% trans "Receive line item" %}'); - } - - if (options.allow_edit) { - html += makeIconButton('fa-clone', 'button-line-duplicate', pk, '{% trans "Duplicate line item" %}'); - html += makeIconButton('fa-edit icon-blue', 'button-line-edit', pk, '{% trans "Edit line item" %}'); - html += makeIconButton('fa-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete line item" %}'); - } - - html += `
    `; - - return html; - }, - } - ] - }); - - linkButtonsToSelection( - table, - [ - '#multi-select-options', - ] - ); - -} - - -/** - * Load a table displaying lines for a particular PurchaseOrder - * - * @param {String} table : HTML ID tag e.g. '#table' - * @param {Object} options : object which contains: - * - order {integer} : pk of the PurchaseOrder - * - status: {integer} : status code for the order - */ -function loadPurchaseOrderExtraLineTable(table, options={}) { - - options.table = table; - - if (!options.pending && !global_settings.PURCHASEORDER_EDIT_COMPLETED_ORDERS) { - options.allow_edit = false; - } - - options.params = options.params || {}; - - if (!options.order) { - console.error('function called without order ID'); - return; - } - - if (!options.status) { - console.error('function called without order status'); - return; - } - - options.params.order = options.order; - options.params.part_detail = true; - options.params.allocations = true; - - var filters = loadTableFilters('purchaseorderextraline'); - - for (var key in options.params) { - filters[key] = options.params[key]; - } - - options.url = options.url || '{% url "api-po-extra-line-list" %}'; - - var filter_target = options.filter_target || '#filter-list-purchase-order-extra-lines'; - - setupFilterList('purchaseorderextraline', $(table), filter_target, {download: true}); - - // Table columns to display - var columns = [ - { - sortable: true, - field: 'reference', - title: '{% trans "Reference" %}', - switchable: true, - }, - { - sortable: true, - field: 'quantity', - title: '{% trans "Quantity" %}', - footerFormatter: function(data) { - return data.map(function(row) { - return +row['quantity']; - }).reduce(function(sum, i) { - return sum + i; - }, 0); - }, - switchable: false, - }, - { - sortable: true, - field: 'price', - title: '{% trans "Unit Price" %}', - formatter: function(value, row) { - return formatCurrency(row.price, { - currency: row.price_currency, - }); - } - }, - { - field: 'total_price', - sortable: true, - title: '{% trans "Total Price" %}', - formatter: function(value, row) { - return formatCurrency(row.price * row.quantity, { - currency: row.price_currency, - }); - }, - footerFormatter: function(data) { - return calculateTotalPrice( - data, - function(row) { - return row.price ? row.price * row.quantity : null; - }, - function(row) { - return row.price_currency; - } - ); - } - } - ]; - - columns.push({ - field: 'notes', - title: '{% trans "Notes" %}', - }); - - columns.push({ - field: 'buttons', - switchable: false, - formatter: function(value, row, index, field) { - - var html = `
    `; - - var pk = row.pk; - - if (options.allow_edit) { - html += makeIconButton('fa-clone', 'button-duplicate', pk, '{% trans "Duplicate line" %}'); - html += makeIconButton('fa-edit icon-blue', 'button-edit', pk, '{% trans "Edit line" %}'); - html += makeIconButton('fa-trash-alt icon-red', 'button-delete', pk, '{% trans "Delete line" %}', ); - } - - html += `
    `; - - return html; - } - }); - - function reloadTable() { - $(table).bootstrapTable('refresh'); - reloadTotal(); - } - - // Configure callback functions once the table is loaded - function setupCallbacks() { - - // Callback for duplicating lines - $(table).find('.button-duplicate').click(function() { - var pk = $(this).attr('pk'); - - inventreeGet(`/api/order/po-extra-line/${pk}/`, {}, { - success: function(data) { - - var fields = extraLineFields(); - - constructForm('{% url "api-po-extra-line-list" %}', { - method: 'POST', - fields: fields, - data: data, - title: '{% trans "Duplicate Line" %}', - onSuccess: function(response) { - $(table).bootstrapTable('refresh'); - } - }); - } - }); - }); - - // Callback for editing lines - $(table).find('.button-edit').click(function() { - var pk = $(this).attr('pk'); - - constructForm(`/api/order/po-extra-line/${pk}/`, { - fields: extraLineFields(), - title: '{% trans "Edit Line" %}', - onSuccess: reloadTable, - }); - }); - - // Callback for deleting lines - $(table).find('.button-delete').click(function() { - var pk = $(this).attr('pk'); - - constructForm(`/api/order/po-extra-line/${pk}/`, { - method: 'DELETE', - title: '{% trans "Delete Line" %}', - onSuccess: reloadTable, - }); - }); - } - - $(table).inventreeTable({ - onPostBody: setupCallbacks, - name: 'purchaseorderextraline', - sidePagination: 'client', - formatNoMatches: function() { - return '{% trans "No matching line" %}'; - }, - queryParams: filters, - original: options.params, - url: options.url, - showFooter: true, - uniqueId: 'pk', - detailViewByClick: false, - columns: columns, - }); -} - - -/* - * Load table displaying list of sales orders - */ -function loadSalesOrderTable(table, options) { - - // Ensure the table starts in a known state - $(table).bootstrapTable('destroy'); - - options.params = options.params || {}; - options.params['customer_detail'] = true; - - var filters = loadTableFilters('salesorder'); - - for (var key in options.params) { - filters[key] = options.params[key]; - } - - options.url = options.url || '{% url "api-so-list" %}'; - - var target = '#filter-list-salesorder'; - - setupFilterList('salesorder', $(table), target, {download: true}); - - var display_mode = inventreeLoad('salesorder-table-display-mode', 'list'); - - function buildEvents(calendar) { - - var start = startDate(calendar); - var end = endDate(calendar); - - clearEvents(calendar); - - // Extract current filters from table - var table_options = $(table).bootstrapTable('getOptions'); - var filters = table_options.query_params || {}; - - filters.customer_detail = true; - filters.min_date = start; - filters.max_date = end; - - // Request orders from the server within specified date range - inventreeGet( - '{% url "api-so-list" %}', - filters, - { - success: function(response) { - - for (var idx = 0; idx < response.length; idx++) { - var order = response[idx]; - - var date = order.creation_date; - - if (order.shipment_date) { - date = order.shipment_date; - } else if (order.target_date) { - date = order.target_date; - } - - var title = `${order.reference} - ${order.customer_detail.name}`; - - // Default color is blue - var color = '#4c68f5'; - - // Overdue orders are red - if (order.overdue) { - color = '#c22525'; - } else if (order.status == {{ SalesOrderStatus.SHIPPED }}) { - color = '#25c235'; - } - - var event = { - title: title, - start: date, - end: date, - url: `/order/sales-order/${order.pk}/`, - backgroundColor: color, - }; - - calendar.addEvent(event); - } - } - } - ); - } - - $(table).inventreeTable({ - url: options.url, - queryParams: filters, - name: 'salesorder', - groupBy: false, - sidePagination: 'server', - original: options.params, - showColums: display_mode != 'calendar', - search: display_mode != 'calendar', - showCustomViewButton: false, - showCustomView: display_mode == 'calendar', - disablePagination: display_mode == 'calendar', - formatNoMatches: function() { - return '{% trans "No sales orders found" %}'; - }, - buttons: constructOrderTableButtons({ - prefix: 'salesorder', - disableTreeView: true, - callback: function() { - // Reload the entire table - loadSalesOrderTable(table, options); - }, - }), - customView: function(data) { - return `
    `; - }, - onRefresh: function() { - loadSalesOrderTable(table, options); - }, - onLoadSuccess: function() { - - if (display_mode == 'calendar') { - var el = document.getElementById('purchase-order-calendar'); - - calendar = new FullCalendar.Calendar(el, { - initialView: 'dayGridMonth', - nowIndicator: true, - aspectRatio: 2.5, - locale: options.locale, - datesSet: function() { - buildEvents(calendar); - } - }); - - calendar.render(); - } - }, - columns: [ - { - title: '', - checkbox: true, - visible: true, - switchable: false, - }, - { - sortable: true, - field: 'reference', - title: '{% trans "Sales Order" %}', - formatter: function(value, row) { - var html = renderLink(value, `/order/sales-order/${row.pk}/`); - - if (row.overdue) { - html += makeIconBadge('fa-calendar-times icon-red', '{% trans "Order is overdue" %}'); - } - - return html; - }, - }, - { - sortable: true, - sortName: 'customer__name', - field: 'customer_detail', - title: '{% trans "Customer" %}', - formatter: function(value, row) { - - if (!row.customer_detail) { - return '{% trans "Invalid Customer" %}'; - } - - return imageHoverIcon(row.customer_detail.image) + renderLink(row.customer_detail.name, `/company/${row.customer}/sales-orders/`); - } - }, - { - sortable: true, - field: 'customer_reference', - title: '{% trans "Customer Reference" %}', - }, - { - sortable: false, - field: 'description', - title: '{% trans "Description" %}', - }, - { - sortable: true, - field: 'status', - title: '{% trans "Status" %}', - formatter: function(value, row) { - return salesOrderStatusDisplay(row.status); - } - }, - { - sortable: true, - field: 'creation_date', - title: '{% trans "Creation Date" %}', - formatter: function(value) { - return renderDate(value); - } - }, - { - sortable: true, - field: 'target_date', - title: '{% trans "Target Date" %}', - formatter: function(value) { - return renderDate(value); - } - }, - { - sortable: true, - field: 'shipment_date', - title: '{% trans "Shipment Date" %}', - formatter: function(value) { - return renderDate(value); - } - }, - { - sortable: true, - field: 'line_items', - title: '{% trans "Items" %}' - }, - { - field: 'total_price', - title: '{% trans "Total Cost" %}', - switchable: true, - sortable: true, - formatter: function(value, row) { - return formatCurrency(value, { - currency: row.total_price_currency, - }); - } - } - ], - }); -} - - -/* - * Load a table displaying Shipment information against a particular order - */ -function loadSalesOrderShipmentTable(table, options={}) { - - options.table = table; - - options.params = options.params || {}; - - // Filter by order - options.params.order = options.order; - - // Filter by "shipped" status - options.params.shipped = options.shipped || false; - - var filters = loadTableFilters('salesordershipment'); - - for (var key in options.params) { - filters[key] = options.params[key]; - } - - setupFilterList('salesordershipment', $(table), options.filter_target); - - // Add callbacks for expand / collapse buttons - var prefix = options.shipped ? 'completed' : 'pending'; - - $(`#${prefix}-shipments-expand`).click(function() { - $(table).bootstrapTable('expandAllRows'); - }); - - $(`#${prefix}-shipments-collapse`).click(function() { - $(table).bootstrapTable('collapseAllRows'); - }); - - function makeShipmentActions(row) { - // Construct "actions" for the given shipment row - var pk = row.pk; - - var html = `
    `; - - html += makeIconButton('fa-edit icon-blue', 'button-shipment-edit', pk, '{% trans "Edit shipment" %}'); - - if (!options.shipped) { - html += makeIconButton('fa-truck icon-green', 'button-shipment-ship', pk, '{% trans "Complete shipment" %}'); - } - - var enable_delete = row.allocations && row.allocations.length == 0; - - html += makeIconButton('fa-trash-alt icon-red', 'button-shipment-delete', pk, '{% trans "Delete shipment" %}', {disabled: !enable_delete}); - - html += `
    `; - - return html; - - } - - function setupShipmentCallbacks() { - // Setup action button callbacks - - $(table).find('.button-shipment-edit').click(function() { - var pk = $(this).attr('pk'); - - var fields = salesOrderShipmentFields(); - - delete fields.order; - - constructForm(`/api/order/so/shipment/${pk}/`, { - fields: fields, - title: '{% trans "Edit Shipment" %}', - onSuccess: function() { - $(table).bootstrapTable('refresh'); - } - }); - }); - - $(table).find('.button-shipment-ship').click(function() { - var pk = $(this).attr('pk'); - - completeShipment(pk); - }); - - $(table).find('.button-shipment-delete').click(function() { - var pk = $(this).attr('pk'); - - constructForm(`/api/order/so/shipment/${pk}/`, { - title: '{% trans "Delete Shipment" %}', - method: 'DELETE', - onSuccess: function() { - $(table).bootstrapTable('refresh'); - } - }); - }); - } - - $(table).inventreeTable({ - url: '{% url "api-so-shipment-list" %}', - queryParams: filters, - original: options.params, - name: options.name || 'salesordershipment', - search: false, - paginationVAlign: 'bottom', - showColumns: true, - detailView: true, - detailViewByClick: false, - buttons: constructExpandCollapseButtons(table), - detailFilter: function(index, row) { - return row.allocations.length > 0; - }, - detailFormatter: function(index, row, element) { - return showAllocationSubTable(index, row, element, options); - }, - onPostBody: function() { - setupShipmentCallbacks(); - - // Auto-expand rows on the "pending" table - if (!options.shipped) { - $(table).bootstrapTable('expandAllRows'); - } - }, - formatNoMatches: function() { - return '{% trans "No matching shipments found" %}'; - }, - columns: [ - { - visible: false, - checkbox: true, - switchable: false, - }, - { - field: 'reference', - title: '{% trans "Shipment Reference" %}', - switchable: false, - }, - { - field: 'allocations', - title: '{% trans "Items" %}', - switchable: false, - sortable: true, - formatter: function(value, row) { - if (row && row.allocations) { - return row.allocations.length; - } else { - return '-'; - } - } - }, - { - field: 'shipment_date', - title: '{% trans "Shipment Date" %}', - sortable: true, - formatter: function(value, row) { - if (value) { - return renderDate(value); - } else { - return '{% trans "Not shipped" %}'; - } - } - }, - { - field: 'tracking_number', - title: '{% trans "Tracking" %}', - }, - { - field: 'invoice_number', - title: '{% trans "Invoice" %}', - }, - { - field: 'link', - title: '{% trans "Link" %}', - formatter: function(value) { - if (value) { - return renderLink(value, value); - } else { - return '-'; - } - } - }, - { - field: 'notes', - title: '{% trans "Notes" %}', - visible: false, - switchable: false, - // TODO: Implement 'notes' field - }, - { - title: '', - switchable: false, - formatter: function(value, row) { - return makeShipmentActions(row); - } - } - ], - }); -} - - -/** - * Allocate stock items against a SalesOrder - * - * arguments: - * - order_id: The ID / PK value for the SalesOrder - * - lines: A list of SalesOrderLineItem objects to be allocated - * - * options: - * - source_location: ID / PK of the top-level StockLocation to source stock from (or null) - */ -function allocateStockToSalesOrder(order_id, line_items, options={}) { - - function renderLineItemRow(line_item, quantity) { - // Function to render a single line_item row - - var pk = line_item.pk; - - var part = line_item.part_detail; - - var thumb = thumbnailImage(part.thumbnail || part.image); - - var delete_button = `
    `; - - delete_button += makeIconButton( - 'fa-times icon-red', - 'button-row-remove', - pk, - '{% trans "Remove row" %}', - ); - - delete_button += '
    '; - - var quantity_input = constructField( - `items_quantity_${pk}`, - { - type: 'decimal', - min_value: 0, - value: quantity || 0, - title: '{% trans "Specify stock allocation quantity" %}', - required: true, - }, - { - hideLabels: true, - } - ); - - var stock_input = constructField( - `items_stock_item_${pk}`, - { - type: 'related field', - required: 'true', - }, - { - hideLabels: true, - } - ); - - var html = ` - - - ${thumb} ${part.full_name} - - - ${stock_input} - - - ${quantity_input} - - - - - {% trans "Part" %} - {% trans "Stock Item" %} - {% trans "Quantity" %} - - - - ${table_entries} - - `; - - constructForm(`/api/order/so/${order_id}/allocate/`, { - method: 'POST', - fields: { - shipment: { - filters: { - order: order_id, - shipped: false, - }, - value: options.shipment || null, - auto_fill: true, - secondary: { - method: 'POST', - title: '{% trans "Add Shipment" %}', - fields: function() { - var ref = null; - - // TODO: Refactor code for getting next shipment number - inventreeGet( - '{% url "api-so-shipment-list" %}', - { - order: options.order, - }, - { - async: false, - success: function(results) { - // "predict" the next reference number - ref = results.length + 1; - - var found = false; - - while (!found) { - - var no_match = true; - - for (var ii = 0; ii < results.length; ii++) { - if (ref.toString() == results[ii].reference.toString()) { - no_match = false; - break; - } - } - - if (no_match) { - break; - } else { - ref++; - } - } - } - } - ); - - var fields = salesOrderShipmentFields(options); - - fields.reference.value = ref; - fields.reference.prefix = options.reference; - - return fields; - } - } - } - }, - preFormContent: html, - confirm: true, - confirmMessage: '{% trans "Confirm stock allocation" %}', - title: '{% trans "Allocate Stock Items to Sales Order" %}', - afterRender: function(fields, opts) { - - // Initialize source location field - var take_from_field = { - name: 'take_from', - model: 'stocklocation', - api_url: '{% url "api-location-list" %}', - required: false, - type: 'related field', - value: options.source_location || null, - noResults: function(query) { - return '{% trans "No matching stock locations" %}'; - }, - }; - - initializeRelatedField( - take_from_field, - null, - opts - ); - - // Add callback to "clear" button for take_from field - addClearCallback( - 'take_from', - take_from_field, - opts, - ); - - // Initialize fields for each line item - line_items.forEach(function(line_item) { - var pk = line_item.pk; - - initializeRelatedField( - { - name: `items_stock_item_${pk}`, - api_url: '{% url "api-stock-list" %}', - filters: { - part: line_item.part, - in_stock: true, - part_detail: true, - location_detail: true, - available: true, - }, - model: 'stockitem', - required: true, - render_part_detail: true, - render_location_detail: true, - auto_fill: true, - onSelect: function(data, field, opts) { - // Adjust the 'quantity' field based on availability - - if (!('quantity' in data)) { - return; - } - - // Calculate the available quantity - var available = Math.max((data.quantity || 0) - (data.allocated || 0), 0); - - // Remaining quantity to be allocated? - var remaining = Math.max(line_item.quantity - line_item.shipped - line_item.allocated, 0); - - // Maximum amount that we need - var desired = Math.min(available, remaining); - - updateFieldValue(`items_quantity_${pk}`, desired, {}, opts); - - }, - adjustFilters: function(filters) { - // Restrict query to the selected location - var location = getFormFieldValue( - 'take_from', - {}, - { - modal: opts.modal, - } - ); - - filters.location = location; - filters.cascade = true; - - // Exclude expired stock? - if (global_settings.STOCK_ENABLE_EXPIRY && !global_settings.STOCK_ALLOW_EXPIRED_SALE) { - filters.expired = false; - } - - return filters; - }, - noResults: function(query) { - return '{% trans "No matching stock items" %}'; - } - }, - null, - opts - ); - }); - - // Add remove-row button callbacks - $(opts.modal).find('.button-row-remove').click(function() { - var pk = $(this).attr('pk'); - - $(opts.modal).find(`#allocation_row_${pk}`).remove(); - }); - }, - onSubmit: function(fields, opts) { - // Extract data elements from the form - var data = { - items: [], - shipment: getFormFieldValue( - 'shipment', - {}, - opts - ) - }; - - var item_pk_values = []; - - line_items.forEach(function(item) { - - var pk = item.pk; - - var quantity = getFormFieldValue( - `items_quantity_${pk}`, - {}, - opts - ); - - var stock_item = getFormFieldValue( - `items_stock_item_${pk}`, - {}, - opts - ); - - if (quantity != null) { - data.items.push({ - line_item: pk, - stock_item: stock_item, - quantity: quantity, - }); - - item_pk_values.push(pk); - } - }); - - // Provide nested values - opts.nested = { - 'items': item_pk_values - }; - - inventreePut( - opts.url, - data, - { - method: 'POST', - success: function(response) { - $(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 loadSalesOrderAllocationTable(table, options={}) { - /** - * Load a table with SalesOrderAllocation items - */ - - options.params = options.params || {}; - - options.params['location_detail'] = true; - options.params['part_detail'] = true; - options.params['item_detail'] = true; - options.params['order_detail'] = true; - - var filters = loadTableFilters('salesorderallocation'); - - for (var key in options.params) { - filters[key] = options.params[key]; - } - - setupFilterList('salesorderallocation', $(table)); - - $(table).inventreeTable({ - url: '{% url "api-so-allocation-list" %}', - queryParams: filters, - name: options.name || 'salesorderallocation', - groupBy: false, - search: false, - paginationVAlign: 'bottom', - original: options.params, - formatNoMatches: function() { - return '{% trans "No sales order allocations found" %}'; - }, - columns: [ - { - field: 'pk', - visible: false, - switchable: false, - }, - { - field: 'order', - switchable: false, - title: '{% trans "Order" %}', - formatter: function(value, row) { - - var ref = `${row.order_detail.reference}`; - - return renderLink(ref, `/order/sales-order/${row.order}/`); - } - }, - { - field: 'item', - title: '{% trans "Stock Item" %}', - formatter: function(value, row) { - // Render a link to the particular stock item - - var link = `/stock/item/${row.item}/`; - var text = `{% trans "Stock Item" %} ${row.item}`; - - return renderLink(text, link); - } - }, - { - field: 'location', - title: '{% trans "Location" %}', - formatter: function(value, row) { - return locationDetail(row.item_detail, true); - } - }, - { - field: 'quantity', - title: '{% trans "Quantity" %}', - sortable: true, - }, - ] - }); -} - - -/** - * Display an "allocations" sub table, showing stock items allocated againt a sales order - * @param {*} index - * @param {*} row - * @param {*} element - */ -function showAllocationSubTable(index, row, element, options) { - - // Construct a sub-table element - var html = ` -
    -
    -
    `; - - element.html(html); - - var table = $(`#allocation-table-${row.pk}`); - - function setupCallbacks() { - // Add callbacks for 'edit' buttons - table.find('.button-allocation-edit').click(function() { - - var pk = $(this).attr('pk'); - - // Edit the sales order alloction - constructForm( - `/api/order/so-allocation/${pk}/`, - { - fields: { - quantity: {}, - }, - title: '{% trans "Edit Stock Allocation" %}', - onSuccess: function() { - // Refresh the parent table - $(options.table).bootstrapTable('refresh'); - }, - }, - ); - }); - - // Add callbacks for 'delete' buttons - table.find('.button-allocation-delete').click(function() { - var pk = $(this).attr('pk'); - - constructForm( - `/api/order/so-allocation/${pk}/`, - { - method: 'DELETE', - confirmMessage: '{% trans "Confirm Delete Operation" %}', - title: '{% trans "Delete Stock Allocation" %}', - onSuccess: function() { - // Refresh the parent table - $(options.table).bootstrapTable('refresh'); - } - } - ); - }); - } - - table.bootstrapTable({ - onPostBody: setupCallbacks, - data: row.allocations, - showHeader: true, - columns: [ - { - field: 'part_detail', - title: '{% trans "Part" %}', - formatter: function(part, row) { - return imageHoverIcon(part.thumbnail) + renderLink(part.full_name, `/part/${part.pk}/`); - } - }, - { - field: 'allocated', - title: '{% trans "Stock Item" %}', - formatter: function(value, row, index, field) { - var text = ''; - - var item = row.item_detail; - - var text = `{% trans "Quantity" %}: ${row.quantity}`; - - if (item && item.serial != null && row.quantity == 1) { - text = `{% trans "Serial Number" %}: ${item.serial}`; - } - - return renderLink(text, `/stock/item/${row.item}/`); - }, - }, - { - field: 'location', - title: '{% trans "Location" %}', - formatter: function(value, row, index, field) { - - if (row.shipment_date) { - return `{% trans "Shipped to customer" %} - ${row.shipment_date}`; - } else if (row.location) { - // Location specified - return renderLink( - row.location_detail.pathstring || '{% trans "Location" %}', - `/stock/location/${row.location}/` - ); - } else { - return `{% trans "Stock location not specified" %}`; - } - }, - }, - { - field: 'buttons', - title: '', - formatter: function(value, row, index, field) { - - var html = `
    `; - var pk = row.pk; - - if (row.shipment_date) { - html += `{% trans "Shipped" %}`; - } else { - html += makeIconButton('fa-edit icon-blue', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}'); - html += makeIconButton('fa-trash-alt icon-red', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}'); - } - - html += '
    '; - - return html; - }, - }, - ], - }); -} - -/** - * Display a "fulfilled" sub table, showing stock items fulfilled against a purchase order - */ -function showFulfilledSubTable(index, row, element, options) { - // Construct a table showing stock items which have been fulfilled against this line item - - if (!options.order) { - return 'ERROR: Order ID not supplied'; - } - - var id = `fulfilled-table-${row.pk}`; - - var html = ` -
    - -
    -
    `; - - element.html(html); - - $(`#${id}`).bootstrapTable({ - url: '{% url "api-stock-list" %}', - queryParams: { - part: row.part, - sales_order: options.order, - location_detail: true, - }, - showHeader: true, - columns: [ - { - field: 'pk', - visible: false, - }, - { - field: 'stock', - title: '{% trans "Stock Item" %}', - 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}/`); - }, - }, - { - field: 'location', - title: '{% trans "Location" %}', - formatter: function(value, row) { - if (row.customer) { - return renderLink( - '{% trans "Shipped to customer" %}', - `/company/${row.customer}/` - ); - } else if (row.location && row.location_detail) { - return renderLink( - row.location_detail.pathstring, - `/stock/location/${row.location}`, - ); - } else { - return `{% trans "Stock location not specified" %}`; - } - } - } - ], - }); -} - var TotalPriceRef = ''; // reference to total price field var TotalPriceOptions = {}; // options to reload the price @@ -3951,726 +152,195 @@ function reloadTotal() { }; -/** - * Load a table displaying line items for a particular SalesOrder +/* + * Load a table displaying "extra" line items for a given order. + * Used for all external order types (e.g. PurchaseOrder / SalesOrder / ReturnOrder) * - * @param {String} table : HTML ID tag e.g. '#table' - * @param {Object} options : object which contains: - * - order {integer} : pk of the SalesOrder - * - status: {integer} : status code for the order + * options: + * - table: The DOM ID of the table element + * - order: The ID of the related order (required) + * - name: The unique 'name' for this table + * - url: The API URL for the extra line item model (required) + * - filtertarget: The DOM ID for the filter list element */ -function loadSalesOrderLineItemTable(table, options={}) { +function loadExtraLineTable(options={}) { - options.table = table; - - if (!options.pending && !global_settings.SALESORDER_EDIT_COMPLETED_ORDERS) { - options.allow_edit = false; - } + const table = options.table; options.params = options.params || {}; - if (!options.order) { - console.error('function called without order ID'); - return; - } - - if (!options.status) { - console.error('function called without order status'); - return; - } - + // Filtering options.params.order = options.order; - options.params.part_detail = true; - options.params.allocations = true; - var filters = loadTableFilters('salesorderlineitem'); + var filters = {}; - for (var key in options.params) { - filters[key] = options.params[key]; + if (options.name) { + filters = loadTableFilters(options.name); } - options.url = options.url || '{% url "api-so-line-list" %}'; - - var filter_target = options.filter_target || '#filter-list-sales-order-lines'; + Object.assign(filters, options.params); setupFilterList( - 'salesorderlineitem', + options.name, $(table), - filter_target, + options.filtertarget, { - download: true, + download: true } ); - // Is the order pending? - var pending = options.pending; - - // Has the order shipped? - var shipped = options.status == {{ SalesOrderStatus.SHIPPED }}; - - // Show detail view if the PurchaseOrder is PENDING or SHIPPED - var show_detail = pending || shipped; - - // Add callbacks for expand / collapse buttons - $('#sales-lines-expand').click(function() { - $(table).bootstrapTable('expandAllRows'); - }); - - $('#sales-lines-collapse').click(function() { - $(table).bootstrapTable('collapseAllRows'); - }); - - // Table columns to display - var columns = [ - /* - { - checkbox: true, - visible: true, - switchable: false, - }, - */ - { - sortable: true, - sortName: 'part_detail.name', - field: 'part', - title: '{% trans "Part" %}', - switchable: false, - 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 '-'; - } - }, - footerFormatter: function() { - return '{% trans "Total" %}'; - }, - }, - { - sortable: true, - field: 'reference', - title: '{% trans "Reference" %}', - switchable: true, - }, - { - sortable: true, - field: 'quantity', - title: '{% trans "Quantity" %}', - footerFormatter: function(data) { - return data.map(function(row) { - return +row['quantity']; - }).reduce(function(sum, i) { - return sum + i; - }, 0); - }, - switchable: false, - }, - { - sortable: true, - field: 'sale_price', - title: '{% trans "Unit Price" %}', - formatter: function(value, row) { - return formatCurrency(row.sale_price, { - currency: row.sale_price_currency - }); - } - }, - { - field: 'total_price', - sortable: true, - title: '{% trans "Total Price" %}', - formatter: function(value, row) { - return formatCurrency(row.sale_price * row.quantity, { - currency: row.sale_price_currency, - }); - }, - footerFormatter: function(data) { - return calculateTotalPrice( - data, - function(row) { - return row.sale_price ? row.sale_price * row.quantity : null; - }, - function(row) { - return row.sale_price_currency; - } - ); - } - }, - { - field: 'target_date', - title: '{% trans "Target Date" %}', - sortable: true, - switchable: true, - formatter: function(value, row) { - if (row.target_date) { - var html = renderDate(row.target_date); - - if (row.overdue) { - html += ``; - } - - return html; - - } else if (row.order_detail && row.order_detail.target_date) { - return `${renderDate(row.order_detail.target_date)}`; - } else { - return '-'; - } - } - } - ]; - - if (pending) { - columns.push( - { - field: 'stock', - title: '{% trans "Available Stock" %}', - formatter: function(value, row) { - var available = row.available_stock; - var required = Math.max(row.quantity - row.allocated - row.shipped, 0); - - var html = ''; - - if (available > 0) { - var url = `/part/${row.part}/?display=part-stock`; - - var text = available; - - html = renderLink(text, url); - } else { - html += `{% trans "No Stock Available" %}`; - } - - if (required > 0) { - if (available >= required) { - html += ``; - } else { - html += ``; - } - } - - return html; - }, - }, - ); - - columns.push( - { - field: 'allocated', - title: '{% trans "Allocated" %}', - switchable: false, - sortable: true, - formatter: function(value, row, index, field) { - return makeProgressBar(row.allocated, row.quantity, { - id: `order-line-progress-${row.pk}`, - }); - }, - sorter: function(valA, valB, rowA, rowB) { - - var A = rowA.allocated; - var B = rowB.allocated; - - 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; - } - }, - ); - } - - columns.push({ - field: 'shipped', - title: '{% trans "Shipped" %}', - switchable: false, - sortable: true, - formatter: function(value, row) { - return makeProgressBar(row.shipped, row.quantity, { - id: `order-line-shipped-${row.pk}` - }); - }, - sorter: function(valA, valB, rowA, rowB) { - var A = rowA.shipped; - var B = rowB.shipped; - - 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; - } - }); - - columns.push({ - field: 'notes', - title: '{% trans "Notes" %}', - }); - - if (pending) { - columns.push({ - field: 'buttons', - switchable: false, - formatter: function(value, row, index, field) { - - var html = `
    `; - - var pk = row.pk; - - if (row.part) { - var part = row.part_detail; - - if (part.trackable) { - html += makeIconButton('fa-hashtag icon-green', 'button-add-by-sn', pk, '{% trans "Allocate serial numbers" %}'); - } - - html += makeIconButton('fa-sign-in-alt icon-green', 'button-add', pk, '{% trans "Allocate stock" %}'); - - if (part.purchaseable) { - html += makeIconButton('fa-shopping-cart', 'button-buy', row.part, '{% trans "Purchase stock" %}'); - } - - if (part.assembly) { - html += makeIconButton('fa-tools', 'button-build', row.part, '{% trans "Build stock" %}'); - } - - html += makeIconButton('fa-dollar-sign icon-green', 'button-price', pk, '{% trans "Calculate price" %}'); - } - - html += makeIconButton('fa-clone', 'button-duplicate', pk, '{% trans "Duplicate line item" %}'); - html += makeIconButton('fa-edit icon-blue', 'button-edit', pk, '{% trans "Edit line item" %}'); - - var delete_disabled = false; - - var title = '{% trans "Delete line item" %}'; - - if (!!row.shipped) { - delete_disabled = true; - title = '{% trans "Cannot be deleted as items have been shipped" %}'; - } else if (!!row.allocated) { - delete_disabled = true; - title = '{% trans "Cannot be deleted as items have been allocated" %}'; - } - - // Prevent deletion of the line item if items have been allocated or shipped! - html += makeIconButton('fa-trash-alt icon-red', 'button-delete', pk, title, {disabled: delete_disabled}); - - html += `
    `; - - return html; - } - }); - } - - function reloadTable() { + // Helper function to reload table + function reloadExtraLineTable() { $(table).bootstrapTable('refresh'); - reloadTotal(); + + if (options.pricing) { + reloadTotal(); + } } // Configure callback functions once the table is loaded function setupCallbacks() { - // Callback for duplicating line items - $(table).find('.button-duplicate').click(function() { - var pk = $(this).attr('pk'); + if (options.allow_edit) { - inventreeGet(`/api/order/so-line/${pk}/`, {}, { - success: function(data) { + // Callback to duplicate line item + $(table).find('.button-duplicate').click(function() { + var pk = $(this).attr('pk'); - let fields = soLineItemFields(); + inventreeGet(`${options.url}${pk}/`, {}, { + success: function(data) { - constructForm('{% url "api-so-line-list" %}', { - method: 'POST', - fields: fields, - data: data, - title: '{% trans "Duplicate Line Item" %}', - onSuccess: function(response) { - $(table).bootstrapTable('refresh'); - } - }); - } - }); - }); + var fields = extraLineFields(); - // Callback for editing line items - $(table).find('.button-edit').click(function() { - var pk = $(this).attr('pk'); - - constructForm(`/api/order/so-line/${pk}/`, { - fields: soLineItemFields(), - title: '{% trans "Edit Line Item" %}', - onSuccess: reloadTable, - }); - }); - - // Callback for deleting line items - $(table).find('.button-delete').click(function() { - var pk = $(this).attr('pk'); - - constructForm(`/api/order/so-line/${pk}/`, { - method: 'DELETE', - title: '{% trans "Delete Line Item" %}', - onSuccess: reloadTable, - }); - }); - - // Callback for allocating stock items by serial number - $(table).find('.button-add-by-sn').click(function() { - var pk = $(this).attr('pk'); - - inventreeGet(`/api/order/so-line/${pk}/`, {}, - { - success: function(response) { - - constructForm(`/api/order/so/${options.order}/allocate-serials/`, { + constructForm(options.url, { method: 'POST', - title: '{% trans "Allocate Serial Numbers" %}', - fields: { - line_item: { - value: pk, - hidden: true, - }, - quantity: {}, - serial_numbers: {}, - shipment: { - filters: { - order: options.order, - shipped: false, - }, - auto_fill: true, - } - }, - onSuccess: function() { - $(table).bootstrapTable('refresh'); - } + fields: fields, + data: data, + title: '{% trans "Duplicate Line" %}', + onSuccess: reloadExtraLineTable, }); } - } - ); - }); - - // Callback for allocation stock items to the order - $(table).find('.button-add').click(function() { - var pk = $(this).attr('pk'); - - var line_item = $(table).bootstrapTable('getRowByUniqueId', pk); - - allocateStockToSalesOrder( - options.order, - [ - line_item - ], - { - order: options.order, - reference: options.reference, - success: function() { - // Reload this table - $(table).bootstrapTable('refresh'); - - // Reload the pending shipment table - $('#pending-shipments-table').bootstrapTable('refresh'); - } - } - ); - }); - - // Callback for creating a new build - $(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; - } - - // Create a new build order - newBuildOrder({ - part: pk, - sales_order: options.order, - quantity: quantity, - success: reloadTable + }); }); - }); - // Callback for purchasing parts - $(table).find('.button-buy').click(function() { - var pk = $(this).attr('pk'); + // Callback to edit line item + // Callback for editing lines + $(table).find('.button-edit').click(function() { + var pk = $(this).attr('pk'); - inventreeGet( - `/api/part/${pk}/`, - {}, - { - success: function(part) { - orderParts( - [part], - {} - ); - } - } - ); - }); + constructForm(`${options.url}${pk}/`, { + fields: extraLineFields(), + title: '{% trans "Edit Line" %}', + onSuccess: reloadExtraLineTable, + }); + }); + } - // Callback for displaying price - $(table).find('.button-price').click(function() { - var pk = $(this).attr('pk'); - var idx = $(this).closest('tr').attr('data-index'); - var row = $(table).bootstrapTable('getData')[idx]; + if (options.allow_delete) { + // Callback for deleting lines + $(table).find('.button-delete').click(function() { + var pk = $(this).attr('pk'); - launchModalForm( - '{% url "line-pricing" %}', - { - submit_text: '{% trans "Calculate price" %}', - data: { - line_item: pk, - quantity: row.quantity, - }, - buttons: [ - { - name: 'update_price', - title: '{% trans "Update Unit Price" %}' - }, - ], - success: reloadTable, - } - ); - }); + constructForm(`${options.url}${pk}/`, { + method: 'DELETE', + title: '{% trans "Delete Line" %}', + onSuccess: reloadExtraLineTable, + }); + }); + } } $(table).inventreeTable({ + url: options.url, + name: options.name, + sidePagination: 'server', onPostBody: setupCallbacks, - name: 'salesorderlineitems', - sidePagination: 'client', formatNoMatches: function() { - return '{% trans "No matching line items" %}'; + return '{% trans "No line items found" %}'; }, queryParams: filters, original: options.params, - url: options.url, showFooter: true, uniqueId: 'pk', - detailView: show_detail, - detailViewByClick: false, - buttons: constructExpandCollapseButtons(table), - detailFilter: function(index, row) { - if (pending) { - // Order is pending - return row.allocated > 0; - } else { - return row.shipped > 0; - } - }, - detailFormatter: function(index, row, element) { - if (pending) { - return showAllocationSubTable(index, row, element, options); - } else { - return showFulfilledSubTable(index, row, element, options); - } - }, - columns: columns, - }); -} - - -/** - * Load a table displaying lines for a particular SalesOrder - * - * @param {String} table : HTML ID tag e.g. '#table' - * @param {Object} options : object which contains: - * - order {integer} : pk of the SalesOrder - * - status: {integer} : status code for the order - */ -function loadSalesOrderExtraLineTable(table, options={}) { - - options.table = table; - - if (!options.pending && !global_settings.SALESORDER_EDIT_COMPLETED_ORDERS) { - options.allow_edit = false; - } - - options.params = options.params || {}; - - if (!options.order) { - console.error('function called without order ID'); - return; - } - - if (!options.status) { - console.error('function called without order status'); - return; - } - - options.params.order = options.order; - options.params.part_detail = true; - options.params.allocations = true; - - var filters = loadTableFilters('salesorderextraline'); - - for (var key in options.params) { - filters[key] = options.params[key]; - } - - options.url = options.url || '{% url "api-so-extra-line-list" %}'; - - var filter_target = options.filter_target || '#filter-list-sales-order-extra-lines'; - - setupFilterList('salesorderextraline', $(table), filter_target, {download: true}); - - // Table columns to display - var columns = [ - { - sortable: true, - field: 'reference', - title: '{% trans "Reference" %}', - switchable: true, - }, - { - sortable: true, - field: 'quantity', - title: '{% trans "Quantity" %}', - footerFormatter: function(data) { - return data.map(function(row) { - return +row['quantity']; - }).reduce(function(sum, i) { - return sum + i; - }, 0); + columns: [ + { + sortable: true, + field: 'reference', + title: '{% trans "Reference" %}', + switchable: false, }, - switchable: false, - }, - { - sortable: true, - field: 'price', - title: '{% trans "Unit Price" %}', - formatter: function(value, row) { - return formatCurrency(row.price, { - currency: row.price_currency, - }); - } - }, - { - field: 'total_price', - sortable: true, - title: '{% trans "Total Price" %}', - formatter: function(value, row) { - return formatCurrency(row.price * row.quantity, { - currency: row.price_currency, - }); + { + sortable: true, + switchable: false, + field: 'quantity', + title: '{% trans "Quantity" %}', + footerFormatter: function(data) { + return data.map(function(row) { + return +row['quantity']; + }).reduce(function(sum, i) { + return sum + i; + }, 0); + }, }, - footerFormatter: function(data) { - return calculateTotalPrice( - data, - function(row) { - return row.price ? row.price * row.quantity : null; - }, - function(row) { - return row.price_currency; - } - ); - } - } - ]; - - columns.push({ - field: 'notes', - title: '{% trans "Notes" %}', - }); - - columns.push({ - field: 'buttons', - switchable: false, - formatter: function(value, row, index, field) { - - var html = `
    `; - - if (options.allow_edit) { - var pk = row.pk; - html += makeIconButton('fa-clone', 'button-duplicate', pk, '{% trans "Duplicate line" %}'); - html += makeIconButton('fa-edit icon-blue', 'button-edit', pk, '{% trans "Edit line" %}'); - html += makeIconButton('fa-trash-alt icon-red', 'button-delete', pk, '{% trans "Delete line" %}', ); - } - - html += `
    `; - return html; - } - }); - - function reloadTable() { - $(table).bootstrapTable('refresh'); - reloadTotal(); - } - - // Configure callback functions once the table is loaded - function setupCallbacks() { - - // Callback for duplicating lines - $(table).find('.button-duplicate').click(function() { - var pk = $(this).attr('pk'); - - inventreeGet(`/api/order/so-extra-line/${pk}/`, {}, { - success: function(data) { - - var fields = extraLineFields(); - - constructForm('{% url "api-so-extra-line-list" %}', { - method: 'POST', - fields: fields, - data: data, - title: '{% trans "Duplicate Line" %}', - onSuccess: function(response) { - $(table).bootstrapTable('refresh'); - } + { + sortable: true, + field: 'price', + title: '{% trans "Unit Price" %}', + formatter: function(value, row) { + return formatCurrency(row.price, { + currency: row.price_currency, }); } - }); - }); + }, + { + field: 'total_price', + sortable: true, + switchable: true, + title: '{% trans "Total Price" %}', + formatter: function(value, row) { + return formatCurrency(row.price * row.quantity, { + currency: row.price_currency, + }); + }, + footerFormatter: function(data) { + return calculateTotalPrice( + data, + function(row) { + return row.price ? row.price * row.quantity : null; + }, + function(row) { + return row.price_currency; + } + ); + } + }, + { + field: 'notes', + title: '{% trans "Notes" %}', + }, + { + field: 'buttons', + switchable: false, + formatter: function(value, row, index, field) { - // Callback for editing lines - $(table).find('.button-edit').click(function() { - var pk = $(this).attr('pk'); + let html = ''; - constructForm(`/api/order/so-extra-line/${pk}/`, { - fields: extraLineFields(), - title: '{% trans "Edit Line" %}', - onSuccess: reloadTable, - }); - }); + if (options.allow_edit || options.allow_delete) { + var pk = row.pk; - // Callback for deleting lines - $(table).find('.button-delete').click(function() { - var pk = $(this).attr('pk'); + if (options.allow_edit) { + html += makeCopyButton('button-duplicate', pk, '{% trans "Duplicate line" %}'); + html += makeEditButton('button-edit', pk, '{% trans "Edit line" %}'); + } - constructForm(`/api/order/so-extra-line/${pk}/`, { - method: 'DELETE', - title: '{% trans "Delete Line" %}', - onSuccess: reloadTable, - }); - }); - } + if (options.allow_delete) { + html += makeDeleteButton('button-delete', pk, '{% trans "Delete line" %}', ); + } + } - $(table).inventreeTable({ - onPostBody: setupCallbacks, - name: 'salesorderextraline', - sidePagination: 'client', - formatNoMatches: function() { - return '{% trans "No matching lines" %}'; - }, - queryParams: filters, - original: options.params, - url: options.url, - showFooter: true, - uniqueId: 'pk', - detailViewByClick: false, - columns: columns, + return wrapButtons(html); + } + }, + ] }); } diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index eea9c770cc..30ac5400cd 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -12,7 +12,6 @@ loadTableFilters, makeIconBadge, makeIconButton, - printPartLabels, renderLink, setFormGroupVisibility, setupFilterList, @@ -366,7 +365,7 @@ function createPart(options={}) { */ function editPart(pk) { - var url = `/api/part/${pk}/`; + var url = `{% url "api-part-list" %}${pk}/`; var fields = partFields({ edit: true @@ -397,7 +396,7 @@ function duplicatePart(pk, options={}) { } // First we need all the part information - inventreeGet(`/api/part/${pk}/`, {}, { + inventreeGet(`{% url "api-part-list" %}${pk}/`, {}, { success: function(data) { @@ -446,7 +445,7 @@ function duplicatePart(pk, options={}) { // Launch form to delete a part function deletePart(pk, options={}) { - inventreeGet(`/api/part/${pk}/`, {}, { + inventreeGet(`{% url "api-part-list" %}${pk}/`, {}, { success: function(part) { if (part.active) { showAlertDialog( @@ -473,7 +472,7 @@ function deletePart(pk, options={}) {
    `; constructForm( - `/api/part/${pk}/`, + `{% url "api-part-list" %}${pk}/`, { method: 'DELETE', title: '{% trans "Delete Part" %}', @@ -542,7 +541,7 @@ function validateBom(part_id, options={}) {
    `; - constructForm(`/api/part/${part_id}/bom-validate/`, { + constructForm(`{% url "api-part-list" %}${part_id}/bom-validate/`, { method: 'PUT', fields: { valid: {}, @@ -560,7 +559,7 @@ function validateBom(part_id, options={}) { /* Duplicate a BOM */ function duplicateBom(part_id, options={}) { - constructForm(`/api/part/${part_id}/bom-copy/`, { + constructForm(`{% url "api-part-list" %}${part_id}/bom-copy/`, { method: 'POST', fields: { part: { @@ -646,7 +645,7 @@ function partStockLabel(part, options={}) { var required_build_order_quantity = null; var required_sales_order_quantity = null; - inventreeGet(`/api/part/${part.pk}/requirements/`, {}, { + inventreeGet(`{% url "api-part-list" %}${part.pk}/requirements/`, {}, { async: false, success: function(response) { required_build_order_quantity = 0; @@ -953,11 +952,7 @@ function loadPartStocktakeTable(partId, options={}) { params.part = partId; - var filters = loadTableFilters('stocktake'); - - for (var key in params) { - filters[key] = params[key]; - } + var filters = loadTableFilters('stocktake', params); setupFilterList('stocktake', $(table), '#filter-list-partstocktake'); @@ -1024,19 +1019,17 @@ function loadPartStocktakeTable(partId, options={}) { switchable: false, sortable: false, formatter: function(value, row) { - var html = `
    `; + let html = ''; if (options.allow_edit) { - html += makeIconButton('fa-edit icon-blue', 'button-edit-stocktake', row.pk, '{% trans "Edit Stocktake Entry" %}'); + html += makeEditButton('button-edit-stocktake', row.pk, '{% trans "Edit Stocktake Entry" %}'); } if (options.allow_delete) { - html += makeIconButton('fa-trash-alt icon-red', 'button-delete-stocktake', row.pk, '{% trans "Delete Stocktake Entry" %}'); + html += makeDeleteButton('button-delete-stocktake', row.pk, '{% trans "Delete Stocktake Entry" %}'); } - html += `
    `; - - return html; + return wrapButtons(html); } } ], @@ -1045,7 +1038,7 @@ function loadPartStocktakeTable(partId, options={}) { $(table).find('.button-edit-stocktake').click(function() { var pk = $(this).attr('pk'); - constructForm(`/api/part/stocktake/${pk}/`, { + constructForm(`{% url "api-part-stocktake-list" %}${pk}/`, { fields: { item_count: {}, quantity: {}, @@ -1066,21 +1059,17 @@ function loadPartStocktakeTable(partId, options={}) { }, }, title: '{% trans "Edit Stocktake Entry" %}', - onSuccess: function() { - $(table).bootstrapTable('refresh'); - } + refreshTable: table, }); }); $(table).find('.button-delete-stocktake').click(function() { var pk = $(this).attr('pk'); - constructForm(`/api/part/stocktake/${pk}/`, { + constructForm(`{% url "api-part-stocktake-list" %}${pk}/`, { method: 'DELETE', title: '{% trans "Delete Stocktake Entry" %}', - onSuccess: function() { - $(table).bootstrapTable('refresh'); - } + refreshTable: table, }); }); } @@ -1088,7 +1077,8 @@ function loadPartStocktakeTable(partId, options={}) { } -/* Load part variant table +/* + * Load part variant table */ function loadPartVariantTable(table, partId, options={}) { @@ -1097,11 +1087,7 @@ function loadPartVariantTable(table, partId, options={}) { params.ancestor = partId; // Load filters - var filters = loadTableFilters('variants'); - - for (var key in params) { - filters[key] = params[key]; - } + var filters = loadTableFilters('variants', params); setupFilterList('variants', $(table)); @@ -1247,11 +1233,7 @@ function loadPartParameterTable(table, options) { var params = options.params || {}; // Load filters - var filters = loadTableFilters('part-parameters'); - - for (var key in params) { - filters[key] = params[key]; - } + var filters = loadTableFilters('part-parameters', params); var filterTarget = options.filterTarget || '#filter-list-parameters'; @@ -1302,16 +1284,13 @@ function loadPartParameterTable(table, options) { switchable: false, sortable: false, formatter: function(value, row) { - var pk = row.pk; + let pk = row.pk; + let html = ''; - var html = `
    `; + html += makeEditButton('button-parameter-edit', pk, '{% trans "Edit parameter" %}'); + html += makeDeleteButton('button-parameter-delete', pk, '{% trans "Delete parameter" %}'); - html += makeIconButton('fa-edit icon-blue', 'button-parameter-edit', pk, '{% trans "Edit parameter" %}'); - html += makeIconButton('fa-trash-alt icon-red', 'button-parameter-delete', pk, '{% trans "Delete parameter" %}'); - - html += `
    `; - - return html; + return wrapButtons(html); } } ], @@ -1320,26 +1299,22 @@ function loadPartParameterTable(table, options) { $(table).find('.button-parameter-edit').click(function() { var pk = $(this).attr('pk'); - constructForm(`/api/part/parameter/${pk}/`, { + constructForm(`{% url "api-part-parameter-list" %}${pk}/`, { fields: { data: {}, }, title: '{% trans "Edit Parameter" %}', - onSuccess: function() { - $(table).bootstrapTable('refresh'); - } + refreshTable: table, }); }); $(table).find('.button-parameter-delete').click(function() { var pk = $(this).attr('pk'); - constructForm(`/api/part/parameter/${pk}/`, { + constructForm(`{% url "api-part-parameter-list" %}${pk}/`, { method: 'DELETE', title: '{% trans "Delete Parameter" %}', - onSuccess: function() { - $(table).bootstrapTable('refresh'); - } + refreshTable: table, }); }); } @@ -1361,11 +1336,7 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) { options.params.part_detail = true; options.params.order_detail = true; - var filters = loadTableFilters('purchaseorderlineitem'); - - for (var key in options.params) { - filters[key] = options.params[key]; - } + var filters = loadTableFilters('purchaseorderlineitem', options.params); setupFilterList('purchaseorderlineitem', $(table), '#filter-list-partpurchaseorders'); @@ -1474,12 +1445,16 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) { field: 'quantity', title: '{% trans "Quantity" %}', formatter: function(value, row) { - var data = value; + let data = value; if (row.supplier_part_detail.pack_size != 1.0) { - var pack_size = row.supplier_part_detail.pack_size; - var total = value * pack_size; - data += ``; + let pack_size = row.supplier_part_detail.pack_size; + let total = value * pack_size; + + data += makeIconBadge( + 'fa-info-circle icon-blue', + `{% trans "Pack Quantity" %}: ${pack_size} - {% trans "Total Quantity" %}: ${total}` + ); } return data; @@ -1515,7 +1490,10 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) { } if (overdue) { - html += ``; + html += makeIconBadge( + 'fa-calendar-alt icon-red', + '{% trans "This line item is overdue" %}', + ); } return html; @@ -1557,13 +1535,12 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) { // Already recevied return `{% trans "Received" %}`; } else if (row.order_detail && row.order_detail.status == {{ PurchaseOrderStatus.PLACED }}) { - var html = `
    `; + let html = ''; var pk = row.pk; html += makeIconButton('fa-sign-in-alt', 'button-line-receive', pk, '{% trans "Receive line item" %}'); - html += `
    `; - return html; + return wrapButtons(html); } else { return ''; } @@ -1627,14 +1604,10 @@ function loadRelatedPartsTable(table, part_id, options={}) { title: '', switchable: false, formatter: function(value, row) { + let html = ''; + html += makeDeleteButton('button-related-delete', row.pk, '{% trans "Delete part relationship" %}'); - var html = `
    `; - - html += makeIconButton('fa-trash-alt icon-red', 'button-related-delete', row.pk, '{% trans "Delete part relationship" %}'); - - html += '
    '; - - return html; + return wrapButtons(html); } } ]; @@ -1652,12 +1625,10 @@ function loadRelatedPartsTable(table, part_id, options={}) { $(table).find('.button-related-delete').click(function() { var pk = $(this).attr('pk'); - constructForm(`/api/part/related/${pk}/`, { + constructForm(`{% url "api-part-related-list" %}${pk}/`, { method: 'DELETE', title: '{% trans "Delete Part Relationship" %}', - onSuccess: function() { - $(table).bootstrapTable('refresh'); - } + refreshTable: table, }); }); }, @@ -1833,17 +1804,15 @@ function loadPartTable(table, url, options={}) { var params = options.params || {}; - var filters = {}; + var filters = loadTableFilters('parts', options.params); - if (!options.disableFilters) { - filters = loadTableFilters('parts'); - } - - for (var key in params) { - filters[key] = params[key]; - } - - setupFilterList('parts', $(table), options.filterTarget, {download: true}); + setupFilterList('parts', $(table), options.filterTarget, { + download: true, + labels: { + url: '{% url "api-part-label-list" %}', + key: 'part', + } + }); var columns = [ { @@ -2153,7 +2122,7 @@ function loadPartTable(table, url, options={}) { var part = parts.shift(); inventreePut( - `/api/part/${part}/`, + `{% url "api-part-list" %}${part}/`, { category: category, }, @@ -2176,19 +2145,6 @@ function loadPartTable(table, url, options={}) { }, }); }); - - // Callback function for the "print label" button - $('#multi-part-print-label').click(function() { - var selections = getTableData(table); - - var items = []; - - selections.forEach(function(item) { - items.push(item.pk); - }); - - printPartLabels(items); - }); } @@ -2201,15 +2157,8 @@ function loadPartCategoryTable(table, options) { var filterListElement = options.filterList || '#filter-list-category'; - var filters = {}; - var filterKey = options.filterKey || options.name || 'category'; - if (!options.disableFilters) { - filters = loadTableFilters(filterKey); - } - - var tree_view = options.allowTreeView && inventreeLoad('category-tree-view') == 1; if (tree_view) { @@ -2217,12 +2166,7 @@ function loadPartCategoryTable(table, options) { params.depth = global_settings.INVENTREE_TREE_DEPTH; } - var original = {}; - - for (var key in params) { - original[key] = params[key]; - filters[key] = params[key]; - } + let filters = loadTableFilters(filterKey, params); setupFilterList(filterKey, table, filterListElement, {download: true}); @@ -2270,7 +2214,7 @@ function loadPartCategoryTable(table, options) { serverSort: !tree_view, search: !tree_view, name: 'category', - original: original, + original: params, showColumns: true, sortable: true, buttons: options.allowTreeView ? [ @@ -2461,13 +2405,7 @@ function loadPartTestTemplateTable(table, options) { var filterListElement = options.filterList || '#filter-list-parttests'; - var filters = loadTableFilters('parttests'); - - var original = {}; - - for (var k in params) { - original[k] = params[k]; - } + var filters = loadTableFilters('parttests', params); setupFilterList('parttests', table, filterListElement); @@ -2484,7 +2422,7 @@ function loadPartTestTemplateTable(table, options) { url: '{% url "api-part-test-template-list" %}', queryParams: filters, name: 'testtemplate', - original: original, + original: params, columns: [ { field: 'pk', @@ -2528,14 +2466,12 @@ function loadPartTestTemplateTable(table, options) { var pk = row.pk; if (row.part == part) { - var html = `
    `; + let html = ''; - html += makeIconButton('fa-edit icon-blue', 'button-test-edit', pk, '{% trans "Edit test result" %}'); - html += makeIconButton('fa-trash-alt icon-red', 'button-test-delete', pk, '{% trans "Delete test result" %}'); + html += makeEditButton('button-test-edit', pk, '{% trans "Edit test result" %}'); + html += makeDeleteButton('button-test-delete', pk, '{% trans "Delete test result" %}'); - html += `
    `; - - return html; + return wrapButtons(html); } else { var text = '{% trans "This test is defined for a parent part" %}'; @@ -2568,9 +2504,7 @@ function loadPartTestTemplateTable(table, options) { constructForm(url, { method: 'DELETE', title: '{% trans "Delete Test Result Template" %}', - onSuccess: function() { - table.bootstrapTable('refresh'); - }, + refreshTable: table, }); }); } @@ -2593,7 +2527,7 @@ function loadPartSchedulingChart(canvas_id, part_id) { var was_error = false; // First, grab updated data for the particular part - inventreeGet(`/api/part/${part_id}/`, {}, { + inventreeGet(`{% url "api-part-list" %}${part_id}/`, {}, { async: false, success: function(response) { part_info = response; @@ -2632,7 +2566,7 @@ function loadPartSchedulingChart(canvas_id, part_id) { * and arranged in increasing chronological order */ inventreeGet( - `/api/part/${part_id}/scheduling/`, + `{% url "api-part-list" %}${part_id}/scheduling/`, {}, { async: false, @@ -2649,15 +2583,15 @@ function loadPartSchedulingChart(canvas_id, part_id) { if (date == null) { date_string = '{% trans "No date specified" %}'; - date_string += ``; + date_string += makeIconBadge('fa-exclamation-circle icon-red', '{% trans "No date specified" %}'); } else if (date < today) { - date_string += ``; + date_string += makeIconBadge('fa-exclamation-circle icon-yellow', '{% trans "Specified date is in the past" %}'); } var quantity_string = entry.quantity + entry.speculative_quantity; if (entry.speculative_quantity != 0) { - quantity_string += ``; + quantity_string += makeIconBadge('fa-question-circle icon-blue', '{% trans "Speculative" %}'); } // Add an entry to the scheduling table diff --git a/InvenTree/templates/js/translated/pricing.js b/InvenTree/templates/js/translated/pricing.js index 6d693f8b8c..f5c8a4f794 100644 --- a/InvenTree/templates/js/translated/pricing.js +++ b/InvenTree/templates/js/translated/pricing.js @@ -603,14 +603,14 @@ function loadPriceBreakTable(table, options={}) { title: '{% trans "Price" %}', sortable: true, formatter: function(value, row) { - var html = formatCurrency(value, {currency: row.price_currency}); + let html = formatCurrency(value, {currency: row.price_currency}); - html += `
    `; + let buttons = ''; - html += makeIconButton('fa-edit icon-blue', `button-${name}-edit`, row.pk, `{% trans "Edit ${human_name}" %}`); - html += makeIconButton('fa-trash-alt icon-red', `button-${name}-delete`, row.pk, `{% trans "Delete ${human_name}" %}`); + buttons += makeEditButton(`button-${name}-edit`, row.pk, `{% trans "Edit ${human_name}" %}`); + buttons += makeDeleteButton(`button-${name}-delete`, row.pk, `{% trans "Delete ${human_name}" %}`); - html += `
    `; + html += wrapButtons(buttons); return html; } diff --git a/InvenTree/templates/js/translated/purchase_order.js b/InvenTree/templates/js/translated/purchase_order.js new file mode 100644 index 0000000000..2c1f555bd2 --- /dev/null +++ b/InvenTree/templates/js/translated/purchase_order.js @@ -0,0 +1,2068 @@ +{% load i18n %} +{% load inventree_extras %} + +/* globals + companyFormFields, + constructForm, + createSupplierPart, + global_settings, + imageHoverIcon, + inventreeGet, + launchModalForm, + loadTableFilters, + makeIconBadge, + purchaseOrderStatusDisplay, + receivePurchaseOrderItems, + renderLink, + setupFilterList, + supplierPartFields, +*/ + +/* exported + cancelPurchaseOrder, + completePurchaseOrder, + createPurchaseOrder, + createPurchaseOrderLineItem, + duplicatePurchaseOrder, + editPurchaseOrder, + editPurchaseOrderLineItem, + issuePurchaseOrder, + loadPurchaseOrderLineItemTable, + loadPurchaseOrderTable, + newPurchaseOrderFromOrderWizard, + newSupplierPartFromOrderWizard, + orderParts, + removeOrderRowFromOrderWizard, + removePurchaseOrderLineItem, +*/ + + + +/* + * Construct a set of fields for a purchase order form + */ +function purchaseOrderFields(options={}) { + + var fields = { + reference: { + icon: 'fa-hashtag', + }, + description: {}, + supplier: { + icon: 'fa-building', + secondary: { + title: '{% trans "Add Supplier" %}', + fields: function() { + var fields = companyFormFields(); + + fields.is_supplier.value = true; + + return fields; + } + } + }, + supplier_reference: {}, + target_date: { + icon: 'fa-calendar-alt', + }, + link: { + icon: 'fa-link', + }, + contact: { + icon: 'fa-user', + adjustFilters: function(filters) { + let supplier = getFormFieldValue('supplier', {}, {modal: options.modal}); + + if (supplier) { + filters.company = supplier; + } + + return filters; + } + }, + responsible: { + icon: 'fa-user', + }, + }; + + if (options.supplier) { + fields.supplier.value = options.supplier; + } + + if (options.hide_supplier) { + fields.supplier.hidden = true; + } + + // Add fields for order duplication (only if required) + if (options.duplicate_order) { + fields.duplicate_order = { + value: options.duplicate_order, + group: 'duplicate', + required: 'true', + type: 'related field', + model: 'purchaseorder', + filters: { + supplier_detail: true, + }, + api_url: '{% url "api-po-list" %}', + label: '{% trans "Purchase Order" %}', + help_text: '{% trans "Select purchase order to duplicate" %}', + }; + + fields.duplicate_line_items = { + value: true, + group: 'duplicate', + type: 'boolean', + label: '{% trans "Duplicate Line Items" %}', + help_text: '{% trans "Duplicate all line items from the selected order" %}', + }; + + fields.duplicate_extra_lines = { + value: true, + group: 'duplicate', + type: 'boolean', + label: '{% trans "Duplicate Extra Lines" %}', + help_text: '{% trans "Duplicate extra line items from the selected order" %}', + }; + } + + return fields; +} + + +/* + * Edit an existing PurchaseOrder + */ +function editPurchaseOrder(pk, options={}) { + + var fields = purchaseOrderFields(options); + + constructForm(`{% url "api-po-list" %}${pk}/`, { + fields: fields, + title: '{% trans "Edit Purchase Order" %}', + onSuccess: function(response) { + handleFormSuccess(response, options); + } + }); +} + + +// Create a new PurchaseOrder +function createPurchaseOrder(options={}) { + + var fields = purchaseOrderFields(options); + + var groups = {}; + + if (options.duplicate_order) { + groups.duplicate = { + title: '{% trans "Duplication Options" %}', + collapsible: false, + }; + }; + + constructForm('{% url "api-po-list" %}', { + method: 'POST', + fields: fields, + groups: groups, + data: options.data, + onSuccess: function(data) { + + if (options.onSuccess) { + options.onSuccess(data); + } else { + // Default action is to redirect browser to the new PurchaseOrder + location.href = `/order/purchase-order/${data.pk}/`; + } + }, + title: options.title || '{% trans "Create Purchase Order" %}', + }); +} + +/* + * Duplicate an existing PurchaseOrder + * Provides user with option to duplicate line items for the order also. + */ +function duplicatePurchaseOrder(order_id, options={}) { + + options.duplicate_order = order_id; + + inventreeGet(`{% url "api-po-list" %}${order_id}/`, {}, { + success: function(data) { + + // Clear out data we do not want to be duplicated + delete data['pk']; + delete data['reference']; + + options.data = data; + + createPurchaseOrder(options); + } + }); +} + + +/* Construct a set of fields for the PurchaseOrderLineItem form */ +function poLineItemFields(options={}) { + + var fields = { + order: { + filters: { + supplier_detail: true, + } + }, + part: { + icon: 'fa-shapes', + filters: { + part_detail: true, + supplier_detail: true, + supplier: options.supplier, + }, + onEdit: function(value, name, field, opts) { + // If the pack_size != 1, add a note to the field + var pack_size = 1; + var units = ''; + var supplier_part_id = value; + var quantity = getFormFieldValue('quantity', {}, opts); + + // Remove any existing note fields + $(opts.modal).find('#info-pack-size').remove(); + + if (value == null) { + return; + } + + // Request information about the particular supplier part + inventreeGet(`{% url "api-supplier-part-list" %}${value}/`, + { + part_detail: true, + }, + { + success: function(response) { + // Extract information from the returned query + pack_size = response.pack_size || 1; + units = response.part_detail.units || ''; + }, + } + ).then(function() { + // Update pack size information + if (pack_size != 1) { + var txt = ` {% trans "Pack Quantity" %}: ${pack_size} ${units}`; + $(opts.modal).find('#hint_id_quantity').after(`
    ${txt}
    `); + } + }).then(function() { + // Update pricing data (if available) + inventreeGet( + '{% url "api-part-supplier-price-list" %}', + { + part: supplier_part_id, + ordering: 'quantity', + }, + { + success: function(response) { + // Returned prices are in increasing order of quantity + if (response.length > 0) { + var idx = 0; + var index = 0; + + for (var idx = 0; idx < response.length; idx++) { + if (response[idx].quantity > quantity) { + break; + } + + index = idx; + } + + // Update price and currency data in the form + updateFieldValue('purchase_price', response[index].price, {}, opts); + updateFieldValue('purchase_price_currency', response[index].price_currency, {}, opts); + } + } + } + ); + }); + }, + secondary: { + method: 'POST', + title: '{% trans "Add Supplier Part" %}', + fields: function(data) { + var fields = supplierPartFields({ + part: data.part, + }); + + fields.supplier.value = options.supplier; + + // Adjust manufacturer part query based on selected part + fields.manufacturer_part.adjustFilters = function(query, opts) { + + var part = getFormFieldValue('part', {}, opts); + + if (part) { + query.part = part; + } + + return query; + }; + + return fields; + } + } + }, + quantity: {}, + reference: {}, + purchase_price: { + icon: 'fa-dollar-sign', + }, + purchase_price_currency: { + icon: 'fa-coins', + }, + target_date: { + icon: 'fa-calendar-alt', + }, + destination: { + icon: 'fa-sitemap', + filters: { + structural: false, + } + }, + notes: { + icon: 'fa-sticky-note', + }, + }; + + if (options.order) { + fields.order.value = options.order; + fields.order.hidden = true; + } + + if (options.currency) { + fields.purchase_price_currency.value = options.currency; + } + + if (options.target_date) { + fields.target_date.value = options.target_date; + } + + return fields; +} + + + +// Create a new PurchaseOrderLineItem +function createPurchaseOrderLineItem(order, options={}) { + + let fields = poLineItemFields({ + order: order, + supplier: options.supplier, + currency: options.currency, + target_date: options.target_date, + }); + + constructForm('{% url "api-po-line-list" %}', { + fields: fields, + method: 'POST', + title: '{% trans "Add Line Item" %}', + onSuccess: function(response) { + handleFormSuccess(response, options); + } + }); +} + + +/* + * Launches a modal form to mark a PurchaseOrder as "complete" + */ +function completePurchaseOrder(order_id, options={}) { + + constructForm( + `/api/order/po/${order_id}/complete/`, + { + method: 'POST', + title: '{% trans "Complete Purchase Order" %}', + confirm: true, + fieldsFunction: function(opts) { + var fields = { + accept_incomplete: {}, + }; + + if (opts.context.is_complete) { + delete fields['accept_incomplete']; + } + + return fields; + }, + preFormContent: function(opts) { + + var html = ` +
    + {% trans "Mark this order as complete?" %} +
    `; + + if (opts.context.is_complete) { + html += ` +
    + {% trans "All line items have been received" %} +
    `; + } else { + html += ` +
    + {% trans 'This order has line items which have not been marked as received.' %}
    + {% trans 'Completing this order means that the order and line items will no longer be editable.' %} +
    `; + } + + return html; + }, + onSuccess: function(response) { + handleFormSuccess(response, options); + } + } + ); +} + + +/* + * Launches a modal form to mark a PurchaseOrder as 'cancelled' + */ +function cancelPurchaseOrder(order_id, options={}) { + + constructForm( + `/api/order/po/${order_id}/cancel/`, + { + method: 'POST', + title: '{% trans "Cancel Purchase Order" %}', + confirm: true, + preFormContent: function(opts) { + var html = ` +
    + {% trans "Are you sure you wish to cancel this purchase order?" %} +
    `; + + if (!opts.context.can_cancel) { + html += ` +
    + {% trans "This purchase order can not be cancelled" %} +
    `; + } + + return html; + }, + onSuccess: function(response) { + handleFormSuccess(response, options); + } + } + ); +} + + +/* + * Launches a modal form to mark a PurchaseOrder as "issued" + */ +function issuePurchaseOrder(order_id, options={}) { + + let html = ` +
    + {% trans 'After placing this order, line items will no longer be editable.' %} +
    `; + + constructForm(`{% url "api-po-list" %}${order_id}/issue/`, { + method: 'POST', + title: '{% trans "Issue Purchase Order" %}', + confirm: true, + preFormContent: html, + onSuccess: function(response) { + handleFormSuccess(response, options); + } + }); +} + + + + +function newSupplierPartFromOrderWizard(e) { + /* Create a new supplier part directly from an order form. + * Launches a secondary modal and (if successful), + * back-populates the selected row. + */ + + e = e || window.event; + + var src = e.srcElement || e.target; + + var part = $(src).attr('part'); + + if (!part) { + part = $(src).closest('button').attr('part'); + } + + createSupplierPart({ + part: part, + onSuccess: function(data) { + + // TODO: 2021-08-23 - This whole form wizard needs to be refactored. + // In the future, use the API forms functionality to add the new item + // For now, this hack will have to do... + + var dropdown = `#id_supplier_part_${part}`; + + var pk = data.pk; + + inventreeGet( + `/api/company/part/${pk}/`, + { + supplier_detail: true, + }, + { + success: function(response) { + var text = ''; + + if (response.supplier_detail) { + text += response.supplier_detail.name; + text += ' | '; + } + + text += response.SKU; + + var option = new Option(text, pk, true, true); + + $('#modal-form').find(dropdown).append(option).trigger('change'); + } + } + ); + } + }); +} + + + + +/* + * Create a new form to order parts based on the list of provided parts. + */ +function orderParts(parts_list, options) { + + var parts = []; + + var parts_seen = {}; + + parts_list.forEach(function(part) { + if (part.purchaseable) { + + // Prevent duplicates + if (!(part.pk in parts_seen)) { + parts_seen[part.pk] = true; + parts.push(part); + } + } + }); + + if (parts.length == 0) { + showAlertDialog( + '{% trans "Select Parts" %}', + '{% trans "At least one purchaseable part must be selected" %}', + ); + return; + } + + // Render a single part within the dialog + function renderPart(part, opts={}) { + + var pk = part.pk; + + var thumb = thumbnailImage(part.thumbnail || part.image); + + // Default quantity value + var quantity = part.quantity || 1; + + if (quantity < 0) { + quantity = 0; + } + + var quantity_input = constructField( + `quantity_${pk}`, + { + type: 'decimal', + min_value: 0, + value: quantity, + title: '{% trans "Quantity to order" %}', + required: true, + }, + { + hideLabels: true, + } + ); + + var supplier_part_prefix = ` + + `; + + var supplier_part_input = constructField( + `part_${pk}`, + { + type: 'related field', + required: true, + prefixRaw: supplier_part_prefix, + }, + { + hideLabels: true, + } + ); + + var purchase_order_prefix = ` + + `; + + var purchase_order_input = constructField( + `order_${pk}`, + { + type: 'related field', + required: true, + prefixRaw: purchase_order_prefix, + }, + { + hideLabels: 'true', + } + ); + + let buttons = ''; + + if (parts.length > 1) { + buttons += makeRemoveButton( + 'button-row-remove', + pk, + '{% trans "Remove row" %}', + ); + } + + // Button to add row to purchase order + buttons += makeIconButton( + 'fa-shopping-cart icon-blue', + 'button-row-add', + pk, + '{% trans "Add to purchase order" %}', + ); + + buttons = wrapButtons(buttons); + + var html = ` + + ${thumb} ${part.full_name} + ${supplier_part_input} + ${purchase_order_input} + ${quantity_input} + ${buttons} + `; + + return html; + } + + // Remove a single row form this dialog + function removeRow(pk, opts) { + // Remove the row + $(opts.modal).find(`#order_row_${pk}`).remove(); + + // If the modal is now "empty", dismiss it + if (!($(opts.modal).find('.part-order-row').exists())) { + closeModal(opts.modal); + // If there is a onSuccess callback defined, call it + if (options && options.onSuccess) { + options.onSuccess(); + } + } + } + + var table_entries = ''; + + parts.forEach(function(part) { + table_entries += renderPart(part); + }); + + var html = ''; + + // Add table + html += ` + + + + + + + + + + + + ${table_entries} + +
    {% trans "Part" %}{% trans "Supplier Part" %}{% trans "Purchase Order" %}{% trans "Quantity" %}
    + `; + + // Construct API filters for the SupplierPart field + var supplier_part_filters = { + supplier_detail: true, + part_detail: true, + }; + + if (options.supplier) { + supplier_part_filters.supplier = options.supplier; + } + + if (options.manufacturer) { + supplier_part_filters.manufacturer = options.manufacturer; + } + + if (options.manufacturer_part) { + supplier_part_filters.manufacturer_part = options.manufacturer_part; + } + + // Construct API filtres for the PurchaseOrder field + var order_filters = { + status: {{ PurchaseOrderStatus.PENDING }}, + supplier_detail: true, + }; + + if (options.supplier) { + order_filters.supplier = options.supplier; + } + + constructFormBody({}, { + preFormContent: html, + title: '{% trans "Order Parts" %}', + hideSubmitButton: true, + closeText: '{% trans "Close" %}', + afterRender: function(fields, opts) { + parts.forEach(function(part) { + + var pk = part.pk; + + // Filter by base part + supplier_part_filters.part = pk; + + if (part.manufacturer_part) { + // Filter by manufacturer part + supplier_part_filters.manufacturer_part = part.manufacturer_part; + } + + // Callback function when supplier part is changed + // This is used to update the "pack size" attribute + var onSupplierPartChanged = function(value, name, field, opts) { + var pack_size = 1; + var units = ''; + + $(opts.modal).find(`#info-pack-size-${pk}`).remove(); + + if (value != null) { + inventreeGet( + `/api/company/part/${value}/`, + { + part_detail: true, + }, + { + success: function(response) { + pack_size = response.pack_size || 1; + units = response.part_detail.units || ''; + } + } + ).then(function() { + if (pack_size != 1) { + var txt = ` {% trans "Pack Quantity" %}: ${pack_size} ${units}`; + $(opts.modal).find(`#id_quantity_${pk}`).after(`
    ${txt}
    `); + } + }); + } + }; + + var supplier_part_field = { + name: `part_${part.pk}`, + model: 'supplierpart', + api_url: '{% url "api-supplier-part-list" %}', + required: true, + type: 'related field', + auto_fill: true, + value: options.supplier_part, + filters: supplier_part_filters, + onEdit: onSupplierPartChanged, + noResults: function(query) { + return '{% trans "No matching supplier parts" %}'; + } + }; + + // Configure the "supplier part" field + initializeRelatedField(supplier_part_field, null, opts); + addFieldCallback(`part_${part.pk}`, supplier_part_field, opts); + + // Configure the "purchase order" field + initializeRelatedField({ + name: `order_${part.pk}`, + model: 'purchaseorder', + api_url: '{% url "api-po-list" %}', + required: true, + type: 'related field', + auto_fill: false, + value: options.order, + filters: order_filters, + noResults: function(query) { + return '{% trans "No matching purchase orders" %}'; + } + }, null, opts); + + // Request 'requirements' information for each part + inventreeGet(`{% url "api-part-list" %}${part.pk}/requirements/`, {}, { + success: function(response) { + var required = response.required || 0; + var allocated = response.allocated || 0; + var available = response.available_stock || 0; + + // Based on what we currently 'have' on hand, what do we need to order? + var deficit = Math.max(required - allocated, 0); + + if (available < deficit) { + var q = deficit - available; + + updateFieldValue( + `quantity_${part.pk}`, + q, + {}, + opts + ); + } + } + }); + }); + + // Add callback for "add to purchase order" button + $(opts.modal).find('.button-row-add').click(function() { + var pk = $(this).attr('pk'); + + opts.field_suffix = null; + + // Extract information from the row + var data = { + quantity: getFormFieldValue(`quantity_${pk}`, {type: 'decimal'}, opts), + part: getFormFieldValue(`part_${pk}`, {}, opts), + order: getFormFieldValue(`order_${pk}`, {}, opts), + }; + + // Duplicate the form options, to prevent 'field_suffix' override + var row_opts = Object.assign(opts); + row_opts.field_suffix = `_${pk}`; + + inventreePut( + '{% url "api-po-line-list" %}', + data, + { + method: 'POST', + success: function(response) { + removeRow(pk, opts); + }, + error: function(xhr) { + switch (xhr.status) { + case 400: + handleFormErrors(xhr.responseJSON, fields, row_opts); + break; + default: + console.error(`Error adding line to purchase order`); + showApiError(xhr, options.url); + break; + } + } + } + ); + }); + + // Add callback for "remove row" button + $(opts.modal).find('.button-row-remove').click(function() { + var pk = $(this).attr('pk'); + + removeRow(pk, opts); + }); + + // Add callback for "new supplier part" button + $(opts.modal).find('.button-row-new-sp').click(function() { + var pk = $(this).attr('pk'); + + // Launch dialog to create new supplier part + createSupplierPart({ + part: pk, + onSuccess: function(response) { + setRelatedFieldData( + `part_${pk}`, + response, + opts + ); + } + }); + }); + + // Add callback for "new purchase order" button + $(opts.modal).find('.button-row-new-po').click(function() { + var pk = $(this).attr('pk'); + + // Launch dialog to create new purchase order + createPurchaseOrder({ + onSuccess: function(response) { + setRelatedFieldData( + `order_${pk}`, + response, + opts + ); + } + }); + }); + } + }); +} + + + +/* Create a new purchase order directly from an order form. + * Launches a secondary modal and (if successful), + * back-fills the newly created purchase order. + */ +function newPurchaseOrderFromOrderWizard(e) { + + e = e || window.event; + + var src = e.target || e.srcElement; + + var supplier = $(src).attr('supplierid'); + + createPurchaseOrder({ + supplier: supplier, + onSuccess: function(data) { + + // TODO: 2021-08-23 - The whole form wizard needs to be refactored + // In the future, the drop-down should be using a dynamic AJAX request + // to fill out the select2 options! + + var pk = data.pk; + + inventreeGet( + `/api/order/po/${pk}/`, + { + supplier_detail: true, + }, + { + success: function(response) { + var text = response.reference; + + if (response.supplier_detail) { + text += ` ${response.supplier_detail.name}`; + } + + var dropdown = `#id-purchase-order-${supplier}`; + + var option = new Option(text, pk, true, true); + + $('#modal-form').find(dropdown).append(option).trigger('change'); + } + } + ); + } + }); +} + + + +/** + * Receive stock items against a PurchaseOrder + * Uses the PurchaseOrderReceive 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={}) { + + // Zero items selected? + 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; + } + + // Prepend toggles to the quantity input + var toggle_batch = ` + + + + `; + + var toggle_serials = ` + + + + `; + + var units = line_item.part_detail.units || ''; + var pack_size = line_item.supplier_part_detail.pack_size || 1; + var pack_size_div = ''; + + var received = quantity * pack_size; + + if (pack_size != 1) { + pack_size_div = ` +
    + {% trans "Pack Quantity" %}: ${pack_size} ${units}
    + {% trans "Received Quantity" %}: ${received} ${units} +
    `; + } + + // 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, + } + ); + + // Add in options for "batch code" and "serial numbers" + var batch_input = constructField( + `items_batch_code_${pk}`, + { + type: 'string', + required: false, + label: '{% trans "Batch Code" %}', + help_text: '{% trans "Enter batch code for incoming stock items" %}', + prefixRaw: toggle_batch, + } + ); + + var sn_input = constructField( + `items_serial_numbers_${pk}`, + { + type: 'string', + required: false, + label: '{% trans "Serial Numbers" %}', + help_text: '{% trans "Enter serial numbers for incoming stock items" %}', + prefixRaw: toggle_serials, + } + ); + + // Hidden inputs below the "quantity" field + var quantity_input_group = `${quantity_input}${pack_size_div}
    ${batch_input}
    `; + + if (line_item.part_detail.trackable) { + quantity_input_group += `
    ${sn_input}
    `; + } + + // 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 + let buttons = ''; + + buttons += makeIconButton( + 'fa-layer-group', + 'button-row-add-batch', + pk, + '{% trans "Add batch code" %}', + { + collapseTarget: `div-batch-${pk}` + } + ); + + if (line_item.part_detail.trackable) { + buttons += makeIconButton( + 'fa-hashtag', + 'button-row-add-serials', + pk, + '{% trans "Add serial numbers" %}', + { + collapseTarget: `div-serials-${pk}`, + } + ); + } + + if (line_items.length > 1) { + buttons += makeRemoveButton('button-row-remove', pk, '{% trans "Remove row" %}'); + } + + buttons = wrapButtons(buttons); + + var html = ` + + + ${thumb} ${line_item.part_detail.full_name} + + + ${line_item.supplier_part_detail.SKU} + + + ${line_item.quantity} + + + ${line_item.received} + + + ${quantity_input_group} + + + ${status_input} + + + ${destination_input} + + + ${buttons} + + `; + + return html; + } + + var table_entries = ''; + + line_items.forEach(function(item) { + if (item.received < item.quantity) { + table_entries += renderLineItem(item); + } + }); + + var html = ``; + + // Add table + html += ` + + + + + + + + + + + + + + + ${table_entries} + +
    {% trans "Part" %}{% trans "Order Code" %}{% trans "Ordered" %}{% trans "Received" %}{% trans "Quantity to Receive" %}{% trans "Status" %}{% trans "Destination" %}
    + `; + + constructForm(`{% url "api-po-list" %}${order_id}/receive/`, { + method: 'POST', + fields: { + location: { + filters: { + structural: false, + } + }, + }, + preFormContent: html, + confirm: true, + confirmMessage: '{% trans "Confirm receipt of items" %}', + title: '{% trans "Receive Purchase Order Items" %}', + afterRender: function(fields, opts) { + + // Run initialization routines for each line in the form + 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, + }; + + // Initialize the location field + initializeRelatedField( + field_details, + null, + opts, + ); + + // Add 'clear' button callback for the location field + addClearCallback( + name, + field_details, + opts + ); + + // Setup stock item status field + initializeChoiceField( + { + name: `items_status_${pk}`, + }, + null, + opts + ); + + // Add change callback for quantity field + if (item.supplier_part_detail.pack_size != 1) { + $(opts.modal).find(`#id_items_quantity_${pk}`).change(function() { + var value = $(opts.modal).find(`#id_items_quantity_${pk}`).val(); + + var el = $(opts.modal).find(`#quantity_${pk}`).find('.pack_received_quantity'); + + var actual = value * item.supplier_part_detail.pack_size; + actual = formatDecimal(actual); + el.text(actual); + }); + } + }); + + // 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) { + + var line = { + line_item: pk, + quantity: quantity, + status: status, + location: location, + }; + + if (getFormFieldElement(`items_batch_code_${pk}`).exists()) { + line.batch_code = getFormFieldValue(`items_batch_code_${pk}`); + } + + if (getFormFieldElement(`items_serial_numbers_${pk}`).exists()) { + line.serial_numbers = getFormFieldValue(`items_serial_numbers_${pk}`); + } + + data.items.push(line); + 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, opts.url); + break; + } + } + } + ); + } + }); +} + + +/* + * Edit a purchase order line item in a modal form. + */ +function editPurchaseOrderLineItem(e) { + e = e || window.event; + + var src = e.target || e.srcElement; + + var url = $(src).attr('url'); + + // TODO: Migrate this to the API forms + launchModalForm(url, { + reload: true, + }); +} + +/* + * Delete a purchase order line item in a modal form + */ +function removePurchaseOrderLineItem(e) { + + e = e || window.event; + + var src = e.target || e.srcElement; + + var url = $(src).attr('url'); + + // TODO: Migrate this to the API forms + launchModalForm(url, { + reload: true, + }); +} + + +/* + * Load a table displaying list of purchase orders + */ +function loadPurchaseOrderTable(table, options) { + // Ensure the table starts in a known state + $(table).bootstrapTable('destroy'); + + options.params = options.params || {}; + + options.params['supplier_detail'] = true; + + var filters = loadTableFilters('purchaseorder', options.params); + + setupFilterList('purchaseorder', $(table), '#filter-list-purchaseorder', { + download: true, + report: { + url: '{% url "api-po-report-list" %}', + key: 'order', + } + }); + + var display_mode = inventreeLoad('purchaseorder-table-display-mode', 'list'); + + // Function for rendering PurchaseOrder calendar display + function buildEvents(calendar) { + + var start = startDate(calendar); + var end = endDate(calendar); + + clearEvents(calendar); + + // Extract current filters from table + var table_options = $(table).bootstrapTable('getOptions'); + var filters = table_options.query_params || {}; + + filters.supplier_detail = true; + filters.min_date = start; + filters.max_date = end; + + // Request purchase orders from the server within specified date range + inventreeGet( + '{% url "api-po-list" %}', + filters, + { + success: function(response) { + for (var idx = 0; idx < response.length; idx++) { + + var order = response[idx]; + + var date = order.creation_date; + + if (order.complete_date) { + date = order.complete_date; + } else if (order.target_date) { + date = order.target_date; + } + + var title = `${order.reference} - ${order.supplier_detail.name}`; + + var color = '#4c68f5'; + + if (order.complete_date) { + color = '#25c235'; + } else if (order.overdue) { + color = '#c22525'; + } else { + color = '#4c68f5'; + } + + var event = { + title: title, + start: date, + end: date, + url: `/order/purchase-order/${order.pk}/`, + backgroundColor: color, + }; + + calendar.addEvent(event); + } + } + } + ); + } + + $(table).inventreeTable({ + url: '{% url "api-po-list" %}', + queryParams: filters, + name: 'purchaseorder', + groupBy: false, + sidePagination: 'server', + original: options.params, + showColumns: display_mode == 'list', + disablePagination: display_mode == 'calendar', + showCustomViewButton: false, + showCustomView: display_mode == 'calendar', + search: display_mode != 'calendar', + formatNoMatches: function() { + return '{% trans "No purchase orders found" %}'; + }, + buttons: constructOrderTableButtons({ + prefix: 'purchaseorder', + disableTreeView: true, + callback: function() { + // Reload the entire table + loadPurchaseOrderTable(table, options); + } + }), + columns: [ + { + title: '', + visible: true, + checkbox: true, + switchable: false, + }, + { + field: 'reference', + title: '{% trans "Purchase Order" %}', + sortable: true, + switchable: false, + formatter: function(value, row) { + + var html = renderLink(value, `/order/purchase-order/${row.pk}/`); + + if (row.overdue) { + html += makeIconBadge('fa-calendar-times icon-red', '{% trans "Order is overdue" %}'); + } + + return html; + } + }, + { + field: 'supplier_detail', + title: '{% trans "Supplier" %}', + sortable: true, + sortName: 'supplier__name', + formatter: function(value, row) { + return imageHoverIcon(row.supplier_detail.image) + renderLink(row.supplier_detail.name, `/company/${row.supplier}/?display=purchase-orders`); + } + }, + { + field: 'supplier_reference', + title: '{% trans "Supplier Reference" %}', + }, + { + field: 'description', + title: '{% trans "Description" %}', + }, + { + field: 'status', + title: '{% trans "Status" %}', + switchable: true, + sortable: true, + formatter: function(value, row) { + return purchaseOrderStatusDisplay(row.status); + } + }, + { + field: 'creation_date', + title: '{% trans "Date" %}', + sortable: true, + formatter: function(value) { + return renderDate(value); + } + }, + { + field: 'target_date', + title: '{% trans "Target Date" %}', + sortable: true, + formatter: function(value) { + return renderDate(value); + } + }, + { + field: 'line_items', + title: '{% trans "Items" %}', + sortable: true, + }, + { + field: 'total_price', + title: '{% trans "Total Cost" %}', + switchable: true, + sortable: true, + formatter: function(value, row) { + return formatCurrency(value, { + currency: row.total_price_currency, + }); + }, + }, + { + field: 'responsible', + title: '{% trans "Responsible" %}', + switchable: true, + sortable: true, + formatter: function(value, row) { + + if (!row.responsible_detail) { + return '-'; + } + + var html = row.responsible_detail.name; + + if (row.responsible_detail.label == 'group') { + html += ``; + } else { + html += ``; + } + + return html; + } + }, + ], + customView: function(data) { + return `
    `; + }, + onRefresh: function() { + loadPurchaseOrderTable(table, options); + }, + onLoadSuccess: function() { + + if (display_mode == 'calendar') { + var el = document.getElementById('purchase-order-calendar'); + + calendar = new FullCalendar.Calendar(el, { + initialView: 'dayGridMonth', + nowIndicator: true, + aspectRatio: 2.5, + locale: options.locale, + datesSet: function() { + buildEvents(calendar); + } + }); + + calendar.render(); + } + } + }); +} + + +/* + * Delete the selected Purchase Order Line Items from the database + */ +function deletePurchaseOrderLineItems(items, options={}) { + + function renderItem(item, opts={}) { + + var part = item.part_detail; + var thumb = thumbnailImage(item.part_detail.thumbnail || item.part_detail.image); + var MPN = item.supplier_part_detail.manufacturer_part_detail ? item.supplier_part_detail.manufacturer_part_detail.MPN : '-'; + + var html = ` + + ${thumb} ${part.full_name} + ${part.description} + ${item.supplier_part_detail.SKU} + ${MPN} + ${item.quantity} + + `; + + return html; + } + + var rows = ''; + var ids = []; + + items.forEach(function(item) { + rows += renderItem(item); + ids.push(item.pk); + }); + + var html = ` +
    + {% trans "All selected Line items will be deleted" %} +
    + + + + + + + + + + ${rows} +
    {% trans "Part" %}{% trans "Description" %}{% trans "SKU" %}{% trans "MPN" %}{% trans "Quantity" %}
    + `; + + constructForm('{% url "api-po-line-list" %}', { + method: 'DELETE', + multi_delete: true, + title: '{% trans "Delete selected Line items?" %}', + form_data: { + items: ids, + }, + preFormContent: html, + refreshTable: '#po-line-table', + }); +} + + +/** + * Load a table displaying line items for a particular PurchasesOrder + * @param {String} table - HTML ID tag e.g. '#table' + * @param {Object} options - options which must provide: + * - order (integer PK) + * - supplier (integer PK) + * - allow_edit (boolean) + * - allow_receive (boolean) + */ +function loadPurchaseOrderLineItemTable(table, options={}) { + + options.params = options.params || {}; + + options.params['order'] = options.order; + options.params['part_detail'] = true; + + // Override 'editing' if order is not pending + if (!options.pending && !global_settings.PURCHASEORDER_EDIT_COMPLETED_ORDERS) { + options.allow_edit = false; + } + + var filters = loadTableFilters('purchaseorderlineitem', options.params); + + setupFilterList( + 'purchaseorderlineitem', + $(table), + options.filter_target || '#filter-list-purchase-order-lines', + { + download: true + } + ); + + function setupCallbacks() { + if (options.allow_edit) { + + // Callback for "duplicate" button + $(table).find('.button-line-duplicate').click(function() { + var pk = $(this).attr('pk'); + + inventreeGet(`{% url "api-po-line-list" %}${pk}/`, {}, { + success: function(data) { + + var fields = poLineItemFields({ + supplier: options.supplier, + }); + + constructForm('{% url "api-po-line-list" %}', { + method: 'POST', + fields: fields, + data: data, + title: '{% trans "Duplicate Line Item" %}', + refreshTable: table, + }); + } + }); + }); + + // Callback for "edit" button + $(table).find('.button-line-edit').click(function() { + var pk = $(this).attr('pk'); + + var fields = poLineItemFields(options); + + constructForm(`{% url "api-po-line-list" %}${pk}/`, { + fields: fields, + title: '{% trans "Edit Line Item" %}', + refreshTable: table, + }); + }); + + // Callback for "delete" button + $(table).find('.button-line-delete').click(function() { + var pk = $(this).attr('pk'); + + constructForm(`{% url "api-po-line-list" %}${pk}/`, { + method: 'DELETE', + title: '{% trans "Delete Line Item" %}', + refreshTable: table, + }); + }); + + // Callback for bulk deleting mutliple lines + $('#po-lines-bulk-delete').off('click').on('click', function() { + var rows = getTableData(' #po-line-table'); + + deletePurchaseOrderLineItems(rows); + }); + } + + if (options.allow_receive) { + $(table).find('.button-line-receive').click(function() { + var pk = $(this).attr('pk'); + + var line_item = $(table).bootstrapTable('getRowByUniqueId', pk); + + if (!line_item) { + console.warn('getRowByUniqueId returned null'); + return; + } + + receivePurchaseOrderItems( + options.order, + [ + line_item, + ], + { + success: function() { + // Reload the line item table + reloadBootstrapTable(table); + + // Reload the "received stock" table + reloadBootstrapTable('#stock-table'); + } + } + ); + }); + } + } + + $(table).inventreeTable({ + onPostBody: setupCallbacks, + name: 'purchaseorderlines', + sidePagination: 'server', + formatNoMatches: function() { + return '{% trans "No line items found" %}'; + }, + queryParams: filters, + original: options.params, + url: '{% url "api-po-line-list" %}', + showFooter: true, + uniqueId: 'pk', + columns: [ + { + checkbox: true, + visible: true, + switchable: false, + }, + { + field: 'part', + sortable: true, + sortName: 'part_name', + title: '{% trans "Part" %}', + switchable: false, + formatter: function(value, row, index, field) { + if (row.part) { + return imageHoverIcon(row.part_detail.thumbnail) + renderLink(row.part_detail.full_name, `/part/${row.part_detail.pk}/`); + } else { + return '-'; + } + }, + footerFormatter: function() { + return '{% trans "Total" %}'; + } + }, + { + field: 'part_detail.description', + title: '{% trans "Description" %}', + }, + { + sortable: true, + sortName: 'SKU', + field: 'supplier_part_detail.SKU', + title: '{% trans "SKU" %}', + formatter: function(value, row, index, field) { + if (value) { + return renderLink(value, `/supplier-part/${row.part}/`); + } else { + return '-'; + } + }, + }, + { + sortable: false, + field: 'supplier_part_detail.link', + title: '{% trans "Link" %}', + formatter: function(value, row, index, field) { + if (value) { + return renderLink(value, value); + } else { + return ''; + } + }, + }, + { + sortable: true, + sortName: 'MPN', + field: 'supplier_part_detail.manufacturer_part_detail.MPN', + title: '{% trans "MPN" %}', + formatter: function(value, row, index, field) { + if (row.supplier_part_detail && row.supplier_part_detail.manufacturer_part) { + return renderLink(value, `/manufacturer-part/${row.supplier_part_detail.manufacturer_part}/`); + } else { + return '-'; + } + }, + }, + { + sortable: true, + field: 'reference', + title: '{% trans "Reference" %}', + }, + { + sortable: true, + switchable: false, + field: 'quantity', + title: '{% trans "Quantity" %}', + formatter: function(value, row) { + var units = ''; + + if (row.part_detail.units) { + units = ` ${row.part_detail.units}`; + } + + var data = value; + + if (row.supplier_part_detail.pack_size != 1.0) { + var pack_size = row.supplier_part_detail.pack_size; + var total = value * pack_size; + data += ``; + } + + return data; + }, + footerFormatter: function(data) { + return data.map(function(row) { + return +row['quantity']; + }).reduce(function(sum, i) { + return sum + i; + }, 0); + } + }, + { + sortable: false, + switchable: true, + field: 'supplier_part_detail.pack_size', + title: '{% trans "Pack Quantity" %}', + formatter: function(value, row) { + var units = row.part_detail.units; + + if (units) { + value += ` ${units}`; + } + + return value; + } + }, + { + sortable: true, + field: 'purchase_price', + title: '{% trans "Unit Price" %}', + formatter: function(value, row) { + return formatCurrency(row.purchase_price, { + currency: row.purchase_price_currency, + }); + } + }, + { + field: 'total_price', + sortable: true, + title: '{% trans "Total Price" %}', + formatter: function(value, row) { + return formatCurrency(row.purchase_price * row.quantity, { + currency: row.purchase_price_currency + }); + }, + footerFormatter: function(data) { + return calculateTotalPrice( + data, + function(row) { + return row.purchase_price ? row.purchase_price * row.quantity : null; + }, + function(row) { + return row.purchase_price_currency; + } + ); + } + }, + { + sortable: true, + field: 'target_date', + switchable: true, + title: '{% trans "Target Date" %}', + formatter: function(value, row) { + if (row.target_date) { + var html = renderDate(row.target_date); + + if (row.overdue) { + html += makeIconBadge('fa-calendar-times icon-red', '{% trans "This line item is overdue" %}'); + } + + return html; + + } else if (row.order_detail && row.order_detail.target_date) { + return `${renderDate(row.order_detail.target_date)}`; + } else { + return '-'; + } + } + }, + { + sortable: false, + field: 'received', + switchable: false, + 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: 'destination', + title: '{% trans "Destination" %}', + formatter: function(value, row) { + if (value) { + return renderLink(row.destination_detail.pathstring, `/stock/location/${value}/`); + } else { + return '-'; + } + } + }, + { + field: 'notes', + title: '{% trans "Notes" %}', + }, + { + switchable: false, + field: 'buttons', + title: '', + formatter: function(value, row, index, field) { + let buttons = ''; + let pk = row.pk; + + if (options.allow_receive && row.received < row.quantity) { + buttons += makeIconButton('fa-sign-in-alt icon-green', 'button-line-receive', pk, '{% trans "Receive line item" %}'); + } + + if (options.allow_edit) { + buttons += makeCopyButton('button-line-duplicate', pk, '{% trans "Duplicate line item" %}'); + buttons += makeEditButton('button-line-edit', pk, '{% trans "Edit line item" %}'); + buttons += makeDeleteButton('button-line-delete', pk, '{% trans "Delete line item" %}'); + } + + return wrapButtons(buttons); + }, + } + ] + }); + + linkButtonsToSelection( + table, + [ + '#multi-select-options', + ] + ); + +} diff --git a/InvenTree/templates/js/translated/report.js b/InvenTree/templates/js/translated/report.js index 7187674635..a6d124a3cd 100644 --- a/InvenTree/templates/js/translated/report.js +++ b/InvenTree/templates/js/translated/report.js @@ -14,21 +14,17 @@ */ /* exported - printBomReports, - printBuildReports, - printPurchaseOrderReports, - printSalesOrderReports, - printTestReports, + printReports, */ +/** + * Present the user with the available reports, + * and allow them to select which report to print. + * + * The intent is that the available report templates have been requested + * (via AJAX) from the server. + */ function selectReport(reports, items, options={}) { - /** - * Present the user with the available reports, - * and allow them to select which report to print. - * - * The intent is that the available report templates have been requested - * (via AJAX) from the server. - */ // If there is only a single report available, just print! if (reports.length == 1) { @@ -108,270 +104,57 @@ function selectReport(reports, items, options={}) { } -function printTestReports(items) { - /** - * Print test reports for the provided stock item(s) - */ +/* + * Print report(s) for the selected items: + * + * - Retrieve a list of matching report templates from the server + * - Present the available templates to the user (if more than one available) + * - Request printed document + * + * Required options: + * - url: The list URL for the particular template type + * - items: The list of objects to print + * - key: The key to use in the query parameters + */ +function printReports(options) { - if (items.length == 0) { + if (!options.items || options.items.length == 0) { showAlertDialog( - '{% trans "Select Stock Items" %}', - '{% trans "Stock item(s) must be selected before printing reports" %}' + '{% trans "Select Items" %}', + '{% trans "No items selected for printing" }', ); - return; } - // Request available reports from the server - inventreeGet( - '{% url "api-stockitem-testreport-list" %}', - { - enabled: true, - items: items, - }, - { - success: function(response) { - if (response.length == 0) { - showAlertDialog( - '{% trans "No Reports Found" %}', - '{% trans "No report templates found which match selected stock item(s)" %}', - ); + let params = { + enabled: true, + }; - return; - } + params[options.key] = options.items; - // Select report template to print - selectReport( - response, - items, - { - success: function(pk) { - var href = `/api/report/test/${pk}/print/?`; - - items.forEach(function(item) { - href += `item=${item}&`; - }); - - window.open(href); - } - } + // Request a list of available report templates + inventreeGet(options.url, params, { + success: function(response) { + if (response.length == 0) { + showAlertDialog( + '{% trans "No Reports Found" %}', + '{% trans "No report templates found which match the selected items" %}', ); + return; } - } - ); -} + // Select report template for printing + selectReport(response, options.items, { + success: function(pk) { + let href = `${options.url}${pk}/print/?`; -function printBuildReports(builds) { - /** - * Print Build report for the provided build(s) - */ + options.items.forEach(function(item) { + href += `${options.key}=${item}&`; + }); - if (builds.length == 0) { - showAlertDialog( - '{% trans "Select Builds" %}', - '{% trans "Build(s) must be selected before printing reports" %}', - ); - - return; - } - - inventreeGet( - '{% url "api-build-report-list" %}', - { - enabled: true, - builds: builds, - }, - { - success: function(response) { - if (response.length == 0) { - showAlertDialog( - '{% trans "No Reports Found" %}', - '{% trans "No report templates found which match selected build(s)" %}' - ); - - return; + window.open(href); } - - // Select which report to print - selectReport( - response, - builds, - { - success: function(pk) { - var href = `/api/report/build/${pk}/print/?`; - - builds.forEach(function(build) { - href += `build=${build}&`; - }); - - window.open(href); - } - } - ); - } + }); } - ); -} - - -function printBomReports(parts) { - /** - * Print BOM reports for the provided part(s) - */ - - if (parts.length == 0) { - showAlertDialog( - '{% trans "Select Parts" %}', - '{% trans "Part(s) must be selected before printing reports" %}' - ); - - return; - } - - // Request available reports from the server - inventreeGet( - '{% url "api-bom-report-list" %}', - { - enabled: true, - parts: parts, - }, - { - success: function(response) { - if (response.length == 0) { - showAlertDialog( - '{% trans "No Reports Found" %}', - '{% trans "No report templates found which match selected part(s)" %}', - ); - - return; - } - - // Select which report to print - selectReport( - response, - parts, - { - success: function(pk) { - var href = `/api/report/bom/${pk}/print/?`; - - parts.forEach(function(part) { - href += `part=${part}&`; - }); - - window.open(href); - } - } - ); - } - } - ); -} - - -function printPurchaseOrderReports(orders) { - /** - * Print PurchaseOrder reports for the provided purchase order(s) - */ - - if (orders.length == 0) { - showAlertDialog( - '{% trans "Select Purchase Orders" %}', - '{% trans "Purchase Order(s) must be selected before printing report" %}', - ); - - return; - } - - // Request avaiable report templates - inventreeGet( - '{% url "api-po-report-list" %}', - { - enabled: true, - orders: orders, - }, - { - success: function(response) { - if (response.length == 0) { - showAlertDialog( - '{% trans "No Reports Found" %}', - '{% trans "No report templates found which match selected orders" %}', - ); - - return; - } - - // Select report template - selectReport( - response, - orders, - { - success: function(pk) { - var href = `/api/report/po/${pk}/print/?`; - - orders.forEach(function(order) { - href += `order=${order}&`; - }); - - window.open(href); - } - } - ); - } - } - ); -} - - -function printSalesOrderReports(orders) { - /** - * Print SalesOrder reports for the provided purchase order(s) - */ - - if (orders.length == 0) { - showAlertDialog( - '{% trans "Select Sales Orders" %}', - '{% trans "Sales Order(s) must be selected before printing report" %}', - ); - - return; - } - - // Request avaiable report templates - inventreeGet( - '{% url "api-so-report-list" %}', - { - enabled: true, - orders: orders, - }, - { - success: function(response) { - if (response.length == 0) { - showAlertDialog( - '{% trans "No Reports Found" %}', - '{% trans "No report templates found which match selected orders" %}', - ); - - return; - } - - // Select report template - selectReport( - response, - orders, - { - success: function(pk) { - var href = `/api/report/so/${pk}/print/?`; - - orders.forEach(function(order) { - href += `order=${order}&`; - }); - - window.open(href); - } - } - ); - } - } - ); + }); } diff --git a/InvenTree/templates/js/translated/return_order.js b/InvenTree/templates/js/translated/return_order.js new file mode 100644 index 0000000000..7c6519c5f8 --- /dev/null +++ b/InvenTree/templates/js/translated/return_order.js @@ -0,0 +1,743 @@ +{% load i18n %} +{% load inventree_extras %} + +/* globals + companyFormFields, + constructForm, + imageHoverIcon, + loadTableFilters, + renderLink, + returnOrderStatusDisplay, + setupFilterList, +*/ + +/* exported + cancelReturnOrder, + completeReturnOrder, + createReturnOrder, + createReturnOrderLineItem, + editReturnOrder, + editReturnOrderLineItem, + issueReturnOrder, + loadReturnOrderTable, + loadReturnOrderLineItemTable, +*/ + + +/* + * Construct a set of fields for a ReturnOrder form + */ +function returnOrderFields(options={}) { + + let fields = { + reference: { + icon: 'fa-hashtag', + }, + description: {}, + customer: { + icon: 'fa-user-tie', + secondary: { + title: '{% trans "Add Customer" %}', + fields: function() { + var fields = companyFormFields(); + fields.is_customer.value = true; + return fields; + } + } + }, + customer_reference: {}, + target_date: { + icon: 'fa-calendar-alt', + }, + link: { + icon: 'fa-link', + }, + contact: { + icon: 'fa-user', + adjustFilters: function(filters) { + let customer = getFormFieldValue('customer', {}, {modal: options.modal}); + + if (customer) { + filters.company = customer; + } + + return filters; + } + }, + responsible: { + icon: 'fa-user', + } + }; + + return fields; +} + + +/* + * Create a new Return Order + */ +function createReturnOrder(options={}) { + let fields = returnOrderFields(options); + + if (options.customer) { + fields.customer.value = options.customer; + } + + constructForm('{% url "api-return-order-list" %}', { + method: 'POST', + fields: fields, + title: '{% trans "Create Return Order" %}', + onSuccess: function(data) { + location.href = `/order/return-order/${data.pk}/`; + }, + }); +} + + +/* + * Edit an existing Return Order + */ +function editReturnOrder(order_id, options={}) { + + constructForm(`{% url "api-return-order-list" %}${order_id}/`, { + fields: returnOrderFields(options), + title: '{% trans "Edit Return Order" %}', + onSuccess: function(response) { + handleFormSuccess(response, options); + } + }); +} + + +/* + * "Issue" a ReturnOrder, to mark it as "in progress" + */ +function issueReturnOrder(order_id, options={}) { + + let html = ` +
    + {% trans 'After placing this order, line items will no longer be editable.' %} +
    `; + + constructForm(`{% url "api-return-order-list" %}${order_id}/issue/`, { + method: 'POST', + title: '{% trans "Issue Return Order" %}', + confirm: true, + preFormContent: html, + onSuccess: function(response) { + handleFormSuccess(response, options); + } + }); +} + + +/* + * Launches a modal form to cancel a ReturnOrder + */ +function cancelReturnOrder(order_id, options={}) { + + let html = ` +
    + {% trans "Are you sure you wish to cancel this Return Order?" %} +
    `; + + constructForm( + `{% url "api-return-order-list" %}${order_id}/cancel/`, + { + method: 'POST', + title: '{% trans "Cancel Return Order" %}', + confirm: true, + preFormContent: html, + onSuccess: function(response) { + handleFormSuccess(response, options); + } + } + ); +} + + +/* + * Launches a modal form to mark a ReturnOrder as "complete" + */ +function completeReturnOrder(order_id, options={}) { + let html = ` +
    + {% trans "Mark this order as complete?" %} +
    + `; + + constructForm( + `{% url "api-return-order-list" %}${order_id}/complete/`, + { + method: 'POST', + title: '{% trans "Complete Return Order" %}', + confirm: true, + preFormContent: html, + onSuccess: function(response) { + handleFormSuccess(response, options); + } + } + ); +} + + +/* + * Load a table of return orders + */ +function loadReturnOrderTable(table, options={}) { + + // Ensure the table starts in a known state + $(table).bootstrapTable('destroy'); + + options.params = options.params || {}; + options.params['customer_detail'] = true; + + let filters = loadTableFilters('returnorder', options.params); + + setupFilterList('returnorder', $(table), '#filter-list-returnorder', { + download: true, + report: { + url: '{% url "api-return-order-report-list" %}', + key: 'order', + } + }); + + let display_mode = inventreeLoad('returnorder-table-display-mode', 'list'); + + let is_calendar = display_mode == 'calendar'; + + $(table).inventreeTable({ + url: '{% url "api-return-order-list" %}', + queryParams: filters, + name: 'returnorder', + sidePagination: 'server', + original: options.params, + showColumns: !is_calendar, + search: !is_calendar, + showCustomViewButton: false, + showCustomView: is_calendar, + disablePagination: is_calendar, + formatNoMatches: function() { + return '{% trans "No return orders found" %}'; + }, + onRefresh: function() { + loadReturnOrderTable(table, options); + }, + onLoadSuccess: function() { + // TODO + }, + columns: [ + { + title: '', + checkbox: true, + visible: true, + switchable: false, + }, + { + sortable: true, + field: 'reference', + title: '{% trans "Return Order" %}', + formatter: function(value, row) { + let html = renderLink(value, `/order/return-order/${row.pk}/`); + + if (row.overdue) { + html += makeIconBadge('fa-calendar-times icon-red', '{% trans "Order is overdue" %}'); + } + + return html; + }, + }, + { + sortable: true, + sortName: 'customer__name', + field: 'customer_detail', + title: '{% trans "Customer" %}', + formatter: function(value, row) { + + if (!row.customer_detail) { + return '{% trans "Invalid Customer" %}'; + } + + return imageHoverIcon(row.customer_detail.image) + renderLink(row.customer_detail.name, `/company/${row.customer}/sales-orders/`); + } + }, + { + sortable: true, + field: 'customer_reference', + title: '{% trans "Customer Reference" %}', + }, + { + sortable: false, + field: 'description', + title: '{% trans "Description" %}', + }, + { + sortable: true, + field: 'status', + title: '{% trans "Status" %}', + formatter: function(value, row) { + return returnOrderStatusDisplay(row.status); + } + }, + { + sortable: true, + field: 'creation_date', + title: '{% trans "Creation Date" %}', + formatter: function(value) { + return renderDate(value); + } + }, + { + sortable: true, + field: 'target_date', + title: '{% trans "Target Date" %}', + formatter: function(value) { + return renderDate(value); + } + }, + { + field: 'line_items', + title: '{% trans "Items" %}', + sortable: true, + }, + { + field: 'responsible', + title: '{% trans "Responsible" %}', + switchable: true, + sortable: true, + formatter: function(value, row) { + if (!row.responsible_detail) { + return '-'; + } + + let html = row.responsible_detail.name; + + if (row.responsible_detail.label == 'group') { + html += ``; + } else { + html += ``; + } + + return html; + } + }, + { + // TODO: Add in the 'total cost' field + field: 'total_price', + title: '{% trans "Total Cost" %}', + switchable: true, + sortable: true, + visible: false, + formatter: function(value, row) { + return formatCurrency(value, { + currency: row.total_price_currency + }); + } + } + ] + }); +} + + +/* + * Construct a set of fields for a ReturnOrderLineItem form + */ +function returnOrderLineItemFields(options={}) { + + let fields = { + order: { + filters: { + customer_detail: true, + } + }, + item: { + filters: { + part_detail: true, + serialized: true, + } + }, + reference: {}, + outcome: { + icon: 'fa-route', + }, + price: { + icon: 'fa-dollar-sign', + }, + price_currency: { + icon: 'fa-coins', + }, + target_date: { + icon: 'fa-calendar-alt', + }, + notes: { + icon: 'fa-sticky-note', + } + }; + + return fields; +} + + +/* + * Create a new ReturnOrderLineItem + */ +function createReturnOrderLineItem(options={}) { + + let fields = returnOrderLineItemFields(); + + if (options.order) { + fields.order.value = options.order; + fields.order.hidden = true; + } + + if (options.customer) { + Object.assign(fields.item.filters, { + customer: options.customer + }); + } + + constructForm('{% url "api-return-order-line-list" %}', { + fields: fields, + method: 'POST', + title: '{% trans "Add Line Item" %}', + onSuccess: function(response) { + handleFormSuccess(response, options); + } + }); +} + + +/* + * Edit an existing ReturnOrderLineItem + */ +function editReturnOrderLineItem(pk, options={}) { + + let fields = returnOrderLineItemFields(); + + constructForm(`{% url "api-return-order-line-list" %}${pk}/`, { + fields: fields, + title: '{% trans "Edit Line Item" %}', + onSuccess: function(response) { + handleFormSuccess(response, options); + } + }); +} + + +/* + * Receive one or more items against a ReturnOrder + */ +function receiveReturnOrderItems(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) { + let pk = line_item.pk; + + // Render thumbnail + description + let thumb = thumbnailImage(line_item.part_detail.thumbnail); + + let buttons = ''; + + if (line_items.length > 1) { + buttons += makeRemoveButton('button-row-remove', pk, '{% trans "Remove row" %}'); + } + + buttons = wrapButtons(buttons); + + let html = ` + + + ${thumb} ${line_item.part_detail.full_name} + + + ${line_item.item_detail.serial} + + ${buttons} + `; + + return html; + } + + let table_entries = ''; + + line_items.forEach(function(item) { + if (!item.received_date) { + table_entries += renderLineItem(item); + } + }); + + let html = ''; + + html += ` + + + + + + + + ${table_entries} +
    {% trans "Part" %}{% trans "Serial Number" %}
    `; + + constructForm(`{% url "api-return-order-list" %}${order_id}/receive/`, { + method: 'POST', + preFormContent: html, + fields: { + location: { + filters: { + strucutral: false, + } + } + }, + confirm: true, + confirmMessage: '{% trans "Confirm receipt of items" %}', + title: '{% trans "Receive Return Order Items" %}', + afterRender: function(fields, opts) { + // Add callback to remove rows + $(opts.modal).find('.button-row-remove').click(function() { + let pk = $(this).attr('pk'); + $(opts.modal).find(`#receive_row_${pk}`).remove(); + }); + }, + onSubmit: function(fields, opts) { + // Extract data elements from the form + let data = { + items: [], + location: getFormFieldValue('location', {}, opts), + }; + + let item_pk_values = []; + + line_items.forEach(function(item) { + let pk = item.pk; + let row = $(opts.modal).find(`#receive_row_${pk}`); + + if (row.exists()) { + data.items.push({ + item: pk, + }); + item_pk_values.push(pk); + } + }); + + opts.nested = { + 'items': item_pk_values, + }; + + inventreePut( + opts.url, + data, + { + method: 'POST', + success: function(response) { + $(opts.modal).modal('hide'); + + handleFormSuccess(response, options); + }, + error: function(xhr) { + switch (xhr.status) { + case 400: + handleFormErrors(xhr.responseJSON, fields, opts); + break; + default: + $(opts.modal).modal('hide'); + showApiError(xhr, opts.url); + break; + } + } + } + ); + } + }); +} + + +/* + * Load a table displaying line items for a particular ReturnOrder + */ +function loadReturnOrderLineItemTable(options={}) { + + var table = options.table; + + options.params = options.params || {}; + + options.params.order = options.order; + options.params.item_detail = true; + options.params.order_detail = false; + options.params.part_detail = true; + + let filters = loadTableFilters('returnorderlineitem', options.params); + + setupFilterList('returnorderlineitem', $(table), '#filter-list-returnorderlines', {download: true}); + + function setupCallbacks() { + if (options.allow_edit) { + + // Callback for "receive" button + if (options.allow_receive) { + $(table).find('.button-line-receive').click(function() { + let pk = $(this).attr('pk'); + + let line = $(table).bootstrapTable('getRowByUniqueId', pk); + + receiveReturnOrderItems( + options.order, + [line], + { + onSuccess: function(response) { + reloadBootstrapTable(table); + } + } + ); + }); + } + + // Callback for "edit" button + $(table).find('.button-line-edit').click(function() { + let pk = $(this).attr('pk'); + + constructForm(`{% url "api-return-order-line-list" %}${pk}/`, { + fields: returnOrderLineItemFields(), + title: '{% trans "Edit Line Item" %}', + refreshTable: table, + }); + }); + } + + if (options.allow_delete) { + // Callback for "delete" button + $(table).find('.button-line-delete').click(function() { + let pk = $(this).attr('pk'); + + constructForm(`{% url "api-return-order-line-list" %}${pk}/`, { + method: 'DELETE', + title: '{% trans "Delete Line Item" %}', + refreshTable: table, + }); + }); + } + } + + $(table).inventreeTable({ + url: '{% url "api-return-order-line-list" %}', + name: 'returnorderlineitems', + formatNoMatches: function() { + return '{% trans "No matching line items" %}'; + }, + onPostBody: setupCallbacks, + queryParams: filters, + original: options.params, + showColumns: true, + showFooter: true, + uniqueId: 'pk', + columns: [ + { + checkbox: true, + switchable: false, + }, + { + field: 'part', + sortable: true, + switchable: false, + title: '{% trans "Part" %}', + formatter: function(value, row) { + let part = row.part_detail; + let html = thumbnailImage(part.thumbnail) + ' '; + html += renderLink(part.full_name, `/part/${part.pk}/`); + return html; + } + }, + { + field: 'item', + sortable: true, + switchable: false, + title: '{% trans "Item" %}', + formatter: function(value, row) { + return renderLink(`{% trans "Serial Number" %}: ${row.item_detail.serial}`, `/stock/item/${row.item}/`); + } + }, + { + field: 'reference', + title: '{% trans "Reference" %}', + }, + { + field: 'outcome', + title: '{% trans "Outcome" %}', + sortable: true, + formatter: function(value, row) { + return returnOrderLineItemStatusDisplay(value); + } + }, + { + field: 'price', + title: '{% trans "Price" %}', + formatter: function(value, row) { + return formatCurrency(row.price, { + currency: row.price_currency, + }); + } + }, + { + sortable: true, + field: 'target_date', + title: '{% trans "Target Date" %}', + formatter: function(value, row) { + let html = renderDate(value); + + if (row.overdue) { + html += makeIconBadge('fa-calendar-times icon-red', '{% trans "This line item is overdue" %}'); + } + + return html; + } + }, + { + field: 'received_date', + title: '{% trans "Received" %}', + sortable: true, + formatter: function(value) { + if (!value) { + yesNoLabel(value); + } else { + return renderDate(value); + } + } + }, + { + field: 'notes', + title: '{% trans "Notes" %}', + }, + { + field: 'buttons', + title: '', + switchable: false, + formatter: function(value, row) { + let buttons = ''; + let pk = row.pk; + + if (options.allow_edit) { + + if (options.allow_receive && !row.received_date) { + buttons += makeIconButton('fa-sign-in-alt icon-green', 'button-line-receive', pk, '{% trans "Mark item as received" %}'); + } + + buttons += makeEditButton('button-line-edit', pk, '{% trans "Edit line item" %}'); + } + + if (options.allow_delete) { + buttons += makeDeleteButton('button-line-delete', pk, '{% trans "Delete line item" %}'); + } + + return wrapButtons(buttons); + } + } + ] + }); +} diff --git a/InvenTree/templates/js/translated/sales_order.js b/InvenTree/templates/js/translated/sales_order.js new file mode 100644 index 0000000000..763718d100 --- /dev/null +++ b/InvenTree/templates/js/translated/sales_order.js @@ -0,0 +1,2107 @@ +{% load i18n %} +{% load inventree_extras %} + + +/* globals + companyFormFields, + constructForm, + global_settings, + imageHoverIcon, + inventreeGet, + launchModalForm, + loadTableFilters, + makeIconBadge, + renderLink, + salesOrderStatusDisplay, + setupFilterList, +*/ + +/* exported + allocateStockToSalesOrder, + cancelSalesOrder, + completeSalesOrder, + completeSalesOrderShipment, + completePendingShipments, + createSalesOrder, + createSalesOrderLineItem, + createSalesOrderShipment, + editSalesOrder, + exportOrder, + loadSalesOrderAllocationTable, + loadSalesOrderLineItemTable, + loadSalesOrderShipmentTable, + loadSalesOrderTable, + orderParts, + loadOrderTotal +*/ + + + +/* + * Construct a set of form fields for the SalesOrder model + */ +function salesOrderFields(options={}) { + let fields = { + reference: { + icon: 'fa-hashtag', + }, + description: {}, + customer: { + icon: 'fa-user-tie', + secondary: { + title: '{% trans "Add Customer" %}', + fields: function() { + var fields = companyFormFields(); + fields.is_customer.value = true; + return fields; + } + } + }, + customer_reference: {}, + target_date: { + icon: 'fa-calendar-alt', + }, + link: { + icon: 'fa-link', + }, + contact: { + icon: 'fa-user', + adjustFilters: function(filters) { + let customer = getFormFieldValue('customer', {}, {modal: options.modal}); + + if (customer) { + filters.company = customer; + } + + return filters; + } + }, + responsible: { + icon: 'fa-user', + } + }; + + return fields; +} + + +/* + * Create a new SalesOrder + */ +function createSalesOrder(options={}) { + + let fields = salesOrderFields(options); + + if (options.customer) { + fields.customer.value = options.customer; + } + + constructForm('{% url "api-so-list" %}', { + method: 'POST', + fields: fields, + title: '{% trans "Create Sales Order" %}', + onSuccess: function(data) { + location.href = `/order/sales-order/${data.pk}/`; + }, + }); +} + + +/* + * Edit an existing SalesOrder + */ +function editSalesOrder(order_id, options={}) { + + constructForm(`{% url "api-so-list" %}${order_id}/`, { + fields: salesOrderFields(options), + title: '{% trans "Edit Sales Order" %}', + onSuccess: function(response) { + handleFormSuccess(response, options); + } + }); +} + + + + +/* Construct a set of fields for the SalesOrderLineItem form */ +function soLineItemFields(options={}) { + + let fields = { + order: { + hidden: true, + }, + part: { + icon: 'fa-shapes', + }, + quantity: {}, + reference: {}, + sale_price: { + icon: 'fa-dollar-sign', + }, + sale_price_currency: { + icon: 'fa-coins', + }, + target_date: { + icon: 'fa-calendar-alt', + }, + notes: { + icon: 'fa-sticky-note', + }, + }; + + if (options.order) { + fields.order.value = options.order; + } + + if (options.target_date) { + fields.target_date.value = options.target_date; + } + + return fields; +} + + +/* + * Launch a modal form to create a new SalesOrderLineItem + */ +function createSalesOrderLineItem(options={}) { + + let fields = soLineItemFields(options); + + constructForm('{% url "api-so-line-list" %}', { + fields: fields, + method: 'POST', + title: '{% trans "Add Line Item" %}', + onSuccess: function(response) { + handleFormSuccess(response, options); + }, + }); +} + + +/* + * Form field definitions for a SalesOrderShipment + */ +function salesOrderShipmentFields(options={}) { + var fields = { + order: {}, + reference: {}, + tracking_number: { + icon: 'fa-hashtag', + }, + invoice_number: { + icon: 'fa-dollar-sign', + }, + link: { + icon: 'fa-link', + } + }; + + // If order is specified, hide the order field + if (options.order) { + fields.order.value = options.order; + fields.order.hidden = true; + } + + return fields; +} + + +/* + * Complete a Sales Order shipment + */ +function completeSalesOrderShipment(shipment_id, options={}) { + + // Request the list of stock items which will be shipped + inventreeGet(`{% url "api-so-shipment-list" %}${shipment_id}/`, {}, { + success: function(shipment) { + var allocations = shipment.allocations; + + var html = ''; + + if (!allocations || allocations.length == 0) { + html = ` +
    + {% trans "No stock items have been allocated to this shipment" %} +
    + `; + } else { + html = ` + {% trans "The following stock items will be shipped" %} + + + + + + + + + `; + + allocations.forEach(function(allocation) { + + var part = allocation.part_detail; + var thumb = thumbnailImage(part.thumbnail || part.image); + + var stock = ''; + + if (allocation.serial) { + stock = `{% trans "Serial Number" %}: ${allocation.serial}`; + } else { + stock = `{% trans "Quantity" %}: ${allocation.quantity}`; + } + + html += ` + + + + + `; + }); + + html += ` + +
    {% trans "Part" %}{% trans "Stock Item" %}
    ${thumb} ${part.full_name}${stock}
    + `; + } + + constructForm(`{% url "api-so-shipment-list" %}${shipment_id}/ship/`, { + method: 'POST', + title: `{% trans "Complete Shipment" %} ${shipment.reference}`, + fields: { + shipment_date: { + value: moment().format('YYYY-MM-DD'), + }, + tracking_number: { + value: shipment.tracking_number, + icon: 'fa-hashtag', + }, + invoice_number: { + value: shipment.invoice_number, + icon: 'fa-dollar-sign', + }, + link: { + value: shipment.link, + icon: 'fa-link', + } + }, + preFormContent: html, + confirm: true, + confirmMessage: '{% trans "Confirm Shipment" %}', + buttons: options.buttons, + onSuccess: function(data) { + // Reload tables + $('#so-lines-table').bootstrapTable('refresh'); + $('#pending-shipments-table').bootstrapTable('refresh'); + $('#completed-shipments-table').bootstrapTable('refresh'); + + if (options.onSuccess instanceof Function) { + options.onSuccess(data); + } + }, + reload: options.reload + }); + } + }); +} + +/* + * Launches a modal to mark all allocated pending shipments as complete + */ +function completePendingShipments(order_id, options={}) { + var pending_shipments = null; + + // Request the list of stock items which will be shipped + inventreeGet(`{% url "api-so-shipment-list" %}`, + { + order: order_id, + shipped: false + }, + { + async: false, + success: function(shipments) { + pending_shipments = shipments; + } + } + ); + + var allocated_shipments = []; + + for (var idx = 0; idx < pending_shipments.length; idx++) { + if (pending_shipments[idx].allocations.length > 0) { + allocated_shipments.push(pending_shipments[idx]); + } + } + + if (allocated_shipments.length > 0) { + completePendingShipmentsHelper(allocated_shipments, 0, options); + + } else { + html = ` +
    + `; + + if (!pending_shipments.length) { + html += ` + {% trans "No pending shipments found" %} + `; + } else { + html += ` + {% trans "No stock items have been allocated to pending shipments" %} + `; + } + + html += ` +
    + `; + + constructForm(`{% url "api-so-shipment-list" %}0/ship/`, { + method: 'POST', + title: '{% trans "Complete Shipments" %}', + preFormContent: html, + onSubmit: function(fields, options) { + handleFormSuccess(fields, options); + }, + closeText: 'Close', + hideSubmitButton: true, + }); + } +} + + +/* + * Recursive helper for opening shipment completion modals + */ +function completePendingShipmentsHelper(shipments, shipment_idx, options={}) { + if (shipment_idx < shipments.length) { + completeSalseOrderShipment(shipments[shipment_idx].pk, + { + buttons: [ + { + name: 'skip', + title: `{% trans "Skip" %}`, + onClick: function(form_options) { + if (form_options.modal) { + $(form_options.modal).modal('hide'); + } + + completePendingShipmentsHelper(shipments, shipment_idx + 1, options); + } + } + ], + onSuccess: function(data) { + completePendingShipmentsHelper(shipments, shipment_idx + 1, options); + }, + } + ); + + } else if (options.reload) { + location.reload(); + } +} + + + +/* + * Launches a modal form to mark a SalesOrder as "complete" + */ +function completeSalesOrder(order_id, options={}) { + + constructForm( + `/api/order/so/${order_id}/complete/`, + { + method: 'POST', + title: '{% trans "Complete Sales Order" %}', + confirm: true, + fieldsFunction: function(opts) { + var fields = { + accept_incomplete: {}, + }; + + if (opts.context.is_complete) { + delete fields['accept_incomplete']; + } + + return fields; + }, + preFormContent: function(opts) { + var html = ` +
    + {% trans "Mark this order as complete?" %} +
    `; + + if (opts.context.pending_shipments) { + html += ` +
    + {% trans "Order cannot be completed as there are incomplete shipments" %}
    +
    `; + } + + if (!opts.context.is_complete) { + html += ` +
    + {% trans "This order has line items which have not been completed." %}
    + {% trans "Completing this order means that the order and line items will no longer be editable." %} +
    `; + } + + return html; + }, + onSuccess: function(response) { + handleFormSuccess(response, options); + } + } + ); +} + + +/* + * Launches a modal form to mark a SalesOrder as "cancelled" + */ +function cancelSalesOrder(order_id, options={}) { + + constructForm( + `/api/order/so/${order_id}/cancel/`, + { + method: 'POST', + title: '{% trans "Cancel Sales Order" %}', + confirm: true, + preFormContent: function(opts) { + var html = ` +
    + {% trans "Cancelling this order means that the order will no longer be editable." %} +
    `; + + return html; + }, + onSuccess: function(response) { + handleFormSuccess(response, options); + } + } + ); +} + +// Open a dialog to create a new sales order shipment +function createSalesOrderShipment(options={}) { + + // Work out the next shipment number for the given order + inventreeGet( + '{% url "api-so-shipment-list" %}', + { + order: options.order, + }, + { + success: function(results) { + // "predict" the next reference number + var ref = results.length + 1; + + var found = false; + + while (!found) { + + var no_match = true; + + for (var ii = 0; ii < results.length; ii++) { + if (ref.toString() == results[ii].reference.toString()) { + no_match = false; + break; + } + } + + if (no_match) { + break; + } else { + ref++; + } + } + + var fields = salesOrderShipmentFields(options); + + fields.reference.value = ref; + fields.reference.prefix = options.reference; + + constructForm('{% url "api-so-shipment-list" %}', { + method: 'POST', + fields: fields, + title: '{% trans "Create New Shipment" %}', + onSuccess: function(data) { + if (options.onSuccess) { + options.onSuccess(data); + } + } + }); + } + } + ); +} + + + +/* + * Load table displaying list of sales orders + */ +function loadSalesOrderTable(table, options) { + + // Ensure the table starts in a known state + $(table).bootstrapTable('destroy'); + + options.params = options.params || {}; + options.params['customer_detail'] = true; + + var filters = loadTableFilters('salesorder', options.params); + + setupFilterList('salesorder', $(table), '#filter-list-salesorder', { + download: true, + report: { + url: '{% url "api-so-report-list" %}', + key: 'order' + } + }); + + var display_mode = inventreeLoad('salesorder-table-display-mode', 'list'); + + function buildEvents(calendar) { + + var start = startDate(calendar); + var end = endDate(calendar); + + clearEvents(calendar); + + // Extract current filters from table + var table_options = $(table).bootstrapTable('getOptions'); + var filters = table_options.query_params || {}; + + filters.customer_detail = true; + filters.min_date = start; + filters.max_date = end; + + // Request orders from the server within specified date range + inventreeGet( + '{% url "api-so-list" %}', + filters, + { + success: function(response) { + + for (var idx = 0; idx < response.length; idx++) { + var order = response[idx]; + + var date = order.creation_date; + + if (order.shipment_date) { + date = order.shipment_date; + } else if (order.target_date) { + date = order.target_date; + } + + var title = `${order.reference} - ${order.customer_detail.name}`; + + // Default color is blue + var color = '#4c68f5'; + + // Overdue orders are red + if (order.overdue) { + color = '#c22525'; + } else if (order.status == {{ SalesOrderStatus.SHIPPED }}) { + color = '#25c235'; + } + + var event = { + title: title, + start: date, + end: date, + url: `/order/sales-order/${order.pk}/`, + backgroundColor: color, + }; + + calendar.addEvent(event); + } + } + } + ); + } + + $(table).inventreeTable({ + url: '{% url "api-so-list" %}', + queryParams: filters, + name: 'salesorder', + groupBy: false, + sidePagination: 'server', + original: options.params, + showColums: display_mode != 'calendar', + search: display_mode != 'calendar', + showCustomViewButton: false, + showCustomView: display_mode == 'calendar', + disablePagination: display_mode == 'calendar', + formatNoMatches: function() { + return '{% trans "No sales orders found" %}'; + }, + buttons: constructOrderTableButtons({ + prefix: 'salesorder', + disableTreeView: true, + callback: function() { + // Reload the entire table + loadSalesOrderTable(table, options); + }, + }), + customView: function(data) { + return `
    `; + }, + onRefresh: function() { + loadSalesOrderTable(table, options); + }, + onLoadSuccess: function() { + + if (display_mode == 'calendar') { + var el = document.getElementById('purchase-order-calendar'); + + calendar = new FullCalendar.Calendar(el, { + initialView: 'dayGridMonth', + nowIndicator: true, + aspectRatio: 2.5, + locale: options.locale, + datesSet: function() { + buildEvents(calendar); + } + }); + + calendar.render(); + } + }, + columns: [ + { + title: '', + checkbox: true, + visible: true, + switchable: false, + }, + { + sortable: true, + field: 'reference', + title: '{% trans "Sales Order" %}', + formatter: function(value, row) { + var html = renderLink(value, `/order/sales-order/${row.pk}/`); + + if (row.overdue) { + html += makeIconBadge('fa-calendar-times icon-red', '{% trans "Order is overdue" %}'); + } + + return html; + }, + }, + { + sortable: true, + sortName: 'customer__name', + field: 'customer_detail', + title: '{% trans "Customer" %}', + formatter: function(value, row) { + + if (!row.customer_detail) { + return '{% trans "Invalid Customer" %}'; + } + + return imageHoverIcon(row.customer_detail.image) + renderLink(row.customer_detail.name, `/company/${row.customer}/sales-orders/`); + } + }, + { + sortable: true, + field: 'customer_reference', + title: '{% trans "Customer Reference" %}', + }, + { + sortable: false, + field: 'description', + title: '{% trans "Description" %}', + }, + { + sortable: true, + field: 'status', + title: '{% trans "Status" %}', + formatter: function(value, row) { + return salesOrderStatusDisplay(row.status); + } + }, + { + sortable: true, + field: 'creation_date', + title: '{% trans "Creation Date" %}', + formatter: function(value) { + return renderDate(value); + } + }, + { + sortable: true, + field: 'target_date', + title: '{% trans "Target Date" %}', + formatter: function(value) { + return renderDate(value); + } + }, + { + sortable: true, + field: 'shipment_date', + title: '{% trans "Shipment Date" %}', + formatter: function(value) { + return renderDate(value); + } + }, + { + sortable: true, + field: 'line_items', + title: '{% trans "Items" %}' + }, + { + field: 'total_price', + title: '{% trans "Total Cost" %}', + switchable: true, + sortable: true, + formatter: function(value, row) { + return formatCurrency(value, { + currency: row.total_price_currency, + }); + } + } + ], + }); +} + + +/* + * Load a table displaying Shipment information against a particular order + */ +function loadSalesOrderShipmentTable(table, options={}) { + + options.table = table; + + options.params = options.params || {}; + + // Filter by order + options.params.order = options.order; + + // Filter by "shipped" status + options.params.shipped = options.shipped || false; + + var filters = loadTableFilters('salesordershipment', options.params); + + setupFilterList('salesordershipment', $(table), options.filter_target); + + // Add callbacks for expand / collapse buttons + var prefix = options.shipped ? 'completed' : 'pending'; + + $(`#${prefix}-shipments-expand`).click(function() { + $(table).bootstrapTable('expandAllRows'); + }); + + $(`#${prefix}-shipments-collapse`).click(function() { + $(table).bootstrapTable('collapseAllRows'); + }); + + function makeShipmentActions(row) { + // Construct "actions" for the given shipment row + var pk = row.pk; + + let html = ''; + + html += makeEditButton('button-shipment-edit', pk, '{% trans "Edit shipment" %}'); + + if (!options.shipped) { + html += makeIconButton('fa-truck icon-green', 'button-shipment-ship', pk, '{% trans "Complete shipment" %}'); + } + + var enable_delete = row.allocations && row.allocations.length == 0; + + html += makeDeleteButton('button-shipment-delete', pk, '{% trans "Delete shipment" %}', {disabled: !enable_delete}); + + return wrapButtons(html); + } + + function setupShipmentCallbacks() { + // Setup action button callbacks + + $(table).find('.button-shipment-edit').click(function() { + var pk = $(this).attr('pk'); + + var fields = salesOrderShipmentFields(); + + delete fields.order; + + constructForm(`{% url "api-so-shipment-list" %}${pk}/`, { + fields: fields, + title: '{% trans "Edit Shipment" %}', + refreshTable: table, + }); + }); + + $(table).find('.button-shipment-ship').click(function() { + var pk = $(this).attr('pk'); + + completeSalesOrderShipment(pk); + }); + + $(table).find('.button-shipment-delete').click(function() { + var pk = $(this).attr('pk'); + + constructForm(`{% url "api-so-shipment-list" %}${pk}/`, { + title: '{% trans "Delete Shipment" %}', + method: 'DELETE', + refreshTable: table, + }); + }); + } + + $(table).inventreeTable({ + url: '{% url "api-so-shipment-list" %}', + queryParams: filters, + original: options.params, + name: options.name || 'salesordershipment', + search: false, + paginationVAlign: 'bottom', + showColumns: true, + detailView: true, + detailViewByClick: false, + buttons: constructExpandCollapseButtons(table), + detailFilter: function(index, row) { + return row.allocations.length > 0; + }, + detailFormatter: function(index, row, element) { + return showAllocationSubTable(index, row, element, options); + }, + onPostBody: function() { + setupShipmentCallbacks(); + + // Auto-expand rows on the "pending" table + if (!options.shipped) { + $(table).bootstrapTable('expandAllRows'); + } + }, + formatNoMatches: function() { + return '{% trans "No matching shipments found" %}'; + }, + columns: [ + { + visible: false, + checkbox: true, + switchable: false, + }, + { + field: 'reference', + title: '{% trans "Shipment Reference" %}', + switchable: false, + }, + { + field: 'allocations', + title: '{% trans "Items" %}', + switchable: false, + sortable: true, + formatter: function(value, row) { + if (row && row.allocations) { + return row.allocations.length; + } else { + return '-'; + } + } + }, + { + field: 'shipment_date', + title: '{% trans "Shipment Date" %}', + sortable: true, + formatter: function(value, row) { + if (value) { + return renderDate(value); + } else { + return '{% trans "Not shipped" %}'; + } + } + }, + { + field: 'tracking_number', + title: '{% trans "Tracking" %}', + }, + { + field: 'invoice_number', + title: '{% trans "Invoice" %}', + }, + { + field: 'link', + title: '{% trans "Link" %}', + formatter: function(value) { + if (value) { + return renderLink(value, value); + } else { + return '-'; + } + } + }, + { + field: 'notes', + title: '{% trans "Notes" %}', + visible: false, + switchable: false, + // TODO: Implement 'notes' field + }, + { + title: '', + switchable: false, + formatter: function(value, row) { + return makeShipmentActions(row); + } + } + ], + }); +} + + +/** + * Allocate stock items against a SalesOrder + * + * arguments: + * - order_id: The ID / PK value for the SalesOrder + * - lines: A list of SalesOrderLineItem objects to be allocated + * + * options: + * - source_location: ID / PK of the top-level StockLocation to source stock from (or null) + */ +function allocateStockToSalesOrder(order_id, line_items, options={}) { + + function renderLineItemRow(line_item, quantity) { + // Function to render a single line_item row + + var pk = line_item.pk; + + var part = line_item.part_detail; + + var thumb = thumbnailImage(part.thumbnail || part.image); + + let delete_button = wrapButtons( + makeRemoveButton( + 'button-row-remove', + pk, + '{% trans "Remove row" %}', + ) + ); + + delete_button += '
    '; + + var quantity_input = constructField( + `items_quantity_${pk}`, + { + type: 'decimal', + min_value: 0, + value: quantity || 0, + title: '{% trans "Specify stock allocation quantity" %}', + required: true, + }, + { + hideLabels: true, + } + ); + + var stock_input = constructField( + `items_stock_item_${pk}`, + { + type: 'related field', + required: 'true', + }, + { + hideLabels: true, + } + ); + + var html = ` + + + ${thumb} ${part.full_name} + + + ${stock_input} + + + ${quantity_input} + + + + + {% trans "Part" %} + {% trans "Stock Item" %} + {% trans "Quantity" %} + + + + ${table_entries} + + `; + + constructForm(`{% url "api-so-list" %}${order_id}/allocate/`, { + method: 'POST', + fields: { + shipment: { + filters: { + order: order_id, + shipped: false, + }, + value: options.shipment || null, + auto_fill: true, + secondary: { + method: 'POST', + title: '{% trans "Add Shipment" %}', + fields: function() { + var ref = null; + + // TODO: Refactor code for getting next shipment number + inventreeGet( + '{% url "api-so-shipment-list" %}', + { + order: options.order, + }, + { + async: false, + success: function(results) { + // "predict" the next reference number + ref = results.length + 1; + + var found = false; + + while (!found) { + + var no_match = true; + + for (var ii = 0; ii < results.length; ii++) { + if (ref.toString() == results[ii].reference.toString()) { + no_match = false; + break; + } + } + + if (no_match) { + break; + } else { + ref++; + } + } + } + } + ); + + var fields = salesOrderShipmentFields(options); + + fields.reference.value = ref; + fields.reference.prefix = options.reference; + + return fields; + } + } + } + }, + preFormContent: html, + confirm: true, + confirmMessage: '{% trans "Confirm stock allocation" %}', + title: '{% trans "Allocate Stock Items to Sales Order" %}', + afterRender: function(fields, opts) { + + // Initialize source location field + var take_from_field = { + name: 'take_from', + model: 'stocklocation', + api_url: '{% url "api-location-list" %}', + required: false, + type: 'related field', + value: options.source_location || null, + noResults: function(query) { + return '{% trans "No matching stock locations" %}'; + }, + }; + + initializeRelatedField( + take_from_field, + null, + opts + ); + + // Add callback to "clear" button for take_from field + addClearCallback( + 'take_from', + take_from_field, + opts, + ); + + // Initialize fields for each line item + line_items.forEach(function(line_item) { + var pk = line_item.pk; + + initializeRelatedField( + { + name: `items_stock_item_${pk}`, + api_url: '{% url "api-stock-list" %}', + filters: { + part: line_item.part, + in_stock: true, + part_detail: true, + location_detail: true, + available: true, + }, + model: 'stockitem', + required: true, + render_part_detail: true, + render_location_detail: true, + auto_fill: true, + onSelect: function(data, field, opts) { + // Adjust the 'quantity' field based on availability + + if (!('quantity' in data)) { + return; + } + + // Calculate the available quantity + var available = Math.max((data.quantity || 0) - (data.allocated || 0), 0); + + // Remaining quantity to be allocated? + var remaining = Math.max(line_item.quantity - line_item.shipped - line_item.allocated, 0); + + // Maximum amount that we need + var desired = Math.min(available, remaining); + + updateFieldValue(`items_quantity_${pk}`, desired, {}, opts); + + }, + adjustFilters: function(filters) { + // Restrict query to the selected location + var location = getFormFieldValue( + 'take_from', + {}, + { + modal: opts.modal, + } + ); + + filters.location = location; + filters.cascade = true; + + // Exclude expired stock? + if (global_settings.STOCK_ENABLE_EXPIRY && !global_settings.STOCK_ALLOW_EXPIRED_SALE) { + filters.expired = false; + } + + return filters; + }, + noResults: function(query) { + return '{% trans "No matching stock items" %}'; + } + }, + null, + opts + ); + }); + + // Add remove-row button callbacks + $(opts.modal).find('.button-row-remove').click(function() { + var pk = $(this).attr('pk'); + + $(opts.modal).find(`#allocation_row_${pk}`).remove(); + }); + }, + onSubmit: function(fields, opts) { + // Extract data elements from the form + var data = { + items: [], + shipment: getFormFieldValue( + 'shipment', + {}, + opts + ) + }; + + var item_pk_values = []; + + line_items.forEach(function(item) { + + var pk = item.pk; + + var quantity = getFormFieldValue( + `items_quantity_${pk}`, + {}, + opts + ); + + var stock_item = getFormFieldValue( + `items_stock_item_${pk}`, + {}, + opts + ); + + if (quantity != null) { + data.items.push({ + line_item: pk, + stock_item: stock_item, + quantity: quantity, + }); + + item_pk_values.push(pk); + } + }); + + // Provide nested values + opts.nested = { + 'items': item_pk_values + }; + + inventreePut( + opts.url, + data, + { + method: 'POST', + success: function(response) { + $(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; + } + } + } + ); + }, + }); +} + + +/** + * Load a table with SalesOrderAllocation items + */ +function loadSalesOrderAllocationTable(table, options={}) { + + options.params = options.params || {}; + + options.params['location_detail'] = true; + options.params['part_detail'] = true; + options.params['item_detail'] = true; + options.params['order_detail'] = true; + + let filters = loadTableFilters('salesorderallocation', options.params); + + setupFilterList('salesorderallocation', $(table)); + + $(table).inventreeTable({ + url: '{% url "api-so-allocation-list" %}', + queryParams: filters, + name: options.name || 'salesorderallocation', + groupBy: false, + search: false, + paginationVAlign: 'bottom', + original: options.params, + formatNoMatches: function() { + return '{% trans "No sales order allocations found" %}'; + }, + columns: [ + { + field: 'pk', + visible: false, + switchable: false, + }, + { + field: 'order', + switchable: false, + title: '{% trans "Order" %}', + formatter: function(value, row) { + + var ref = `${row.order_detail.reference}`; + + return renderLink(ref, `/order/sales-order/${row.order}/`); + } + }, + { + field: 'item', + title: '{% trans "Stock Item" %}', + formatter: function(value, row) { + // Render a link to the particular stock item + + var link = `/stock/item/${row.item}/`; + var text = `{% trans "Stock Item" %} ${row.item}`; + + return renderLink(text, link); + } + }, + { + field: 'location', + title: '{% trans "Location" %}', + formatter: function(value, row) { + return locationDetail(row.item_detail, true); + } + }, + { + field: 'quantity', + title: '{% trans "Quantity" %}', + sortable: true, + }, + ] + }); +} + + +/** + * Display an "allocations" sub table, showing stock items allocated againt a sales order + * @param {*} index + * @param {*} row + * @param {*} element + */ +function showAllocationSubTable(index, row, element, options) { + + // Construct a sub-table element + var html = ` +
    +
    +
    `; + + element.html(html); + + var table = $(`#allocation-table-${row.pk}`); + + function setupCallbacks() { + // Add callbacks for 'edit' buttons + table.find('.button-allocation-edit').click(function() { + + var pk = $(this).attr('pk'); + + // Edit the sales order alloction + constructForm( + `/api/order/so-allocation/${pk}/`, + { + fields: { + quantity: {}, + }, + title: '{% trans "Edit Stock Allocation" %}', + refreshTable: options.table, + }, + ); + }); + + // Add callbacks for 'delete' buttons + table.find('.button-allocation-delete').click(function() { + var pk = $(this).attr('pk'); + + constructForm( + `/api/order/so-allocation/${pk}/`, + { + method: 'DELETE', + confirmMessage: '{% trans "Confirm Delete Operation" %}', + title: '{% trans "Delete Stock Allocation" %}', + refreshTable: options.table, + } + ); + }); + } + + table.bootstrapTable({ + onPostBody: setupCallbacks, + data: row.allocations, + showHeader: true, + columns: [ + { + field: 'part_detail', + title: '{% trans "Part" %}', + formatter: function(part, row) { + return imageHoverIcon(part.thumbnail) + renderLink(part.full_name, `/part/${part.pk}/`); + } + }, + { + field: 'allocated', + title: '{% trans "Stock Item" %}', + formatter: function(value, row, index, field) { + var text = ''; + + var item = row.item_detail; + + var text = `{% trans "Quantity" %}: ${row.quantity}`; + + if (item && item.serial != null && row.quantity == 1) { + text = `{% trans "Serial Number" %}: ${item.serial}`; + } + + return renderLink(text, `/stock/item/${row.item}/`); + }, + }, + { + field: 'location', + title: '{% trans "Location" %}', + formatter: function(value, row, index, field) { + + if (row.shipment_date) { + return `{% trans "Shipped to customer" %} - ${row.shipment_date}`; + } else if (row.location) { + // Location specified + return renderLink( + row.location_detail.pathstring || '{% trans "Location" %}', + `/stock/location/${row.location}/` + ); + } else { + return `{% trans "Stock location not specified" %}`; + } + }, + }, + { + field: 'buttons', + title: '', + formatter: function(value, row, index, field) { + + let html = ''; + let pk = row.pk; + + if (row.shipment_date) { + html += `{% trans "Shipped" %}`; + } else { + html += makeEditButton('button-allocation-edit', pk, '{% trans "Edit stock allocation" %}'); + html += makeDeleteButton('button-allocation-delete', pk, '{% trans "Delete stock allocation" %}'); + } + + return wrapButtons(html); + }, + }, + ], + }); +} + +/** + * Display a "fulfilled" sub table, showing stock items fulfilled against a purchase order + */ +function showFulfilledSubTable(index, row, element, options) { + // Construct a table showing stock items which have been fulfilled against this line item + + if (!options.order) { + return 'ERROR: Order ID not supplied'; + } + + var id = `fulfilled-table-${row.pk}`; + + var html = ` +
    + +
    +
    `; + + element.html(html); + + $(`#${id}`).bootstrapTable({ + url: '{% url "api-stock-list" %}', + queryParams: { + part: row.part, + sales_order: options.order, + location_detail: true, + }, + showHeader: true, + columns: [ + { + field: 'pk', + visible: false, + }, + { + field: 'stock', + title: '{% trans "Stock Item" %}', + 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}/`); + }, + }, + { + field: 'location', + title: '{% trans "Location" %}', + formatter: function(value, row) { + if (row.customer) { + return renderLink( + '{% trans "Shipped to customer" %}', + `/company/${row.customer}/` + ); + } else if (row.location && row.location_detail) { + return renderLink( + row.location_detail.pathstring, + `/stock/location/${row.location}`, + ); + } else { + return `{% trans "Stock location not specified" %}`; + } + } + } + ], + }); +} + + + +/** + * Load a table displaying line items for a particular SalesOrder + * + * @param {String} table : HTML ID tag e.g. '#table' + * @param {Object} options : object which contains: + * - order {integer} : pk of the SalesOrder + * - status: {integer} : status code for the order + */ +function loadSalesOrderLineItemTable(table, options={}) { + + options.table = table; + + if (!options.pending && !global_settings.SALESORDER_EDIT_COMPLETED_ORDERS) { + options.allow_edit = false; + } + + options.params = options.params || {}; + + if (!options.order) { + console.error('function called without order ID'); + return; + } + + if (!options.status) { + console.error('function called without order status'); + return; + } + + options.params.order = options.order; + options.params.part_detail = true; + options.params.allocations = true; + + var filters = loadTableFilters('salesorderlineitem', options.params); + + options.url = options.url || '{% url "api-so-line-list" %}'; + + var filter_target = options.filter_target || '#filter-list-sales-order-lines'; + + setupFilterList( + 'salesorderlineitem', + $(table), + filter_target, + { + download: true, + } + ); + + // Is the order pending? + var pending = options.pending; + + // Has the order shipped? + var shipped = options.status == {{ SalesOrderStatus.SHIPPED }}; + + // Show detail view if the PurchaseOrder is PENDING or SHIPPED + var show_detail = pending || shipped; + + // Add callbacks for expand / collapse buttons + $('#sales-lines-expand').click(function() { + $(table).bootstrapTable('expandAllRows'); + }); + + $('#sales-lines-collapse').click(function() { + $(table).bootstrapTable('collapseAllRows'); + }); + + // Table columns to display + var columns = [ + /* + { + checkbox: true, + visible: true, + switchable: false, + }, + */ + { + sortable: true, + sortName: 'part_detail.name', + field: 'part', + title: '{% trans "Part" %}', + switchable: false, + 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 '-'; + } + }, + footerFormatter: function() { + return '{% trans "Total" %}'; + }, + }, + { + sortable: true, + field: 'reference', + title: '{% trans "Reference" %}', + switchable: true, + }, + { + sortable: true, + field: 'quantity', + title: '{% trans "Quantity" %}', + footerFormatter: function(data) { + return data.map(function(row) { + return +row['quantity']; + }).reduce(function(sum, i) { + return sum + i; + }, 0); + }, + switchable: false, + }, + { + sortable: true, + field: 'sale_price', + title: '{% trans "Unit Price" %}', + formatter: function(value, row) { + return formatCurrency(row.sale_price, { + currency: row.sale_price_currency + }); + } + }, + { + field: 'total_price', + sortable: true, + title: '{% trans "Total Price" %}', + formatter: function(value, row) { + return formatCurrency(row.sale_price * row.quantity, { + currency: row.sale_price_currency, + }); + }, + footerFormatter: function(data) { + return calculateTotalPrice( + data, + function(row) { + return row.sale_price ? row.sale_price * row.quantity : null; + }, + function(row) { + return row.sale_price_currency; + } + ); + } + }, + { + field: 'target_date', + title: '{% trans "Target Date" %}', + sortable: true, + switchable: true, + formatter: function(value, row) { + if (row.target_date) { + var html = renderDate(row.target_date); + + if (row.overdue) { + html += makeIconBadge('fa-calendar-times', '{% trans "This line item is overdue" %}'); + } + + return html; + + } else if (row.order_detail && row.order_detail.target_date) { + return `${renderDate(row.order_detail.target_date)}`; + } else { + return '-'; + } + } + } + ]; + + if (pending) { + columns.push( + { + field: 'stock', + title: '{% trans "Available Stock" %}', + formatter: function(value, row) { + var available = row.available_stock; + var required = Math.max(row.quantity - row.allocated - row.shipped, 0); + + var html = ''; + + if (available > 0) { + var url = `/part/${row.part}/?display=part-stock`; + + var text = available; + + html = renderLink(text, url); + } else { + html += `{% trans "No Stock Available" %}`; + } + + if (required > 0) { + if (available >= required) { + html += makeIconBadge('fa-check-circle icon-green', '{% trans "Sufficient stock available" %}'); + } else { + html += makeIconBadge('fa-times-circle icon-red', '{% trans "Insufficient stock available" %}'); + } + } + + return html; + }, + }, + ); + + columns.push( + { + field: 'allocated', + title: '{% trans "Allocated" %}', + switchable: false, + sortable: true, + formatter: function(value, row, index, field) { + return makeProgressBar(row.allocated, row.quantity, { + id: `order-line-progress-${row.pk}`, + }); + }, + sorter: function(valA, valB, rowA, rowB) { + + var A = rowA.allocated; + var B = rowB.allocated; + + 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; + } + }, + ); + } + + columns.push({ + field: 'shipped', + title: '{% trans "Shipped" %}', + switchable: false, + sortable: true, + formatter: function(value, row) { + return makeProgressBar(row.shipped, row.quantity, { + id: `order-line-shipped-${row.pk}` + }); + }, + sorter: function(valA, valB, rowA, rowB) { + var A = rowA.shipped; + var B = rowB.shipped; + + 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; + } + }); + + columns.push({ + field: 'notes', + title: '{% trans "Notes" %}', + }); + + if (pending) { + columns.push({ + field: 'buttons', + switchable: false, + formatter: function(value, row, index, field) { + + let buttons = ''; + + var pk = row.pk; + + if (row.part) { + var part = row.part_detail; + + if (part.trackable) { + buttons += makeIconButton('fa-hashtag icon-green', 'button-add-by-sn', pk, '{% trans "Allocate serial numbers" %}'); + } + + buttons += makeIconButton('fa-sign-in-alt icon-green', 'button-add', pk, '{% trans "Allocate stock" %}'); + + if (part.purchaseable) { + buttons += makeIconButton('fa-shopping-cart', 'button-buy', row.part, '{% trans "Purchase stock" %}'); + } + + if (part.assembly) { + buttons += makeIconButton('fa-tools', 'button-build', row.part, '{% trans "Build stock" %}'); + } + + buttons += makeIconButton('fa-dollar-sign icon-green', 'button-price', pk, '{% trans "Calculate price" %}'); + } + + buttons += makeCopyButton('button-duplicate', pk, '{% trans "Duplicate line item" %}'); + buttons += makeEditButton('button-edit', pk, '{% trans "Edit line item" %}'); + + var delete_disabled = false; + + var title = '{% trans "Delete line item" %}'; + + if (!!row.shipped) { + delete_disabled = true; + title = '{% trans "Cannot be deleted as items have been shipped" %}'; + } else if (!!row.allocated) { + delete_disabled = true; + title = '{% trans "Cannot be deleted as items have been allocated" %}'; + } + + // Prevent deletion of the line item if items have been allocated or shipped! + buttons += makeDeleteButton('button-delete', pk, title, {disabled: delete_disabled}); + + return wrapButtons(buttons); + } + }); + } + + function reloadTable() { + $(table).bootstrapTable('refresh'); + reloadTotal(); + } + + // Configure callback functions once the table is loaded + function setupCallbacks() { + + // Callback for duplicating line items + $(table).find('.button-duplicate').click(function() { + var pk = $(this).attr('pk'); + + inventreeGet(`{% url "api-so-line-list" %}${pk}/`, {}, { + success: function(data) { + + let fields = soLineItemFields(); + + constructForm('{% url "api-so-line-list" %}', { + method: 'POST', + fields: fields, + data: data, + title: '{% trans "Duplicate Line Item" %}', + refreshTable: table, + }); + } + }); + }); + + // Callback for editing line items + $(table).find('.button-edit').click(function() { + var pk = $(this).attr('pk'); + + constructForm(`{% url "api-so-line-list" %}${pk}/`, { + fields: soLineItemFields(), + title: '{% trans "Edit Line Item" %}', + onSuccess: reloadTable, + }); + }); + + // Callback for deleting line items + $(table).find('.button-delete').click(function() { + var pk = $(this).attr('pk'); + + constructForm(`{% url "api-so-line-list" %}${pk}/`, { + method: 'DELETE', + title: '{% trans "Delete Line Item" %}', + onSuccess: reloadTable, + }); + }); + + // Callback for allocating stock items by serial number + $(table).find('.button-add-by-sn').click(function() { + var pk = $(this).attr('pk'); + + inventreeGet(`{% url "api-so-line-list" %}${pk}/`, {}, + { + success: function(response) { + + constructForm(`{% url "api-so-line-list" %}${options.order}/allocate-serials/`, { + method: 'POST', + title: '{% trans "Allocate Serial Numbers" %}', + fields: { + line_item: { + value: pk, + hidden: true, + }, + quantity: {}, + serial_numbers: {}, + shipment: { + filters: { + order: options.order, + shipped: false, + }, + auto_fill: true, + } + }, + refreshTable: table, + }); + } + } + ); + }); + + // Callback for allocation stock items to the order + $(table).find('.button-add').click(function() { + var pk = $(this).attr('pk'); + + var line_item = $(table).bootstrapTable('getRowByUniqueId', pk); + + allocateStockToSalesOrder( + options.order, + [ + line_item + ], + { + order: options.order, + reference: options.reference, + success: function() { + // Reload this table + $(table).bootstrapTable('refresh'); + + // Reload the pending shipment table + $('#pending-shipments-table').bootstrapTable('refresh'); + } + } + ); + }); + + // Callback for creating a new build + $(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; + } + + // Create a new build order + newBuildOrder({ + part: pk, + sales_order: options.order, + quantity: quantity, + success: reloadTable + }); + }); + + // Callback for purchasing parts + $(table).find('.button-buy').click(function() { + var pk = $(this).attr('pk'); + + inventreeGet( + `/api/part/${pk}/`, + {}, + { + success: function(part) { + orderParts( + [part], + {} + ); + } + } + ); + }); + + // Callback for displaying price + $(table).find('.button-price').click(function() { + var pk = $(this).attr('pk'); + var idx = $(this).closest('tr').attr('data-index'); + var row = $(table).bootstrapTable('getData')[idx]; + + launchModalForm( + '{% url "line-pricing" %}', + { + submit_text: '{% trans "Calculate price" %}', + data: { + line_item: pk, + quantity: row.quantity, + }, + buttons: [ + { + name: 'update_price', + title: '{% trans "Update Unit Price" %}' + }, + ], + success: reloadTable, + } + ); + }); + } + + $(table).inventreeTable({ + onPostBody: setupCallbacks, + name: 'salesorderlineitems', + sidePagination: 'client', + formatNoMatches: function() { + return '{% trans "No matching line items" %}'; + }, + queryParams: filters, + original: options.params, + url: options.url, + showFooter: true, + uniqueId: 'pk', + detailView: show_detail, + detailViewByClick: false, + buttons: constructExpandCollapseButtons(table), + detailFilter: function(index, row) { + if (pending) { + // Order is pending + return row.allocated > 0; + } else { + return row.shipped > 0; + } + }, + detailFormatter: function(index, row, element) { + if (pending) { + return showAllocationSubTable(index, row, element, options); + } else { + return showFulfilledSubTable(index, row, element, options); + } + }, + columns: columns, + }); +} diff --git a/InvenTree/templates/js/translated/search.js b/InvenTree/templates/js/translated/search.js index 27bf72711a..513d1b17dc 100644 --- a/InvenTree/templates/js/translated/search.js +++ b/InvenTree/templates/js/translated/search.js @@ -222,7 +222,7 @@ function updateSearch() { if (checkPermission('purchase_order') && user_settings.SEARCH_PREVIEW_SHOW_PURCHASE_ORDERS) { - var filters = { + let filters = { supplier_detail: true, }; @@ -235,7 +235,7 @@ function updateSearch() { if (checkPermission('sales_order') && user_settings.SEARCH_PREVIEW_SHOW_SALES_ORDERS) { - var filters = { + let filters = { customer_detail: true, }; @@ -247,6 +247,19 @@ function updateSearch() { addSearchQuery('salesorder', '{% trans "Sales Orders" %}', filters); } + if (checkPermission('return_order') && user_settings.SEARCH_PREVIEW_SHOW_RETURN_ORDERS) { + let filters = { + customer_detail: true, + }; + + // Hide inactive (not "outstanding" orders) + if (user_settings.SEARCH_PREVIEW_EXCLUDE_INACTIVE_RETURN_ORDERS) { + filters.outstanding = true; + } + + addSearchQuery('returnorder', '{% trans "Return Orders" %}', filters); + } + let ctx = $('#offcanvas-search').find('#search-context'); ctx.html(` diff --git a/InvenTree/templates/js/translated/status_codes.js b/InvenTree/templates/js/translated/status_codes.js new file mode 100644 index 0000000000..caab07270e --- /dev/null +++ b/InvenTree/templates/js/translated/status_codes.js @@ -0,0 +1,24 @@ +{% load i18n %} +{% load status_codes %} +{% load inventree_extras %} + +/* globals +*/ + +/* exported + buildStatusDisplay, + purchaseOrderStatusDisplay, + returnOrderStatusDisplay, + returnOrderLineItemStatusDisplay, + salesOrderStatusDisplay, + stockHistoryStatusDisplay, + stockStatusDisplay, +*/ + +{% include "status_codes.html" with label='stock' options=StockStatus.list %} +{% include "status_codes.html" with label='stockHistory' options=StockHistoryCode.list %} +{% include "status_codes.html" with label='build' options=BuildStatus.list %} +{% include "status_codes.html" with label='purchaseOrder' options=PurchaseOrderStatus.list %} +{% include "status_codes.html" with label='salesOrder' options=SalesOrderStatus.list %} +{% include "status_codes.html" with label='returnOrder' options=ReturnOrderStatus.list %} +{% include "status_codes.html" with label='returnOrderLineItem' options=ReturnOrderLineStatus.list %} diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index cc6bdaf4fb..dcc8d88e59 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -24,8 +24,6 @@ modalSetTitle, modalSubmit, openModal, - printStockItemLabels, - printTestReports, renderLink, scanItemsIntoLocation, showAlertDialog, @@ -88,7 +86,7 @@ function serializeStockItem(pk, options={}) { if (options.part) { // Work out the next available serial number - inventreeGet(`/api/part/${options.part}/serial-numbers/`, {}, { + inventreeGet(`{% url "api-part-list" %}${options.part}/serial-numbers/`, {}, { success: function(data) { if (data.next) { options.fields.serial_numbers.placeholder = `{% trans "Next available serial number" %}: ${data.next}`; @@ -230,7 +228,7 @@ function stockItemFields(options={}) { enableFormInput('serial_numbers', opts); // Request part serial number information from the server - inventreeGet(`/api/part/${data.pk}/serial-numbers/`, {}, { + inventreeGet(`{% url "api-part-list" %}${data.pk}/serial-numbers/`, {}, { success: function(data) { var placeholder = ''; if (data.next) { @@ -379,7 +377,7 @@ function duplicateStockItem(pk, options) { } // First, we need the StockItem information - inventreeGet(`/api/stock/${pk}/`, {}, { + inventreeGet(`{% url "api-stock-list" %}${pk}/`, {}, { success: function(data) { // Do not duplicate the serial number @@ -656,8 +654,7 @@ function assignStockToCustomer(items, options={}) { var buttons = `
    `; - buttons += makeIconButton( - 'fa-times icon-red', + buttons += makeRemoveButton( 'button-stock-item-remove', pk, '{% trans "Remove row" %}', @@ -824,13 +821,13 @@ function mergeStockItems(items, options={}) { quantity += stockStatusDisplay(item.status, {classes: 'float-right'}); - var buttons = `
    `; - - buttons += makeIconButton( - 'fa-times icon-red', - 'button-stock-item-remove', - pk, - '{% trans "Remove row" %}', + let buttons = wrapButtons( + makeIconButton( + 'fa-times icon-red', + 'button-stock-item-remove', + pk, + '{% trans "Remove row" %}', + ) ); html += ` @@ -1094,16 +1091,11 @@ function adjustStock(action, items, options={}) { ); } - var buttons = `
    `; - - buttons += makeIconButton( - 'fa-times icon-red', + let buttons = wrapButtons(makeRemoveButton( 'button-stock-item-remove', pk, '{% trans "Remove stock item" %}', - ); - - buttons += `
    `; + )); html += ` @@ -1341,18 +1333,11 @@ function loadStockTestResultsTable(table, options) { var filterKey = options.filterKey || options.name || 'stocktests'; - var filters = loadTableFilters(filterKey); - - var params = { + let params = { part: options.part, }; - var original = {}; - - for (var k in params) { - original[k] = params[k]; - filters[k] = params[k]; - } + var filters = loadTableFilters(filterKey, params); setupFilterList(filterKey, table, filterTarget); @@ -1360,7 +1345,7 @@ function loadStockTestResultsTable(table, options) { // Helper function for rendering buttons - var html = `
    `; + let html = ''; if (row.requires_attachment == false && row.requires_value == false && !row.result) { // Enable a "quick tick" option for this test result @@ -1371,13 +1356,11 @@ function loadStockTestResultsTable(table, options) { if (!grouped && row.result != null) { var pk = row.pk; - html += makeIconButton('fa-edit icon-blue', 'button-test-edit', pk, '{% trans "Edit test result" %}'); - html += makeIconButton('fa-trash-alt icon-red', 'button-test-delete', pk, '{% trans "Delete test result" %}'); + html += makeEditButton('button-test-edit', pk, '{% trans "Edit test result" %}'); + html += makeDeleteButton('button-test-delete', pk, '{% trans "Delete test result" %}'); } - html += '
    '; - - return html; + return wrapButtons(html); } var parent_node = 'parent node'; @@ -1396,7 +1379,7 @@ function loadStockTestResultsTable(table, options) { return '{% trans "No test results found" %}'; }, queryParams: filters, - original: original, + original: params, onPostBody: function() { table.treegrid({ treeColumn: 0, @@ -1444,7 +1427,7 @@ function loadStockTestResultsTable(table, options) { var html = value; if (row.attachment) { - var text = ``; + let text = makeIconBadge('fa-file-alt', ''); html += renderLink(text, row.attachment, {download: true}); } @@ -1700,65 +1683,50 @@ function locationDetail(row, showLink=true) { } +/* Load data into a stock table with adjustable options. + * Fetches data (via AJAX) and loads into a bootstrap table. + * Also links in default button callbacks. + * + * Options: + * url - URL for the stock query + * params - query params for augmenting stock data request + * buttons - Which buttons to link to stock selection callbacks + * filterList -
      element where filters are displayed + * disableFilters: If true, disable custom filters + */ function loadStockTable(table, options) { - /* Load data into a stock table with adjustable options. - * Fetches data (via AJAX) and loads into a bootstrap table. - * Also links in default button callbacks. - * - * Options: - * url - URL for the stock query - * params - query params for augmenting stock data request - * groupByField - Column for grouping stock items - * buttons - Which buttons to link to stock selection callbacks - * filterList -
        element where filters are displayed - * disableFilters: If true, disable custom filters - */ // List of user-params which override the default filters - options.params['location_detail'] = true; options.params['part_detail'] = true; var params = options.params || {}; - var filterTarget = options.filterTarget || '#filter-list-stock'; + const filterTarget = options.filterTarget || '#filter-list-stock'; - var filters = {}; + const filterKey = options.filterKey || options.name || 'stock'; - var filterKey = options.filterKey || options.name || 'stock'; + let filters = loadTableFilters(filterKey, params); - if (!options.disableFilters) { - filters = loadTableFilters(filterKey); - } - - var original = {}; - - for (var k in params) { - original[k] = params[k]; - } - - setupFilterList(filterKey, table, filterTarget, {download: true}); + setupFilterList(filterKey, table, filterTarget, { + download: true, + report: { + url: '{% url "api-stockitem-testreport-list" %}', + key: 'item', + }, + labels: { + url: '{% url "api-stockitem-label-list" %}', + key: 'item', + } + }); // Override the default values, or add new ones for (var key in params) { filters[key] = params[key]; } - var grouping = true; - - if ('grouping' in options) { - grouping = options.grouping; - } - var col = null; - // Explicitly disable part grouping functionality - // Might be able to add this in later on, - // but there is a bug which makes this crash if paginating on the server side. - // Ref: https://github.com/wenzhixin/bootstrap-table/issues/3250 - // eslint-disable-next-line no-unused-vars - grouping = false; - var columns = [ { checkbox: true, @@ -2175,14 +2143,13 @@ function loadStockTable(table, options) { queryParams: filters, sidePagination: 'server', name: 'stock', - original: original, + original: params, showColumns: true, showFooter: true, columns: columns, }); var buttons = [ - '#stock-print-options', '#stock-options', ]; @@ -2206,31 +2173,6 @@ function loadStockTable(table, options) { } // Automatically link button callbacks - - $('#multi-item-print-label').click(function() { - var selections = getTableData(table); - - var items = []; - - selections.forEach(function(item) { - items.push(item.pk); - }); - - printStockItemLabels(items); - }); - - $('#multi-item-print-test-report').click(function() { - var selections = getTableData(table); - - var items = []; - - selections.forEach(function(item) { - items.push(item.pk); - }); - - printTestReports(items); - }); - if (global_settings.BARCODE_ENABLE) { $('#multi-item-barcode-scan-into-location').click(function() { var selections = getTableData(table); @@ -2420,21 +2362,17 @@ function loadStockLocationTable(table, options) { params.depth = global_settings.INVENTREE_TREE_DEPTH; } - var filters = {}; - var filterKey = options.filterKey || options.name || 'location'; - if (!options.disableFilters) { - filters = loadTableFilters(filterKey); - } + let filters = loadTableFilters(filterKey, params); - var original = {}; - - for (var k in params) { - original[k] = params[k]; - } - - setupFilterList(filterKey, table, filterListElement, {download: true}); + setupFilterList(filterKey, table, filterListElement, { + download: true, + labels: { + url: '{% url "api-stocklocation-label-list" %}', + key: 'location' + } + }); for (var key in params) { filters[key] = params[key]; @@ -2484,7 +2422,7 @@ function loadStockLocationTable(table, options) { url: options.url || '{% url "api-location-list" %}', queryParams: filters, name: 'location', - original: original, + original: params, sortable: true, showColumns: true, onPostBody: function() { @@ -2654,26 +2592,20 @@ function loadStockLocationTable(table, options) { }); } +/* + * Load stock history / tracking table for a given StockItem + */ function loadStockTrackingTable(table, options) { var cols = []; - var filterTarget = '#filter-list-stocktracking'; + const filterKey = 'stocktracking'; - var filterKey = 'stocktracking'; + let params = options.params || {}; - var filters = loadTableFilters(filterKey); + let filters = loadTableFilters(filterKey, params); - var params = options.params; - - var original = {}; - - for (var k in params) { - original[k] = params[k]; - filters[k] = params[k]; - } - - setupFilterList(filterKey, table, filterTarget); + setupFilterList(filterKey, table, '#filter-list-stocktracking'); // Date cols.push({ @@ -2747,10 +2679,10 @@ function loadStockTrackingTable(table, options) { html += ''; } - // Purchase Order Information + // PurchaseOrder Information if (details.purchaseorder) { - html += `{% trans "Purchase Order" %}`; + html += `{% trans "Purchase Order" %}`; html += ''; @@ -2766,6 +2698,40 @@ function loadStockTrackingTable(table, options) { html += ''; } + // SalesOrder information + if (details.salesorder) { + html += `{% trans "Sales Order" %}`; + html += ''; + + if (details.salesorder_detail) { + html += renderLink( + details.salesorder_detail.reference, + `/order/sales-order/${details.salesorder}` + ); + } else { + html += `{% trans "Sales Order no longer exists" %}`; + } + + html += ``; + } + + // ReturnOrder information + if (details.returnorder) { + html += `{% trans "Return Order" %}`; + html += ''; + + if (details.returnorder_detail) { + html += renderLink( + details.returnorder_detail.reference, + `/order/return-order/${details.returnorder}/` + ); + } else { + html += `{% trans "Return Order no longer exists" %}`; + } + + html += ``; + } + // Customer information if (details.customer) { @@ -2808,12 +2774,7 @@ function loadStockTrackingTable(table, options) { html += `{% trans "Status" %}`; html += ''; - html += stockStatusDisplay( - details.status, - { - classes: 'float-right', - } - ); + html += stockStatusDisplay(details.status); html += ''; } @@ -2865,7 +2826,7 @@ function loadStockTrackingTable(table, options) { table.inventreeTable({ method: 'get', queryParams: filters, - original: original, + original: params, columns: cols, url: options.url, }); @@ -2951,14 +2912,12 @@ function loadInstalledInTable(table, options) { title: '', switchable: false, formatter: function(value, row) { - var pk = row.pk; - var html = ''; + let pk = row.pk; + let html = ''; - html += `
        `; html += makeIconButton('fa-unlink', 'button-uninstall', pk, '{% trans "Uninstall Stock Item" %}'); - html += `
        `; - return html; + return wrapButtons(html); } } ], diff --git a/InvenTree/templates/js/translated/table_filters.js b/InvenTree/templates/js/translated/table_filters.js index 4070bf565c..8e32862f2d 100644 --- a/InvenTree/templates/js/translated/table_filters.js +++ b/InvenTree/templates/js/translated/table_filters.js @@ -2,23 +2,15 @@ {% load status_codes %} {% load inventree_extras %} -{% include "status_codes.html" with label='stock' options=StockStatus.list %} -{% include "status_codes.html" with label='stockHistory' options=StockHistoryCode.list %} -{% include "status_codes.html" with label='build' options=BuildStatus.list %} -{% include "status_codes.html" with label='purchaseOrder' options=PurchaseOrderStatus.list %} -{% include "status_codes.html" with label='salesOrder' options=SalesOrderStatus.list %} - /* globals global_settings + purchaseOrderCodes, + returnOrderCodes, + salesOrderCodes, */ /* exported - buildStatusDisplay, getAvailableTableFilters, - purchaseOrderStatusDisplay, - salesOrderStatusDisplay, - stockHistoryStatusDisplay, - stockStatusDisplay, */ @@ -26,6 +18,42 @@ function getAvailableTableFilters(tableKey) { tableKey = tableKey.toLowerCase(); + // Filters for "returnorder" table + if (tableKey == 'returnorder') { + return { + status: { + title: '{% trans "Order status" %}', + options: returnOrderCodes + }, + outstanding: { + type: 'bool', + title: '{% trans "Outstanding" %}', + }, + overdue: { + type: 'bool', + title: '{% trans "Overdue" %}', + }, + assigned_to_me: { + type: 'bool', + title: '{% trans "Assigned to me" %}', + }, + }; + } + + // Filters for "returnorderlineitem" table + if (tableKey == 'returnorderlineitem') { + return { + received: { + type: 'bool', + title: '{% trans "Received" %}', + }, + outcome: { + title: '{% trans "Outcome" %}', + options: returnOrderLineItemCodes, + } + }; + } + // Filters for "variant" table if (tableKey == 'variants') { return { @@ -363,7 +391,7 @@ function getAvailableTableFilters(tableKey) { title: '{% trans "Responsible" %}', options: function() { var ownersList = {}; - inventreeGet(`/api/user/owner/`, {}, { + inventreeGet('{% url "api-owner-list" %}', {}, { async: false, success: function(response) { for (key in response) { @@ -445,6 +473,10 @@ function getAvailableTableFilters(tableKey) { type: 'bool', title: '{% trans "Overdue" %}', }, + assigned_to_me: { + type: 'bool', + title: '{% trans "Assigned to me" %}', + }, }; } diff --git a/InvenTree/templates/js/translated/tables.js b/InvenTree/templates/js/translated/tables.js index 6de27fb9cb..65d58cb75f 100644 --- a/InvenTree/templates/js/translated/tables.js +++ b/InvenTree/templates/js/translated/tables.js @@ -9,7 +9,7 @@ customGroupSorter, downloadTableData, getTableData, - reloadtable, + reloadBootstrapTable, renderLink, reloadTableFilters, constructExpandCollapseButtons, @@ -20,8 +20,23 @@ * Reload a named table * @param table */ -function reloadtable(table) { - $(table).bootstrapTable('refresh'); +function reloadBootstrapTable(table) { + + let tbl = table; + + if (tbl) { + if (typeof tbl === 'string' || tbl instanceof String) { + tbl = $(tbl); + } + + if (tbl.exists()) { + tbl.bootstrapTable('refresh'); + } else { + console.error(`Invalid table name passed to reloadTable(): ${table}`); + } + } else { + console.error(`Null value passed to reloadTable()`); + } } @@ -127,7 +142,7 @@ function constructExpandCollapseButtons(table, idx=0) { */ function getTableData(table, allowEmpty=false) { - var data = $(table).bootstrapTable('getSelections'); + let data = $(table).bootstrapTable('getSelections'); if (data.length == 0 && !allowEmpty) { data = $(table).bootstrapTable('getData'); diff --git a/InvenTree/templates/navbar.html b/InvenTree/templates/navbar.html index 620aaf3eff..1be812c509 100644 --- a/InvenTree/templates/navbar.html +++ b/InvenTree/templates/navbar.html @@ -47,18 +47,23 @@ {% endif %} - {% if roles.sales_order.view %} + {% if roles.sales_order.view or roles.return_order.view %} {% endif %} diff --git a/InvenTree/templates/stock_table.html b/InvenTree/templates/stock_table.html index d609e78253..6484f74f0c 100644 --- a/InvenTree/templates/stock_table.html +++ b/InvenTree/templates/stock_table.html @@ -22,18 +22,6 @@
    {% endif %} - -
    - - -
    {% if not read_only %} {% if roles.stock.change or roles.stock.delete %}
    diff --git a/InvenTree/users/admin.py b/InvenTree/users/admin.py index d26a7f97fc..693840187d 100644 --- a/InvenTree/users/admin.py +++ b/InvenTree/users/admin.py @@ -85,7 +85,7 @@ class RoleGroupAdmin(admin.ModelAdmin): # pragma: no cover ] list_display = ('name', 'admin', 'part_category', 'part', 'stocktake', 'stock_location', - 'stock_item', 'build', 'purchase_order', 'sales_order') + 'stock_item', 'build', 'purchase_order', 'sales_order', 'return_order') def get_rule_set(self, obj, rule_set_type): """Return list of permissions for the given ruleset.""" @@ -161,6 +161,10 @@ class RoleGroupAdmin(admin.ModelAdmin): # pragma: no cover """Return the ruleset for the SalesOrder role""" return self.get_rule_set(obj, 'sales_order') + def return_order(self, obj): + """Return the ruleset ofr the ReturnOrder role""" + return self.get_rule_set(obj, 'return_order') + def get_formsets_with_inlines(self, request, obj=None): """Return all inline formsets""" for inline in self.get_inline_instances(request, obj): diff --git a/InvenTree/users/migrations/0007_alter_ruleset_name.py b/InvenTree/users/migrations/0007_alter_ruleset_name.py new file mode 100644 index 0000000000..b38511dc30 --- /dev/null +++ b/InvenTree/users/migrations/0007_alter_ruleset_name.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.18 on 2023-03-14 10:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0006_alter_ruleset_name'), + ] + + operations = [ + migrations.AlterField( + model_name='ruleset', + name='name', + field=models.CharField(choices=[('admin', 'Admin'), ('part_category', 'Part Categories'), ('part', 'Parts'), ('stocktake', 'Stocktake'), ('stock_location', 'Stock Locations'), ('stock', 'Stock Items'), ('build', 'Build Orders'), ('purchase_order', 'Purchase Orders'), ('sales_order', 'Sales Orders'), ('return_order', 'Return Orders')], help_text='Permission set', max_length=50), + ), + ] diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index e68a00a539..9d4948bdc7 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -42,6 +42,7 @@ class RuleSet(models.Model): ('build', _('Build Orders')), ('purchase_order', _('Purchase Orders')), ('sales_order', _('Sales Orders')), + ('return_order', _('Return Orders')), ] RULESET_NAMES = [ @@ -135,6 +136,7 @@ class RuleSet(models.Model): 'purchase_order': [ 'company_company', 'company_companyattachment', + 'company_contact', 'company_manufacturerpart', 'company_manufacturerpartparameter', 'company_supplierpart', @@ -148,6 +150,7 @@ class RuleSet(models.Model): 'sales_order': [ 'company_company', 'company_companyattachment', + 'company_contact', 'order_salesorder', 'order_salesorderallocation', 'order_salesorderattachment', @@ -155,6 +158,16 @@ class RuleSet(models.Model): 'order_salesorderextraline', 'order_salesordershipment', 'report_salesorderreport', + ], + 'return_order': [ + 'company_company', + 'company_companyattachment', + 'company_contact', + 'order_returnorder', + 'order_returnorderlineitem', + 'order_returnorderextraline', + 'order_returnorderattachment', + 'report_returnorderreport', ] } @@ -172,7 +185,6 @@ class RuleSet(models.Model): 'common_webhookmessage', 'common_notificationentry', 'common_notificationmessage', - 'company_contact', 'users_owner', # Third-party tables @@ -505,6 +517,26 @@ def clear_user_role_cache(user): cache.delete(key) +def get_user_roles(user): + """Return all roles available to a given user""" + + roles = set() + + for group in user.groups.all(): + for rule in group.rule_sets.all(): + name = rule.name + if rule.can_view: + roles.add(f'{name}.view') + if rule.can_add: + roles.add(f'{name}.add') + if rule.can_change: + roles.add(f'{name}.change') + if rule.can_delete: + roles.add(f'{name}.delete') + + return roles + + def check_user_role(user, role, permission): """Check if a user has a particular role:permission combination. diff --git a/tasks.py b/tasks.py index 85501e024e..e6e602f133 100644 --- a/tasks.py +++ b/tasks.py @@ -578,7 +578,7 @@ def setup_test(c, ignore_update=False, dev=False, path="inventree-demo-dataset") # Get test data print("Cloning demo dataset ...") - c.run(f'git clone https://github.com/inventree/demo-dataset {path} -v') + c.run(f'git clone https://github.com/inventree/demo-dataset {path} -v --depth=1') print("========================================") # Make sure migrations are done - might have just deleted sqlite database