diff --git a/InvenTree/InvenTree/api.py b/InvenTree/InvenTree/api.py index d68ecd67ad..abb6102a6f 100644 --- a/InvenTree/InvenTree/api.py +++ b/InvenTree/InvenTree/api.py @@ -18,9 +18,10 @@ from .version import inventreeVersion, inventreeInstanceName from plugins import plugins as inventree_plugins # Load barcode plugins -print("INFO: Loading plugins") - +print("Loading barcode plugins") barcode_plugins = inventree_plugins.load_barcode_plugins() + +print("Loading action plugins") action_plugins = inventree_plugins.load_action_plugins() @@ -136,7 +137,4 @@ class BarcodePluginView(APIView): # Include the original barcode data response['barcode_data'] = barcode_data - print("Response:") - print(response) - return Response(response) diff --git a/InvenTree/InvenTree/context.py b/InvenTree/InvenTree/context.py new file mode 100644 index 0000000000..7de41eef15 --- /dev/null +++ b/InvenTree/InvenTree/context.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- + +""" +Provides extra global data to all templates. +""" + +from InvenTree.status_codes import SalesOrderStatus, PurchaseOrderStatus +from InvenTree.status_codes import BuildStatus, StockStatus + + +def status_codes(request): + + return { + # Expose the StatusCode classes to the templates + 'SalesOrderStatus': SalesOrderStatus, + 'PurchaseOrderStatus': PurchaseOrderStatus, + 'BuildStatus': BuildStatus, + 'StockStatus': StockStatus, + } diff --git a/InvenTree/InvenTree/fields.py b/InvenTree/InvenTree/fields.py index 4d8ab9ef82..ba3648da30 100644 --- a/InvenTree/InvenTree/fields.py +++ b/InvenTree/InvenTree/fields.py @@ -69,5 +69,7 @@ class RoundingDecimalField(models.DecimalField): defaults = { 'form_class': RoundingDecimalFormField } + defaults.update(kwargs) - return super(RoundingDecimalField, self).formfield(**kwargs) + + return super().formfield(**kwargs) diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index fa6bf96ec7..b787668a9d 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -145,8 +145,10 @@ TEMPLATES = [ 'context_processors': [ 'django.template.context_processors.debug', 'django.template.context_processors.request', + 'django.template.context_processors.i18n', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', + 'InvenTree.context.status_codes', ], }, }, @@ -203,10 +205,12 @@ When running unit tests, enforce usage of sqlite3 database, so that the tests can be run in RAM without any setup requirements """ if 'test' in sys.argv: - eprint('Running tests - Using sqlite3 memory database') + eprint('InvenTree: Running tests - Using sqlite3 memory database') DATABASES['default'] = { + # Ensure sqlite3 backend is being used 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': 'test_db.sqlite3' + # Doesn't matter what the database is called, it is executed in RAM + 'NAME': 'ram_test_db.sqlite3', } # Database backend selection diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index 3724835621..2a1215d7a8 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -3,6 +3,12 @@ --secondary-color: #b69c80; --highlight-color: #f5efe8; --basic-color: #333; + + --label-red: #e35a57; + --label-blue: #4194bd; + --label-green: #50aa51; + --label-grey: #aaa; + --label-yellow: #fdc82a; } .markdownx .row { @@ -29,6 +35,38 @@ padding: 10px; } +/* Progress bars */ + +.progress { + position: relative; + width: 100%; + margin-bottom: 0px; + background: #eeeef5; +} + +.progress-bar { + opacity: 60%; + background: #2aa02a; +} + +.progress-bar-under { + background: #eeaa33; +} + +.progress-bar-over { + background: #337ab7; +} + +.progress-value { + width: 100%; + color: #333; + position: absolute; + text-align: center; + top: 0px; + left: 0px; + font-size: 110%; +} + .qr-code { max-width: 400px; max-height: 400px; @@ -79,24 +117,20 @@ color: rgb(13, 245, 25); } -.glyphicon-ok { - color: #5C5; +.icon-red { + color: #c55; } -.glyphicon-ok-circle { +.icon-green { + color: #43bb43; +} + +.icon-blue { color: #55c; } -.glyphicon-remove { - color: #C55; -} - -.glyphicon-trash { - color: #C55; -} - -.glyphicon-plus { - color: #5C5; +.icon-yellow { + color: #CC2; } /* CSS overrides for treeview */ @@ -121,6 +155,58 @@ .label-large { margin: 3px; font-size: 100%; + border: 3px solid; + border-radius: 15px; + background: none; + padding-right: 10px; + padding-left: 10px; + padding-top: 5px; + padding-bottom: 5px; +} + +.label-large-red { + color: var(--label-red); + border-color: var(--label-red); +} + +.label-red { + background: var(--label-red); +} + +.label-large-blue { + color: var(--label-blue); + border-color: var(--label-blue); +} + +.label-blue { + background: var(--label-blue); +} + +.label-large-green { + color: var(--label-green); + border-color: var(--label-green); +} + +.label-green { + background: var(--label-green); +} + +.label-large-grey { + color: var(--label-grey); + border-color: var(--label-grey); +} + +.label-grey { + background: var(--label-grey); +} + +.label-large-yellow { + color: var(--label-yellow); + border-color: var(--label-yellow); +} + +.label-yellow { + background: var(--label-yellow); } .label-right { @@ -135,6 +221,15 @@ background-color: #ebf4f4; } +.sub-table { + margin-left: 45px; + margin-right: 45px; +} + +.detail-icon .glyphicon { + color: #98d296; +} + /* Force select2 elements in modal forms to be full width */ .select-full-width { width: 100%; @@ -248,7 +343,6 @@ /* dropzone class - for Drag-n-Drop file uploads */ .dropzone { - border: 1px solid #555; z-index: 2; } @@ -290,6 +384,20 @@ padding-bottom: 2px; } +.action-buttons .btn { + font-size: 175%; + align-content: center; + vertical-align: middle; + padding-left: 6px; + padding-right: 6px; + padding-top: 3px; + padding-bottom: 2px; +}; + +.panel-heading .badge { + float: right; +} + .badge { float: right; background-color: #777; @@ -308,6 +416,8 @@ margin: 2px; padding: 3px; object-fit: contain; + border: 1px solid #aaa; + border-radius: 3px; } .part-thumb-container:hover .part-thumb-overlay { diff --git a/InvenTree/InvenTree/static/script/inventree/api.js b/InvenTree/InvenTree/static/script/inventree/api.js index 8c67f92979..0a3b8d9374 100644 --- a/InvenTree/InvenTree/static/script/inventree/api.js +++ b/InvenTree/InvenTree/static/script/inventree/api.js @@ -25,7 +25,6 @@ function inventreeGet(url, filters={}, options={}) { dataType: 'json', contentType: 'application/json', success: function(response) { - console.log('Success GET data at ' + url); if (options.success) { options.success(response); } @@ -64,7 +63,6 @@ function inventreeFormDataUpload(url, data, options={}) { processData: false, contentType: false, success: function(data, status, xhr) { - console.log('Form data upload success'); if (options.success) { options.success(data, status, xhr); } @@ -97,7 +95,6 @@ function inventreePut(url, data={}, options={}) { dataType: 'json', contentType: 'application/json', success: function(response, status) { - console.log(method + ' - ' + url + ' : result = ' + status); if (options.success) { options.success(response, status); } @@ -114,25 +111,3 @@ function inventreePut(url, data={}, options={}) { } }); } - -// Return list of parts with optional filters -function getParts(filters={}, options={}) { - return inventreeGet('/api/part/', filters, options); -} - -// Return list of part categories with optional filters -function getPartCategories(filters={}, options={}) { - return inventreeGet('/api/part/category/', filters, options); -} - -function getCompanies(filters={}, options={}) { - return inventreeGet('/api/company/', filters, options); -} - -function updateStockItem(pk, data, final=false) { - return inventreePut('/api/stock/' + pk + '/', data, final); -} - -function updatePart(pk, data, final=false) { - return inventreePut('/api/part/' + pk + '/', data, final); -} \ No newline at end of file diff --git a/InvenTree/InvenTree/static/script/inventree/bom.js b/InvenTree/InvenTree/static/script/inventree/bom.js index 3d1e8fc594..def3910999 100644 --- a/InvenTree/InvenTree/static/script/inventree/bom.js +++ b/InvenTree/InvenTree/static/script/inventree/bom.js @@ -221,7 +221,6 @@ function loadBomTable(table, options) { } } }); - } // Part notes diff --git a/InvenTree/InvenTree/static/script/inventree/build.js b/InvenTree/InvenTree/static/script/inventree/build.js index e61fa397a6..28767c99b4 100644 --- a/InvenTree/InvenTree/static/script/inventree/build.js +++ b/InvenTree/InvenTree/static/script/inventree/build.js @@ -1,4 +1,5 @@ function loadBuildTable(table, options) { + // Display a table of Build objects var params = options.params || {}; diff --git a/InvenTree/InvenTree/static/script/inventree/filters.js b/InvenTree/InvenTree/static/script/inventree/filters.js index de04bcd86d..8c9ebbec6d 100644 --- a/InvenTree/InvenTree/static/script/inventree/filters.js +++ b/InvenTree/InvenTree/static/script/inventree/filters.js @@ -18,6 +18,8 @@ function defaultFilters() { build: "", parts: "cascade=1", company: "", + salesorder: "", + purchaseorder: "", }; } diff --git a/InvenTree/InvenTree/static/script/inventree/inventree.js b/InvenTree/InvenTree/static/script/inventree/inventree.js index 04539c6d96..5995034241 100644 --- a/InvenTree/InvenTree/static/script/inventree/inventree.js +++ b/InvenTree/InvenTree/static/script/inventree/inventree.js @@ -78,6 +78,59 @@ function getImageUrlFromTransfer(transfer) { return url; } +function makeIconButton(icon, cls, pk, title) { + // Construct an 'icon button' using the fontawesome set + + var classes = `btn btn-default btn-glyph ${cls}`; + + var id = `${cls}-${pk}`; + + var html = ''; + + html += ``; + + return html; +} + +function makeProgressBar(value, maximum, opts) { + /* + * Render a progessbar! + * + * @param value is the current value of the progress bar + * @param maximum is the maximum value of the progress bar + */ + + var options = opts || {}; + + value = parseFloat(value); + maximum = parseFloat(maximum); + + var percent = parseInt(value / maximum * 100); + + if (percent > 100) { + percent = 100; + } + + var extraclass = ''; + + if (value > maximum) { + extraclass='progress-bar-over'; + } else if (value < maximum) { + extraclass = 'progress-bar-under'; + } + + var id = options.id || 'progress-bar'; + + return ` +
+
+
${value} / ${maximum}
+
+ `; +} + function enableDragAndDrop(element, url, options) { /* Enable drag-and-drop file uploading for a given element. diff --git a/InvenTree/InvenTree/static/script/inventree/order.js b/InvenTree/InvenTree/static/script/inventree/order.js index d583ebcdc4..c4e39d9e1d 100644 --- a/InvenTree/InvenTree/static/script/inventree/order.js +++ b/InvenTree/InvenTree/static/script/inventree/order.js @@ -108,13 +108,13 @@ function loadPurchaseOrderTable(table, options) { options.params['supplier_detail'] = true; - var filters = loadTableFilters("order"); + var filters = loadTableFilters("purchaseorder"); for (var key in options.params) { filters[key] = options.params[key]; } - setupFilterList("order", $(table)); + setupFilterList("purchaseorder", $(table)); $(table).inventreeTable({ url: options.url, @@ -145,9 +145,9 @@ function loadPurchaseOrderTable(table, options) { } }, { + field: 'supplier_reference', + title: 'Supplier Reference', sortable: true, - field: 'creation_date', - title: 'Date', }, { sortable: true, @@ -159,9 +159,92 @@ function loadPurchaseOrderTable(table, options) { field: 'status', title: 'Status', formatter: function(value, row, index, field) { - return orderStatusDisplay(row.status, row.status_text); + return purchaseOrderStatusDisplay(row.status, row.status_text); } }, + { + sortable: true, + field: 'creation_date', + title: 'Date', + }, + { + sortable: true, + field: 'line_items', + title: 'Items' + }, + ], + }); +} + +function loadSalesOrderTable(table, options) { + + options.params = options.params || {}; + options.params['customer_detail'] = true; + + var filters = loadTableFilters("salesorder"); + + for (var key in options.params) { + filters[key] = options.params[key]; + } + + setupFilterList("salesorder", $(table)); + + $(table).inventreeTable({ + url: options.url, + queryParams: filters, + groupBy: false, + original: options.params, + formatNoMatches: function() { return "No sales orders found"; }, + columns: [ + { + field: 'pk', + title: 'ID', + visible: false, + }, + { + sortable: true, + field: 'reference', + title: 'Sales Order', + formatter: function(value, row, index, field) { + return renderLink(value, `/order/sales-order/${row.pk}/`); + }, + }, + { + sortable: true, + field: 'customer_detail', + title: 'Customer', + formatter: function(value, row, index, field) { + return imageHoverIcon(row.customer_detail.image) + renderLink(row.customer_detail.name, `/company/${row.customer}/sales-orders/`); + } + }, + { + field: 'customer_reference', + title: 'Customer Reference', + sotrable: true, + }, + { + sortable: true, + field: 'description', + title: 'Description', + }, + { + sortable: true, + field: 'status', + title: 'Status', + formatter: function(value, row, index, field) { + return salesOrderStatusDisplay(row.status, row.status_text); + } + }, + { + sortable: true, + field: 'creation_date', + title: 'Creation Date', + }, + { + sortable: true, + field: 'shipment_date', + title: "Shipment Date", + }, { sortable: true, field: 'line_items', diff --git a/InvenTree/InvenTree/static/script/inventree/part.js b/InvenTree/InvenTree/static/script/inventree/part.js index 7dc2c6a82a..92460a51a7 100644 --- a/InvenTree/InvenTree/static/script/inventree/part.js +++ b/InvenTree/InvenTree/static/script/inventree/part.js @@ -50,7 +50,7 @@ function toggleStar(options) { { method: 'POST', success: function(response, status) { - $(options.button).removeClass('glyphicon-star-empty').addClass('glyphicon-star'); + $(options.button).addClass('icon-yellow'); }, } ); @@ -64,7 +64,7 @@ function toggleStar(options) { { method: 'DELETE', success: function(response, status) { - $(options.button).removeClass('glyphicon-star').addClass('glyphicon-star-empty'); + $(options.button).removeClass('icon-yellow'); }, } ); @@ -158,6 +158,10 @@ function loadPartTable(table, url, options={}) { display += ``; } + if (row.salable) { + display += ``; + } + /* if (row.component) { display = display + ``; diff --git a/InvenTree/InvenTree/static/script/inventree/stock.js b/InvenTree/InvenTree/static/script/inventree/stock.js index 3fe11ed087..e21971bb0f 100644 --- a/InvenTree/InvenTree/static/script/inventree/stock.js +++ b/InvenTree/InvenTree/static/script/inventree/stock.js @@ -228,8 +228,10 @@ function loadStockTable(table, options) { } else { url = `/part/${row.part}/`; } + + html = imageHoverIcon(thumb) + renderLink(name, url); - return imageHoverIcon(thumb) + renderLink(name, url); + return html; } }, { @@ -255,9 +257,18 @@ function loadStockTable(table, options) { val = +val.toFixed(5); } - var text = renderLink(val, '/stock/item/' + row.pk + '/'); + var html = renderLink(val, `/stock/item/${row.pk}/`); - return text; + if (row.allocated) { + html += ``; + } + + // 70 = "LOST" + if (row.status == 70) { + html += ``; + } + + return html; } }, { diff --git a/InvenTree/InvenTree/status_codes.py b/InvenTree/InvenTree/status_codes.py index a07a8d6f99..efb76b86fa 100644 --- a/InvenTree/InvenTree/status_codes.py +++ b/InvenTree/InvenTree/status_codes.py @@ -7,12 +7,10 @@ class StatusCode: This is used to map a set of integer values to text. """ - labels = {} - @classmethod - def render(cls, key): + def render(cls, key, large=False): """ - Render the value as a label. + Render the value as a HTML label. """ # If the key cannot be found, pass it back @@ -20,12 +18,17 @@ class StatusCode: return key value = cls.options.get(key, key) - label = cls.labels.get(key, None) + color = cls.colors.get(key, 'grey') - if label: - return "{value}".format(label=label, value=value) + if large: + span_class = 'label label-large label-large-{c}'.format(c=color) else: - return value + span_class = 'label label-{c}'.format(c=color) + + return "{value}".format( + cl=span_class, + value=value + ) @classmethod def list(cls): @@ -42,10 +45,10 @@ class StatusCode: 'value': cls.options[key] } - label = cls.labels.get(key) + color = cls.colors.get(key, None) - if label: - opt['label'] = label + if color: + opt['color'] = color codes.append(opt) @@ -70,11 +73,14 @@ class StatusCode: raise ValueError("Label not found") -class OrderStatus(StatusCode): +class PurchaseOrderStatus(StatusCode): + """ + Defines a set of status codes for a PurchaseOrder + """ # Order status codes PENDING = 10 # Order is pending (not yet placed) - PLACED = 20 # Order has been placed + PLACED = 20 # Order has been placed with supplier COMPLETE = 30 # Order has been completed CANCELLED = 40 # Order was cancelled LOST = 50 # Order was lost @@ -89,13 +95,13 @@ class OrderStatus(StatusCode): RETURNED: _("Returned"), } - labels = { - PENDING: "primary", - PLACED: "primary", - COMPLETE: "success", - CANCELLED: "danger", - LOST: "warning", - RETURNED: "warning", + colors = { + PENDING: 'blue', + PLACED: 'blue', + COMPLETE: 'green', + CANCELLED: 'red', + LOST: 'yellow', + RETURNED: 'yellow', } # Open orders @@ -112,6 +118,32 @@ class OrderStatus(StatusCode): ] +class SalesOrderStatus(StatusCode): + """ Defines a set of status codes for a SalesOrder """ + + PENDING = 10 # Order is pending + SHIPPED = 20 # Order has been shipped to customer + CANCELLED = 40 # Order has been cancelled + LOST = 50 # Order was lost + RETURNED = 60 # Order was returned + + options = { + PENDING: _("Pending"), + SHIPPED: _("Shipped"), + CANCELLED: _("Cancelled"), + LOST: _("Lost"), + RETURNED: _("Returned"), + } + + colors = { + PENDING: 'blue', + SHIPPED: 'green', + CANCELLED: 'red', + LOST: 'yellow', + RETURNED: 'yellow', + } + + class StockStatus(StatusCode): OK = 10 # Item is OK @@ -119,6 +151,15 @@ class StockStatus(StatusCode): DAMAGED = 55 # Item is damaged DESTROYED = 60 # Item is destroyed LOST = 70 # Item has been lost + RETURNED = 85 # Item has been returned from a customer + + # Any stock code above 100 means that the stock item is not "in stock" + # This can be used as a quick check for filtering + NOT_IN_STOCK = 100 + + SHIPPED = 110 # Item has been shipped to a customer + ASSIGNED_TO_BUILD = 120 + ASSIGNED_TO_OTHER_ITEM = 130 options = { OK: _("OK"), @@ -126,12 +167,20 @@ class StockStatus(StatusCode): DAMAGED: _("Damaged"), DESTROYED: _("Destroyed"), LOST: _("Lost"), + RETURNED: _("Returned"), + SHIPPED: _('Shipped'), + ASSIGNED_TO_BUILD: _("Used for Build"), + ASSIGNED_TO_OTHER_ITEM: _("Installed in Stock Item") } - labels = { - OK: 'success', - ATTENTION: 'warning', - DAMAGED: 'danger', + colors = { + OK: 'green', + ATTENTION: 'yellow', + DAMAGED: 'red', + DESTROYED: 'red', + SHIPPED: 'green', + ASSIGNED_TO_BUILD: 'blue', + ASSIGNED_TO_OTHER_ITEM: 'blue', } # The following codes correspond to parts that are 'available' or 'in stock' @@ -139,12 +188,16 @@ class StockStatus(StatusCode): OK, ATTENTION, DAMAGED, + RETURNED, ] # The following codes correspond to parts that are 'unavailable' UNAVAILABLE_CODES = [ DESTROYED, LOST, + SHIPPED, + ASSIGNED_TO_BUILD, + ASSIGNED_TO_OTHER_ITEM, ] @@ -163,11 +216,11 @@ class BuildStatus(StatusCode): COMPLETE: _("Complete"), } - labels = { - PENDING: 'primary', - ALLOCATED: 'info', - COMPLETE: 'success', - CANCELLED: 'danger', + colors = { + PENDING: 'blue', + ALLOCATED: 'blue', + COMPLETE: 'green', + CANCELLED: 'red', } ACTIVE_CODES = [ diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index d9600333f4..cf53667fb9 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -25,7 +25,7 @@ from part.api import part_api_urls, bom_api_urls from company.api import company_api_urls from stock.api import stock_api_urls from build.api import build_api_urls -from order.api import po_api_urls +from order.api import order_api_urls from django.conf import settings from django.conf.urls.static import static @@ -49,7 +49,7 @@ apipatterns = [ url(r'^company/', include(company_api_urls)), url(r'^stock/', include(stock_api_urls)), url(r'^build/', include(build_api_urls)), - url(r'^po/', include(po_api_urls)), + url(r'^order/', include(order_api_urls)), # User URLs url(r'^user/', include(user_urls)), @@ -73,11 +73,17 @@ settings_urls = [ url(r'^.*$', SettingsView.as_view(template_name='InvenTree/settings/user.html'), name='settings'), ] +dynamic_javascript_urls = [ +] + urlpatterns = [ url(r'^part/', include(part_urls)), url(r'^supplier-part/', include(supplier_part_urls)), url(r'^price-break/', include(price_break_urls)), + # "Dynamic" javascript files which are rendered using InvenTree templating. + url(r'^dynamic/', include(dynamic_javascript_urls)), + url(r'^common/', include(common_urls)), url(r'^stock/', include(stock_urls)), diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index 213cad3d81..3ac225fd6e 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -6,7 +6,7 @@ import subprocess from common.models import InvenTreeSetting import django -INVENTREE_SW_VERSION = "0.0.13 pre" +INVENTREE_SW_VERSION = "0.1.0 pre" def inventreeInstanceName(): diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index 5c059efaf8..22514ee7b7 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -38,6 +38,7 @@ class BuildList(generics.ListCreateAPIView): ] filter_fields = [ + 'sales_order', ] def get_queryset(self): @@ -46,21 +47,27 @@ class BuildList(generics.ListCreateAPIView): as some of the fields don't natively play nicely with DRF """ - build_list = super().get_queryset() + queryset = super().get_queryset().prefetch_related('part') - # Filter by part - part = self.request.query_params.get('part', None) + return queryset + + def filter_queryset(self, queryset): - if part is not None: - build_list = build_list.filter(part=part) + queryset = super().filter_queryset(queryset) # Filter by build status? status = self.request.query_params.get('status', None) if status is not None: - build_list = build_list.filter(status=status) + queryset = queryset.filter(status=status) - return build_list + # Filter by associated part? + part = self.request.query_params.get('part', None) + + if part is not None: + queryset = queryset.filter(part=part) + + return queryset def get_serializer(self, *args, **kwargs): @@ -99,20 +106,25 @@ class BuildItemList(generics.ListCreateAPIView): to allow filtering by stock_item.part """ - # Does the user wish to filter by part? - part_pk = self.request.query_params.get('part', None) - query = BuildItem.objects.all() query = query.select_related('stock_item') query = query.prefetch_related('stock_item__part') query = query.prefetch_related('stock_item__part__category') - if part_pk: - query = query.filter(stock_item__part=part_pk) - return query + def filter_queryset(self, queryset): + queryset = super().filter_queryset(queryset) + + # Does the user wish to filter by part? + part_pk = self.request.query_params.get('part', None) + + if part_pk: + queryset = queryset.filter(stock_item__part=part_pk) + + return queryset + permission_classes = [ permissions.IsAuthenticated, ] @@ -132,7 +144,7 @@ build_item_api_urls = [ ] build_api_urls = [ - url(r'^item/?', include(build_item_api_urls)), + url(r'^item/', include(build_item_api_urls)), url(r'^(?P\d+)/', BuildDetail.as_view(), name='api-build-detail'), diff --git a/InvenTree/build/fixtures/build.yaml b/InvenTree/build/fixtures/build.yaml index c46afe625c..47e77dff07 100644 --- a/InvenTree/build/fixtures/build.yaml +++ b/InvenTree/build/fixtures/build.yaml @@ -10,6 +10,10 @@ status: 10 # PENDING creation_date: '2019-03-16' link: http://www.google.com + level: 0 + lft: 0 + rght: 0 + tree_id: 0 - model: build.build fields: @@ -19,4 +23,8 @@ status: 40 # COMPLETE quantity: 21 notes: 'Some more simple notes' - creation_date: '2019-03-16' \ No newline at end of file + creation_date: '2019-03-16' + level: 0 + lft: 0 + rght: 0 + tree_id: 1 \ No newline at end of file diff --git a/InvenTree/build/forms.py b/InvenTree/build/forms.py index e060ff644d..d9f497da4a 100644 --- a/InvenTree/build/forms.py +++ b/InvenTree/build/forms.py @@ -22,6 +22,8 @@ class EditBuildForm(HelperForm): fields = [ 'title', 'part', + 'parent', + 'sales_order', 'quantity', 'take_from', 'batch', diff --git a/InvenTree/build/migrations/0012_build_sales_order.py b/InvenTree/build/migrations/0012_build_sales_order.py new file mode 100644 index 0000000000..6b4a845a6e --- /dev/null +++ b/InvenTree/build/migrations/0012_build_sales_order.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.5 on 2020-04-24 22:51 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0029_auto_20200423_1042'), + ('build', '0011_auto_20200406_0123'), + ] + + operations = [ + migrations.AddField( + model_name='build', + name='sales_order', + field=models.ForeignKey(blank=True, help_text='SalesOrder to which this build is allocated', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='builds', to='order.SalesOrder'), + ), + ] diff --git a/InvenTree/build/migrations/0013_auto_20200425_0507.py b/InvenTree/build/migrations/0013_auto_20200425_0507.py new file mode 100644 index 0000000000..d960e416c8 --- /dev/null +++ b/InvenTree/build/migrations/0013_auto_20200425_0507.py @@ -0,0 +1,55 @@ +# Generated by Django 3.0.5 on 2020-04-25 05:07 + +from django.db import migrations, models +import django.db.models.deletion +import mptt.fields +from build.models import Build + + +def update_tree(apps, schema_editor): + # Update the Build MPTT model + Build.objects.rebuild() + + +def nupdate_tree(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('build', '0012_build_sales_order'), + ] + + operations = [ + migrations.AddField( + model_name='build', + name='level', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='build', + name='lft', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='build', + name='parent', + field=mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='children', to='build.Build'), + ), + migrations.AddField( + model_name='build', + name='rght', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='build', + name='tree_id', + field=models.PositiveIntegerField(db_index=True, default=0, editable=False), + preserve_default=False, + ), + migrations.RunPython(update_tree, reverse_code=nupdate_tree), + ] diff --git a/InvenTree/build/migrations/0014_auto_20200425_1243.py b/InvenTree/build/migrations/0014_auto_20200425_1243.py new file mode 100644 index 0000000000..c8148b6c1b --- /dev/null +++ b/InvenTree/build/migrations/0014_auto_20200425_1243.py @@ -0,0 +1,71 @@ +# Generated by Django 3.0.5 on 2020-04-25 12:43 + +import InvenTree.fields +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import markdownx.models +import mptt.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0035_auto_20200406_0045'), + ('stock', '0031_auto_20200422_0209'), + ('order', '0029_auto_20200423_1042'), + ('build', '0013_auto_20200425_0507'), + ] + + operations = [ + migrations.AlterField( + model_name='build', + name='batch', + field=models.CharField(blank=True, help_text='Batch code for this build output', max_length=100, null=True, verbose_name='Batch Code'), + ), + migrations.AlterField( + model_name='build', + name='link', + field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', verbose_name='External Link'), + ), + migrations.AlterField( + model_name='build', + name='notes', + field=markdownx.models.MarkdownxField(blank=True, help_text='Extra build notes', verbose_name='Notes'), + ), + migrations.AlterField( + model_name='build', + name='parent', + field=mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='children', to='build.Build', verbose_name='Parent Build'), + ), + migrations.AlterField( + model_name='build', + name='part', + field=models.ForeignKey(help_text='Select part to build', limit_choices_to={'active': True, 'assembly': True, 'is_template': False, 'virtual': False}, on_delete=django.db.models.deletion.CASCADE, related_name='builds', to='part.Part', verbose_name='Part'), + ), + migrations.AlterField( + model_name='build', + name='quantity', + field=models.PositiveIntegerField(default=1, help_text='Number of parts to build', validators=[django.core.validators.MinValueValidator(1)], verbose_name='Build Quantity'), + ), + migrations.AlterField( + model_name='build', + name='sales_order', + field=models.ForeignKey(blank=True, help_text='SalesOrder to which this build is allocated', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='builds', to='order.SalesOrder', verbose_name='Sales Order Reference'), + ), + migrations.AlterField( + model_name='build', + name='status', + field=models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Allocated'), (30, 'Cancelled'), (40, 'Complete')], default=10, help_text='Build status code', validators=[django.core.validators.MinValueValidator(0)], verbose_name='Build Status'), + ), + migrations.AlterField( + model_name='build', + name='take_from', + field=models.ForeignKey(blank=True, help_text='Select location to take stock from for this build (leave blank to take from any stock location)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sourcing_builds', to='stock.StockLocation', verbose_name='Source Location'), + ), + migrations.AlterField( + model_name='build', + name='title', + field=models.CharField(help_text='Brief description of the build', max_length=100, verbose_name='Build Title'), + ), + ] diff --git a/InvenTree/build/migrations/0015_auto_20200425_1350.py b/InvenTree/build/migrations/0015_auto_20200425_1350.py new file mode 100644 index 0000000000..4f57df066e --- /dev/null +++ b/InvenTree/build/migrations/0015_auto_20200425_1350.py @@ -0,0 +1,26 @@ +# Generated by Django 3.0.5 on 2020-04-25 13:50 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import mptt.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('build', '0014_auto_20200425_1243'), + ] + + operations = [ + migrations.AlterField( + model_name='build', + name='parent', + field=mptt.fields.TreeForeignKey(blank=True, help_text='Parent build to which this build is allocated', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='children', to='build.Build', verbose_name='Parent Build'), + ), + migrations.AlterField( + model_name='builditem', + name='quantity', + field=models.DecimalField(decimal_places=5, default=1, help_text='Stock quantity to allocate to build', max_digits=15, validators=[django.core.validators.MinValueValidator(0)]), + ), + ] diff --git a/InvenTree/build/migrations/0016_auto_20200426_0551.py b/InvenTree/build/migrations/0016_auto_20200426_0551.py new file mode 100644 index 0000000000..f44a37712c --- /dev/null +++ b/InvenTree/build/migrations/0016_auto_20200426_0551.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.5 on 2020-04-26 05:51 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0033_auto_20200426_0539'), + ('build', '0015_auto_20200425_1350'), + ] + + operations = [ + migrations.AlterField( + model_name='builditem', + name='stock_item', + field=models.ForeignKey(help_text='Stock Item to allocate to build', limit_choices_to={'belongs_to': None, 'build_order': None, 'customer': None}, on_delete=django.db.models.deletion.CASCADE, related_name='allocations', to='stock.StockItem'), + ), + ] diff --git a/InvenTree/build/migrations/0017_auto_20200426_0612.py b/InvenTree/build/migrations/0017_auto_20200426_0612.py new file mode 100644 index 0000000000..83eb02ce35 --- /dev/null +++ b/InvenTree/build/migrations/0017_auto_20200426_0612.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.5 on 2020-04-26 06:12 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0034_auto_20200426_0602'), + ('build', '0016_auto_20200426_0551'), + ] + + operations = [ + migrations.AlterField( + model_name='builditem', + name='stock_item', + field=models.ForeignKey(help_text='Stock Item to allocate to build', limit_choices_to={'belongs_to': None, 'build_order': None, 'sales_order': None}, on_delete=django.db.models.deletion.CASCADE, related_name='allocations', to='stock.StockItem'), + ), + ] diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 8730fd6700..8a00f0ad63 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -14,11 +14,14 @@ from django.core.exceptions import ValidationError from django.urls import reverse from django.db import models, transaction from django.db.models import Sum +from django.db.models.functions import Coalesce from django.core.validators import MinValueValidator from markdownx.models import MarkdownxField -from InvenTree.status_codes import BuildStatus +from mptt.models import MPTTModel, TreeForeignKey + +from InvenTree.status_codes import BuildStatus, StockStatus from InvenTree.fields import InvenTreeURLField from InvenTree.helpers import decimal2string @@ -26,13 +29,15 @@ from stock.models import StockItem from part.models import Part, BomItem -class Build(models.Model): +class Build(MPTTModel): """ A Build object organises the creation of new parts from the component parts. Attributes: part: The part to be built (from component BOM items) title: Brief title describing the build (required) quantity: Number of units to be built + parent: Reference to a Build object for which this Build is required + sales_order: References to a SalesOrder object for which this Build is required (e.g. the output of this build will be used to fulfil a sales order) take_from: Location to take stock from to make this build (if blank, can take from anywhere) status: Build status code batch: Batch code transferred to build parts (optional) @@ -43,60 +48,102 @@ class Build(models.Model): """ def __str__(self): - return "Build {q} x {part}".format(q=decimal2string(self.quantity), part=str(self.part)) + return "{q} x {part}".format(q=decimal2string(self.quantity), part=str(self.part.full_name)) def get_absolute_url(self): return reverse('build-detail', kwargs={'pk': self.id}) title = models.CharField( + verbose_name=_('Build Title'), blank=False, max_length=100, - help_text=_('Brief description of the build')) + help_text=_('Brief description of the build') + ) - part = models.ForeignKey('part.Part', on_delete=models.CASCADE, - related_name='builds', - limit_choices_to={ - 'is_template': False, - 'assembly': True, - 'active': True, - 'virtual': False, - }, - help_text=_('Select part to build'), - ) + parent = TreeForeignKey( + 'self', + on_delete=models.DO_NOTHING, + blank=True, null=True, + related_name='children', + verbose_name=_('Parent Build'), + help_text=_('Parent build to which this build is allocated'), + ) + + part = models.ForeignKey( + 'part.Part', + verbose_name=_('Part'), + on_delete=models.CASCADE, + related_name='builds', + limit_choices_to={ + 'is_template': False, + 'assembly': True, + 'active': True, + 'virtual': False, + }, + help_text=_('Select part to build'), + ) + + sales_order = models.ForeignKey( + 'order.SalesOrder', + verbose_name=_('Sales Order Reference'), + on_delete=models.SET_NULL, + related_name='builds', + null=True, blank=True, + help_text=_('SalesOrder to which this build is allocated') + ) - take_from = models.ForeignKey('stock.StockLocation', on_delete=models.SET_NULL, - related_name='sourcing_builds', - null=True, blank=True, - help_text=_('Select location to take stock from for this build (leave blank to take from any stock location)') - ) + take_from = models.ForeignKey( + 'stock.StockLocation', + verbose_name=_('Source Location'), + on_delete=models.SET_NULL, + related_name='sourcing_builds', + null=True, blank=True, + help_text=_('Select location to take stock from for this build (leave blank to take from any stock location)') + ) quantity = models.PositiveIntegerField( + verbose_name=_('Build Quantity'), default=1, validators=[MinValueValidator(1)], help_text=_('Number of parts to build') ) - status = models.PositiveIntegerField(default=BuildStatus.PENDING, - choices=BuildStatus.items(), - validators=[MinValueValidator(0)], - help_text=_('Build status')) + status = models.PositiveIntegerField( + verbose_name=_('Build Status'), + default=BuildStatus.PENDING, + choices=BuildStatus.items(), + validators=[MinValueValidator(0)], + help_text=_('Build status code') + ) - batch = models.CharField(max_length=100, blank=True, null=True, - help_text=_('Batch code for this build output')) + batch = models.CharField( + verbose_name=_('Batch Code'), + max_length=100, + blank=True, + null=True, + help_text=_('Batch code for this build output') + ) creation_date = models.DateField(auto_now_add=True, editable=False) completion_date = models.DateField(null=True, blank=True) - completed_by = models.ForeignKey(User, - on_delete=models.SET_NULL, - blank=True, null=True, - related_name='builds_completed' - ) + completed_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + blank=True, null=True, + related_name='builds_completed' + ) - link = InvenTreeURLField(blank=True, help_text=_('Link to external URL')) + link = InvenTreeURLField( + verbose_name=_('External Link'), + blank=True, help_text=_('Link to external URL') + ) - notes = MarkdownxField(blank=True, help_text=_('Extra build notes')) + notes = MarkdownxField( + verbose_name=_('Notes'), + blank=True, help_text=_('Extra build notes') + ) @property def output_count(self): @@ -214,32 +261,20 @@ class Build(models.Model): - Delete pending BuildItem objects """ - for item in self.allocated_stock.all().prefetch_related('stock_item'): - - # Subtract stock from the item - item.stock_item.take_stock( - item.quantity, - user, - 'Removed {n} items to build {m} x {part}'.format( - n=item.quantity, - m=self.quantity, - part=self.part.full_name - ) - ) + # Complete the build allocation for each BuildItem + for build_item in self.allocated_stock.all().prefetch_related('stock_item'): + build_item.complete_allocation(user) - # Delete the item - item.delete() - - # Mark the date of completion - self.completion_date = datetime.now().date() - - self.completed_by = user + # Check that the stock-item has been assigned to this build, and remove the builditem from the database + if build_item.stock_item.build_order == self: + build_item.delete() notes = 'Built {q} on {now}'.format( q=self.quantity, now=str(datetime.now().date()) ) + # Generate the build outputs if self.part.trackable and serial_numbers: # Add new serial numbers for serial in serial_numbers: @@ -269,31 +304,54 @@ class Build(models.Model): item.save() # Finally, mark the build as complete + self.completion_date = datetime.now().date() + self.completed_by = user self.status = BuildStatus.COMPLETE self.save() + return True + + def isFullyAllocated(self): + """ + Return True if this build has been fully allocated. + """ + + bom_items = self.part.bom_items.all() + + for item in bom_items: + part = item.sub_part + + if not self.isPartFullyAllocated(part): + return False + + return True + + def isPartFullyAllocated(self, part): + """ + Check if a given Part is fully allocated for this Build + """ + + return self.getAllocatedQuantity(part) >= self.getRequiredQuantity(part) + def getRequiredQuantity(self, part): """ Calculate the quantity of required to make this build. """ try: item = BomItem.objects.get(part=self.part.id, sub_part=part.id) - return item.get_required_quantity(self.quantity) + q = item.quantity except BomItem.DoesNotExist: - return 0 + q = 0 + + return q * self.quantity def getAllocatedQuantity(self, part): """ Calculate the total number of currently allocated to this build """ - allocated = BuildItem.objects.filter(build=self.id, stock_item__part=part.id).aggregate(Sum('quantity')) + allocated = BuildItem.objects.filter(build=self.id, stock_item__part=part.id).aggregate(q=Coalesce(Sum('quantity'), 0)) - q = allocated['quantity__sum'] - - if q: - return int(q) - else: - return 0 + return allocated['q'] def getUnallocatedQuantity(self, part): """ Calculate the quantity of which still needs to be allocated to this build. @@ -313,11 +371,12 @@ class Build(models.Model): parts = [] for item in self.part.bom_items.all().prefetch_related('sub_part'): - part = {'part': item.sub_part, - 'per_build': item.quantity, - 'quantity': item.quantity * self.quantity, - 'allocated': self.getAllocatedQuantity(item.sub_part) - } + part = { + 'part': item.sub_part, + 'per_build': item.quantity, + 'quantity': item.quantity * self.quantity, + 'allocated': self.getAllocatedQuantity(item.sub_part) + } parts.append(part) @@ -393,15 +452,39 @@ class BuildItem(models.Model): q=self.stock_item.quantity ))] - except StockItem.DoesNotExist: - pass + if self.stock_item.quantity - self.stock_item.allocation_count() + self.quantity < self.quantity: + errors['quantity'] = _('StockItem is over-allocated') - except Part.DoesNotExist: + if self.quantity <= 0: + errors['quantity'] = _('Allocation quantity must be greater than zero') + + if self.stock_item.serial and not self.quantity == 1: + errors['quantity'] = _('Quantity must be 1 for serialized stock') + + except (StockItem.DoesNotExist, Part.DoesNotExist): pass if len(errors) > 0: raise ValidationError(errors) + def complete_allocation(self, user): + + item = self.stock_item + + # Split the allocated stock if there are more available than allocated + if item.quantity > self.quantity: + item = item.splitStock(self.quantity, None, user) + + # Update our own reference to the new item + self.stock_item = item + self.save() + + # TODO - If the item__part object is not trackable, delete the stock item here + + item.status = StockStatus.ASSIGNED_TO_BUILD + item.build_order = self.build + item.save() + build = models.ForeignKey( Build, on_delete=models.CASCADE, @@ -414,12 +497,17 @@ class BuildItem(models.Model): on_delete=models.CASCADE, related_name='allocations', help_text=_('Stock Item to allocate to build'), + limit_choices_to={ + 'build_order': None, + 'sales_order': None, + 'belongs_to': None, + } ) quantity = models.DecimalField( decimal_places=5, max_digits=15, default=1, - validators=[MinValueValidator(1)], + validators=[MinValueValidator(0)], help_text=_('Stock quantity to allocate to build') ) diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 19073d1e2d..6480004699 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -21,6 +21,8 @@ class BuildSerializer(InvenTreeModelSerializer): part_detail = PartBriefSerializer(source='part', many=False, read_only=True) + quantity = serializers.FloatField() + def __init__(self, *args, **kwargs): part_detail = kwargs.pop('part_detail', False) @@ -39,6 +41,7 @@ class BuildSerializer(InvenTreeModelSerializer): 'completion_date', 'part', 'part_detail', + 'sales_order', 'quantity', 'status', 'status_text', @@ -62,6 +65,8 @@ class BuildItemSerializer(InvenTreeModelSerializer): part_image = serializers.CharField(source='stock_item.part.image', read_only=True) stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True) + quantity = serializers.FloatField() + class Meta: model = BuildItem fields = [ diff --git a/InvenTree/build/templates/build/allocate.html b/InvenTree/build/templates/build/allocate.html index 3e67c65145..b90508a7d8 100644 --- a/InvenTree/build/templates/build/allocate.html +++ b/InvenTree/build/templates/build/allocate.html @@ -1,5 +1,6 @@ {% extends "build/build_base.html" %} {% load static %} +{% load i18n %} {% load inventree_extras %} {% block page_title %} @@ -10,39 +11,398 @@ InvenTree | Allocate Parts {% include "build/tabs.html" with tab='allocate' %} -{% if editing %} -{% include "build/allocate_edit.html" %} -{% else %} -{% include "build/allocate_view.html" %} -{% endif %} +
+ {% if build.status == BuildStatus.PENDING %} +
+ + + +
+ {% endif %} +
+ +
{% endblock %} -{% block js_load %} -{{ block.super }} - - -{% endblock %} - {% block js_ready %} {{ block.super }} - {% if editing %} + var buildTable = $("#build-item-list"); - {% for bom_item in bom_items.all %} + // Calculate sum of allocations for a particular table row + function sumAllocations(row) { + if (row.allocations == null) { + return 0; + } - loadAllocationTable( - $("#allocate-table-id-{{ bom_item.sub_part.id }}"), - {{ bom_item.sub_part.id }}, - "{{ bom_item.sub_part.full_name }}", - "{% url 'api-build-item-list' %}?build={{ build.id }}&part={{ bom_item.sub_part.id }}", - {% multiply build.quantity bom_item.quantity %}, - $("#new-item-{{ bom_item.sub_part.id }}") - ); + var quantity = 0; - {% endfor %} + row.allocations.forEach(function(item) { + quantity += item.quantity; + }); - $("#auto-allocate-build").on('click', function() { + return quantity; + } + + function getUnallocated(row) { + // Return the number of items remaining to be allocated for a given row + return {{ build.quantity }} * row.quantity - sumAllocations(row); + } + + function reloadTable() { + // Reload the build allocation table + buildTable.bootstrapTable('refresh'); + } + + function setupCallbacks() { + // Register button callbacks once the table data are loaded + + buildTable.find(".button-add").click(function() { + var pk = $(this).attr('pk'); + + // Extract row data from the table + var idx = $(this).closest('tr').attr('data-index'); + var row = buildTable.bootstrapTable('getData')[idx]; + + launchModalForm('/build/item/new/', { + success: reloadTable, + data: { + part: row.sub_part, + build: {{ build.id }}, + quantity: getUnallocated(row), + }, + secondary: [ + { + field: 'stock_item', + label: '{% trans "New Stock Item" %}', + title: '{% trans "Create new Stock Item"', + url: '{% url "stock-item-create" %}', + data: { + part: row.sub_part, + }, + }, + ] + }); + }); + + + buildTable.find(".button-build").click(function() { + // Start a new build for the sub_part + + var pk = $(this).attr('pk'); + + // Extract row data from the table + var idx = $(this).closest('tr').attr('data-index'); + var row = buildTable.bootstrapTable('getData')[idx]; + + launchModalForm('/build/new/', { + follow: true, + data: { + part: row.sub_part, + parent: {{ build.id }}, + quantity: getUnallocated(row), + }, + }); + + }); + + buildTable.find(".button-buy").click(function() { + var pk = $(this).attr('pk'); + + // Extract row data from the table + var idx = $(this).closest('tr').attr('data-index'); + var row = buildTable.bootstrapTable('getData')[idx]; + + launchModalForm("{% url 'order-parts' %}", { + data: { + parts: [row.sub_part], + }, + }); + }); + } + + buildTable.inventreeTable({ + uniqueId: 'sub_part', + url: "{% url 'api-bom-list' %}", + onPostBody: setupCallbacks, + detailViewByClick: true, + detailView: true, + detailFilter: function(index, row) { + return row.allocations != null; + }, + detailFormatter: function(index, row, element) { + // Construct an 'inner table' which shows the stock allocations + + var subTableId = `allocation-table-${row.pk}`; + + var html = `
`; + + element.html(html); + + var lineItem = row; + + var subTable = $(`#${subTableId}`); + + subTable.bootstrapTable({ + data: row.allocations, + showHeader: false, + columns: [ + { + width: '50%', + field: 'quantity', + title: 'Quantity', + formatter: function(value, row, index, field) { + var text = ''; + + var url = ''; + + if (row.serial && row.quantity == 1) { + text = `{% trans "Serial Number" %}: ${row.serial}`; + } else { + text = `{% trans "Quantity" %}: ${row.quantity}`; + } + + {% if build.status == BuildStatus.COMPLETE %} + url = `/stock/item/${row.pk}/`; + {% else %} + url = `/stock/item/${row.stock_item}/`; + {% endif %} + + return renderLink(text, url); + }, + }, + { + field: 'location', + title: '{% trans "Location" %}', + formatter: function(value, row, index, field) { + {% if build.status == BuildStatus.COMPLETE %} + var text = row.location_detail.pathstring; + var url = `/stock/location/${row.location}/`; + {% else %} + var text = row.stock_item_detail.location_name; + var url = `/stock/location/${row.stock_item_detail.location}/`; + {% endif %} + + return renderLink(text, url); + } + }, + {% if build.status == BuildStatus.PENDING %} + { + field: 'buttons', + title: 'Actions', + formatter: function(value, row) { + + var pk = row.pk; + + var html = `
`; + + {% if build.status == BuildStatus.PENDING %} + html += makeIconButton('fa-edit', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}'); + html += makeIconButton('fa-trash-alt', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}'); + {% endif %} + + html += `
`; + + return html; + }, + }, + {% endif %} + ] + }); + + // Assign button callbacks to the newly created allocation buttons + subTable.find(".button-allocation-edit").click(function() { + var pk = $(this).attr('pk'); + launchModalForm(`/build/item/${pk}/edit/`, { + success: reloadTable, + }); + }); + + subTable.find('.button-allocation-delete').click(function() { + var pk = $(this).attr('pk'); + launchModalForm(`/build/item/${pk}/delete/`, { + success: reloadTable, + }); + }); + }, + formatNoMatches: function() { return "{% trans 'No BOM items found' %}"; }, + onLoadSuccess: function(tableData) { + // Once the BOM data are loaded, request allocation data for the build + {% if build.status == BuildStatus.COMPLETE %} + // Request StockItem which have been assigned to this build + inventreeGet('/api/stock/', + { + build_order: {{ build.id }}, + location_detail: true, + }, + { + success: function(data) { + // Iterate through the returned data, group by "part", + var allocations = {}; + + data.forEach(function(item) { + // Group allocations by referenced 'part' + var key = parseInt(item.part); + + if (!(key in allocations)) { + allocations[key] = new Array(); + } + + allocations[key].push(item); + }); + + for (var key in allocations) { + + var tableRow = buildTable.bootstrapTable('getRowByUniqueId', key); + + tableRow.allocations = allocations[key]; + + buildTable.bootstrapTable('updateByUniqueId', key, tableRow, true); + } + }, + }, + ); + + {% else %} + inventreeGet('/api/build/item/', + { + build: {{ build.id }}, + }, + { + success: function(data) { + + // Iterate through the returned data, and group by "part" + var allocations = {}; + + data.forEach(function(item) { + + // Group allocations by referenced 'part' + var part = item.part; + var key = parseInt(part); + + if (!(key in allocations)) { + allocations[key] = new Array(); + } + + // Add the allocation to the list + allocations[key].push(item); + }); + + for (var key in allocations) { + + // Select the associated row in the table + var tableRow = buildTable.bootstrapTable('getRowByUniqueId', key); + + // Set the allocations for the row + tableRow.allocations = allocations[key]; + + // And push the updated row back into the main table + buildTable.bootstrapTable('updateByUniqueId', key, tableRow, true); + } + } + }, + ); + {% endif %} + }, + queryParams: { + part: {{ build.part.id }}, + sub_part_detail: 1, + }, + columns: [ + { + field: 'id', + visible: false, + }, + { + sortable: true, + field: 'sub_part', + title: '{% trans "Part" %}', + formatter: function(value, row, index, field) { + return imageHoverIcon(row.sub_part_detail.thumbnail) + renderLink(row.sub_part_detail.full_name, `/part/${row.sub_part}/`); + }, + }, + { + sortable: true, + field: 'sub_part_detail.description', + title: '{% trans "Description" %}', + }, + { + sortable: true, + field: 'reference', + title: '{% trans "Reference" %}', + }, + { + sortable: true, + field: 'quantity', + title: '{% trans "Required" %}', + formatter: function(value, row) { + return value * {{ build.quantity }}; + }, + }, + { + sortable: true, + field: 'allocated', + {% if build.status == BuildStatus.COMPLETE %} + title: '{% trans "Assigned" %}', + {% else %} + title: '{% trans "Allocated" %}', + {% endif %} + formatter: function(value, row) { + + var allocated = sumAllocations(row); + + return makeProgressBar(allocated, row.quantity * {{ build.quantity }}); + }, + sorter: function(valA, valB, rowA, rowB) { + + var aA = sumAllocations(rowA); + var aB = sumAllocations(rowB); + + var qA = rowA.quantity * {{ build.quantity }}; + var qB = rowB.quantity * {{ build.quantity }}; + + if (aA == 0 && aB == 0) { + return (qA > qB) ? 1 : -1; + } + + var progressA = parseFloat(aA) / qA; + var progressB = parseFloat(aB) / qB; + + return (progressA < progressB) ? 1 : -1; + } + }, + {% if build.status == BuildStatus.PENDING %} + { + field: 'buttons', + formatter: function(value, row, index, field) { + + var html = `
`; + var pk = row.sub_part; + + {% if build.status == BuildStatus.PENDING %} + if (row.sub_part_detail.purchaseable) { + html += makeIconButton('fa-shopping-cart', 'button-buy', pk, '{% trans "Buy parts" %}'); + } + + if (row.sub_part_detail.assembly) { + html += makeIconButton('fa-tools', 'button-build', pk, '{% trans "Build parts" %}'); + } + + html += makeIconButton('fa-plus', 'button-add', pk, '{% trans "Allocate stock" %}'); + {% endif %} + + html += '
'; + + return html; + }, + } + {% endif %} + ], + }); + + {% if build.status == BuildStatus.PENDING %} + $("#btn-allocate").on('click', function() { launchModalForm( "{% url 'build-auto-allocate' build.id %}", { @@ -50,8 +410,8 @@ InvenTree | Allocate Parts } ); }); - - $('#unallocate-build').on('click', function() { + + $('#btn-unallocate').on('click', function() { launchModalForm( "{% url 'build-unallocate' build.id %}", { @@ -59,16 +419,7 @@ InvenTree | Allocate Parts } ); }); - - {% else %} - - $("#build-list").inventreeTable({ - }); - - $("#btn-allocate").click(function() { - location.href = "{% url 'build-allocate' build.id %}?edit=1"; - }); - + $("#btn-order-parts").click(function() { launchModalForm("/order/purchase-order/order-parts/", { data: { @@ -76,7 +427,8 @@ InvenTree | Allocate Parts }, }); }); - + {% endif %} {% endblock %} + \ No newline at end of file diff --git a/InvenTree/build/templates/build/allocate_edit.html b/InvenTree/build/templates/build/allocate_edit.html deleted file mode 100644 index ca6990ee00..0000000000 --- a/InvenTree/build/templates/build/allocate_edit.html +++ /dev/null @@ -1,34 +0,0 @@ -{% load i18n %} -{% load inventree_extras %} - -
-

{% trans "Allocate Stock to Build" %}

-
-
-
-
- - -
-
-
-
- -
-
-

{% trans "Part" %}

-
-
-

{% trans "Available" %}

-
-
-

{% trans "Required" %}

-
-
-

{% trans "Allocated" %}

-
-
- -{% for bom_item in bom_items.all %} -{% include "build/allocation_item.html" with item=bom_item build=build collapse_id=bom_item.id %} -{% endfor %} diff --git a/InvenTree/build/templates/build/allocate_view.html b/InvenTree/build/templates/build/allocate_view.html deleted file mode 100644 index 6100f7232e..0000000000 --- a/InvenTree/build/templates/build/allocate_view.html +++ /dev/null @@ -1,40 +0,0 @@ -{% load i18n %} -{% load inventree_extras %} - -

{% trans "Required Parts" %}

-
- -
-
- - -
-
- - - - - - - - - - - - - - {% for item in build.required_parts %} - - - - - - - - - {% endfor %} - -
{% trans "Part" %}{% trans "Description" %}{% trans "Available" %}{% trans "Required" %}{% trans "Allocated" %}{% trans "On Order" %}
- {% include "hover_image.html" with image=item.part.image hover=True %} - {{ item.part.full_name }} - {{ item.part.description }}{% decimal item.part.total_stock %}{% decimal item.quantity %}{{ item.allocated }}{% decimal item.part.on_order %}
\ No newline at end of file diff --git a/InvenTree/build/templates/build/allocation_item.html b/InvenTree/build/templates/build/allocation_item.html deleted file mode 100644 index 0492e44280..0000000000 --- a/InvenTree/build/templates/build/allocation_item.html +++ /dev/null @@ -1,34 +0,0 @@ -{% extends "collapse.html" %} - -{% load static %} -{% load inventree_extras %} - -{% block collapse_panel_setup %}class='panel part-allocation' id='allocation-panel-{{ item.sub_part.id }}'{% endblock %} - -{% block collapse_title %} - {% include "hover_image.html" with image=item.sub_part.image hover=false %} -
- {{ item.sub_part.full_name }} - {{ item.sub_part.description }} -
-{% endblock %} - -{% block collapse_heading %} -
- {% decimal item.sub_part.total_stock %} -
-
- {% multiply build.quantity item.quantity %}{% if item.overage %} (+ {{ item.overage }}){% endif %} -
-
- {% part_allocation_count build item.sub_part %} -
- -
-
-{% endblock %} - -{% block collapse_content %} - -
-{% endblock %} \ No newline at end of file diff --git a/InvenTree/build/templates/build/auto_allocate.html b/InvenTree/build/templates/build/auto_allocate.html index d278b9cd18..fc1e42096b 100644 --- a/InvenTree/build/templates/build/auto_allocate.html +++ b/InvenTree/build/templates/build/auto_allocate.html @@ -1,22 +1,23 @@ {% extends "modal_form.html" %} - +{% load i18n %} {% block pre_form_content %} {{ block.super }} -Build: {{ build.title }} - {{ build.quantity }} x {{ build.part.full_name }} -

-Automatically allocate stock to this build? -
+
+{% trans "Automatically Allocate Stock" %}
+{% trans "Stock Items are selected for automatic allocation if there is only a single stock item available." %}
+{% trans "The following stock items will be allocated to the build:" %}
+
{% if allocations %} - - - + + + {% for item in allocations %} @@ -34,7 +35,9 @@ Automatically allocate stock to this build?
PartQuantityLocation{% trans "Part" %}{% trans "Quantity" %}{% trans "Location" %}
{% else %} -No stock could be selected for automatic build allocation. +
+ {% trans "No stock items found that can be allocated to this build" %} +
{% endif %} {% endblock %} \ No newline at end of file diff --git a/InvenTree/build/templates/build/build_base.html b/InvenTree/build/templates/build/build_base.html index b1bd07773d..8f5ec62663 100644 --- a/InvenTree/build/templates/build/build_base.html +++ b/InvenTree/build/templates/build/build_base.html @@ -1,104 +1,114 @@ -{% extends "base.html" %} +{% extends "two_column.html" %} {% load static %} {% load i18n %} {% load status_codes %} {% block page_title %} -InvenTree | Build - {{ build }} +InvenTree | {% trans "Build" %} - {{ build }} {% endblock %} -{% block content %} - -
-
-
-
-
- -
-
-
-

{% trans "Build" %}

-
-
- - {% if build.is_active %} - - - {% endif %} - {% if build.status == BuildStatus.CANCELLED %} - - {% endif %} -
-
-
-
-
-
-

{% trans "Build Details" %}

- - - - - - - - - - - - - - - - - - - - - - - - - - -
{% trans "Build Title" %}{{ build.title }}
Part{{ build.part.full_name }}
{% trans "Quantity" %}{{ build.quantity }}
{% trans "Status" %}{% build_status build.status %}
{% trans "BOM Price" %} - {% if bom_price %} - {{ bom_price }} - {% if build.part.has_complete_bom_pricing == False %} -
{% trans "BOM pricing is incomplete" %} - {% endif %} - {% else %} - {% trans "No pricing information" %} - {% endif %} -
-
-
+{% block pre_content %} +{% if build.sales_order %} +
+ {% trans "This build is allocated to Sales Order" %} {{ build.sales_order }}
+{% endif %} +{% if build.parent %} +
+ {% trans "This build is a child of Build" %} {{ build.parent }} +
+{% endif %} +{% endblock %} +{% block thumbnail %} + +{% endblock %} + +{% block page_data %} +

{% trans "Build" %} {% build_status_label build.status large=True %}


- -
-{% block details %} - +

{{ build.quantity }} x {{ build.part.full_name }}

+
+
+ + {% if build.is_active %} + + + {% endif %} + {% if build.status == BuildStatus.CANCELLED %} + + {% endif %} +
+
{% endblock %} -
+{% block page_details %} +

{% trans "Build Details" %}

+ + + + + + + + + + + + + + + + + + + + + + {% if build.parent %} + + + + + + {% endif %} + {% if build.sales_order %} + + + + + + {% endif %} + + + + + +
{% trans "Build Title" %}{{ build.title }}
{% trans "Part" %}{{ build.part.full_name }}
{% trans "Quantity" %}{{ build.quantity }}
{% trans "Status" %}{% build_status_label build.status %}
{% trans "Parent Build" %}{{ build.parent }}
{% trans "Sales Order" %}{{ build.sales_order }}
{% trans "BOM Price" %} + {% if bom_price %} + {{ bom_price }} + {% if build.part.has_complete_bom_pricing == False %} +
{% trans "BOM pricing is incomplete" %} + {% endif %} + {% else %} + {% trans "No pricing information" %} + {% endif %} +
{% endblock %} {% block js_load %} diff --git a/InvenTree/build/templates/build/complete.html b/InvenTree/build/templates/build/complete.html index 230092d766..a48b831645 100644 --- a/InvenTree/build/templates/build/complete.html +++ b/InvenTree/build/templates/build/complete.html @@ -1,42 +1,37 @@ {% extends "modal_form.html" %} +{% load i18n %} {% block pre_form_content %} -Build: {{ build.title }} - {{ build.quantity }} x {{ build.part.full_name }} -
-Are you sure you want to mark this build as complete? -
-{% if taking %} -The following items will be removed from stock: - - - - - - - -{% for item in taking %} - - - - - - -{% endfor %} -
PartQuantityLocation
- {% include "hover_image.html" with image=item.stock_item.part.image hover=True %} - - {{ item.stock_item.part.full_name }}
- {{ item.stock_item.part.description }} -
{{ item.quantity }}{{ item.stock_item.location }}
+

{% trans "Build" %} - {{ build }}

+ +{% if build.isFullyAllocated %} +
+

{% trans "Build order allocation is complete" %}

+
{% else %} -No parts have been allocated to this build. +
+

{% trans "Warning: Build order allocation is not complete" %}

+ {% trans "Build Order has not been fully allocated. Ensure that all Stock Items have been allocated to the Build" %} +
{% endif %} -
-The following items will be created: + +
+

{% trans "The following actions will be performed:" %}

+
    +
  • {% trans "Remove allocated items from stock" %}
  • +
  • {% trans "Add completed items to stock" %}
  • +
+
+
- {% include "hover_image.html" with image=build.part.image hover=True %} - {{ build.quantity }} x {{ build.part.full_name }} +
+ {% trans "The following items will be created" %} +
+
+ {% include "hover_image.html" with image=build.part.image hover=True %} + {{ build.quantity }} x {{ build.part.full_name }} +
{% endblock %} \ No newline at end of file diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index 42f8852ef9..6abbc69bc5 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -40,7 +40,7 @@ {% trans "Status" %} - {% build_status build.status %} + {% build_status_label build.status %} {% if build.batch %} diff --git a/InvenTree/build/templates/build/tabs.html b/InvenTree/build/templates/build/tabs.html index 552bac7408..24b1e53a15 100644 --- a/InvenTree/build/templates/build/tabs.html +++ b/InvenTree/build/templates/build/tabs.html @@ -4,13 +4,13 @@ {% trans "Details" %} + + {% trans "Allocated Parts" %} + - {% trans "Outputs" %}{% if build.output_count > 0%}{{ build.output_count }}{% endif %} + {% trans "Build Outputs" %}{% if build.output_count > 0%}{{ build.output_count }}{% endif %} {% trans "Notes" %}{% if build.notes %} {% endif %} - - {% trans "Assign Parts" %} - \ No newline at end of file diff --git a/InvenTree/build/test_build.py b/InvenTree/build/test_build.py new file mode 100644 index 0000000000..c1fb4a5efd --- /dev/null +++ b/InvenTree/build/test_build.py @@ -0,0 +1,227 @@ +# -*- coding: utf-8 -*- + +from django.test import TestCase + +from django.core.exceptions import ValidationError +from django.db import transaction +from django.db.utils import IntegrityError + +from build.models import Build, BuildItem +from stock.models import StockItem +from part.models import Part, BomItem +from InvenTree import status_codes as status + +from InvenTree.helpers import ExtractSerialNumbers + + +class BuildTest(TestCase): + """ + Run some tests to ensure that the Build model is working properly. + """ + + def setUp(self): + """ + Initialize data to use for these tests. + """ + + # Create a base "Part" + self.assembly = Part.objects.create( + name="An assembled part", + description="Why does it matter what my description is?", + assembly=True, + trackable=True, + ) + + self.sub_part_1 = Part.objects.create( + name="Widget A", + description="A widget", + component=True + ) + + self.sub_part_2 = Part.objects.create( + name="Widget B", + description="A widget", + component=True + ) + + # Create BOM item links for the parts + BomItem.objects.create( + part=self.assembly, + sub_part=self.sub_part_1, + quantity=10 + ) + + BomItem.objects.create( + part=self.assembly, + sub_part=self.sub_part_2, + quantity=25 + ) + + # Create a "Build" object to make 10x objects + self.build = Build.objects.create( + title="This is a build", + part=self.assembly, + quantity=10 + ) + + # Create some stock items to assign to the build + self.stock_1_1 = StockItem.objects.create(part=self.sub_part_1, quantity=1000) + self.stock_1_2 = StockItem.objects.create(part=self.sub_part_1, quantity=100) + + self.stock_2_1 = StockItem.objects.create(part=self.sub_part_2, quantity=5000) + + def test_init(self): + # Perform some basic tests before we start the ball rolling + + self.assertEqual(StockItem.objects.count(), 3) + self.assertEqual(self.build.status, status.BuildStatus.PENDING) + self.assertFalse(self.build.isFullyAllocated()) + + self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1)) + self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2)) + + self.assertEqual(self.build.getRequiredQuantity(self.sub_part_1), 100) + self.assertEqual(self.build.getRequiredQuantity(self.sub_part_2), 250) + + self.assertTrue(self.build.can_build) + self.assertFalse(self.build.is_complete) + + # Delete some stock and see if the build can still be completed + self.stock_2_1.delete() + self.assertFalse(self.build.can_build) + + def test_build_item_clean(self): + # Ensure that dodgy BuildItem objects cannot be created + + stock = StockItem.objects.create(part=self.assembly, quantity=99) + + # Create a BuiltItem which points to an invalid StockItem + b = BuildItem(stock_item=stock, build=self.build, quantity=10) + + with self.assertRaises(ValidationError): + b.clean() + + # Create a BuildItem which has too much stock assigned + b = BuildItem(stock_item=self.stock_1_1, build=self.build, quantity=9999999) + + with self.assertRaises(ValidationError): + b.clean() + + # Negative stock? Not on my watch! + b = BuildItem(stock_item=self.stock_1_1, build=self.build, quantity=-99) + + with self.assertRaises(ValidationError): + b.clean() + + def test_duplicate_bom_line(self): + # Try to add a duplicate BOM item - it should fail! + + with self.assertRaises(IntegrityError): + BomItem.objects.create( + part=self.assembly, + sub_part=self.sub_part_1, + quantity=99 + ) + + def allocate_stock(self, q11, q12, q21): + # Assign stock to this build + + BuildItem.objects.create( + build=self.build, + stock_item=self.stock_1_1, + quantity=q11 + ) + + BuildItem.objects.create( + build=self.build, + stock_item=self.stock_1_2, + quantity=q12 + ) + + BuildItem.objects.create( + build=self.build, + stock_item=self.stock_2_1, + quantity=q21 + ) + + with transaction.atomic(): + with self.assertRaises(IntegrityError): + BuildItem.objects.create( + build=self.build, + stock_item=self.stock_2_1, + quantity=99 + ) + + self.assertEqual(BuildItem.objects.count(), 3) + + def test_partial_allocation(self): + + self.allocate_stock(50, 50, 200) + + self.assertFalse(self.build.isFullyAllocated()) + self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_1)) + self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2)) + + self.build.unallocateStock() + self.assertEqual(BuildItem.objects.count(), 0) + + def test_auto_allocate(self): + + allocations = self.build.getAutoAllocations() + + self.assertEqual(len(allocations), 1) + + self.build.autoAllocate() + self.assertEqual(BuildItem.objects.count(), 1) + self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_2)) + + def test_cancel(self): + + self.allocate_stock(50, 50, 200) + self.build.cancelBuild(None) + + self.assertEqual(BuildItem.objects.count(), 0) + + def test_complete(self): + + self.allocate_stock(50, 50, 250) + + self.assertTrue(self.build.isFullyAllocated()) + + # Generate some serial numbers! + serials = ExtractSerialNumbers("1-10", 10) + + self.build.completeBuild(None, serials, None) + + self.assertEqual(self.build.status, status.BuildStatus.COMPLETE) + + # the original BuildItem objects should have been deleted! + self.assertEqual(BuildItem.objects.count(), 0) + + # New stock items should have been created! + # - Ten for the build output (as the part was serialized) + # - Three for the split items assigned to the build + self.assertEqual(StockItem.objects.count(), 16) + + # Stock should have been subtracted from the original items + self.assertEqual(StockItem.objects.get(pk=1).quantity, 950) + self.assertEqual(StockItem.objects.get(pk=2).quantity, 50) + self.assertEqual(StockItem.objects.get(pk=3).quantity, 4750) + + # New stock items created and assigned to the build + self.assertEqual(StockItem.objects.get(pk=4).quantity, 50) + self.assertEqual(StockItem.objects.get(pk=4).build_order, self.build) + self.assertEqual(StockItem.objects.get(pk=4).status, status.StockStatus.ASSIGNED_TO_BUILD) + + self.assertEqual(StockItem.objects.get(pk=5).quantity, 50) + self.assertEqual(StockItem.objects.get(pk=5).build_order, self.build) + self.assertEqual(StockItem.objects.get(pk=5).status, status.StockStatus.ASSIGNED_TO_BUILD) + + self.assertEqual(StockItem.objects.get(pk=6).quantity, 250) + self.assertEqual(StockItem.objects.get(pk=6).build_order, self.build) + self.assertEqual(StockItem.objects.get(pk=6).status, status.StockStatus.ASSIGNED_TO_BUILD) + + # And a new stock item created for the build output + self.assertEqual(StockItem.objects.get(pk=7).quantity, 1) + self.assertEqual(StockItem.objects.get(pk=7).serial, 1) + self.assertEqual(StockItem.objects.get(pk=7).build, self.build) diff --git a/InvenTree/build/tests.py b/InvenTree/build/tests.py index acd614425a..42b953fe20 100644 --- a/InvenTree/build/tests.py +++ b/InvenTree/build/tests.py @@ -39,7 +39,7 @@ class BuildTestSimple(TestCase): self.assertEqual(b.batch, 'B2') self.assertEqual(b.quantity, 21) - self.assertEqual(str(b), 'Build 21 x Orphan - A part without a category') + self.assertEqual(str(b), '21 x Orphan') def test_url(self): b1 = Build.objects.get(pk=1) diff --git a/InvenTree/build/urls.py b/InvenTree/build/urls.py index 5d23c55a2d..d8cb3c03ea 100644 --- a/InvenTree/build/urls.py +++ b/InvenTree/build/urls.py @@ -6,16 +6,6 @@ from django.conf.urls import url, include from . import views -build_item_detail_urls = [ - url('^edit/?', views.BuildItemEdit.as_view(), name='build-item-edit'), - url('^delete/?', views.BuildItemDelete.as_view(), name='build-item-delete'), -] - -build_item_urls = [ - url(r'^(?P\d+)/', include(build_item_detail_urls)), - url('^new/', views.BuildItemCreate.as_view(), name='build-item-create'), -] - build_detail_urls = [ url(r'^edit/', views.BuildUpdate.as_view(), name='build-edit'), url(r'^allocate/', views.BuildAllocate.as_view(), name='build-allocate'), @@ -33,7 +23,13 @@ build_detail_urls = [ ] build_urls = [ - url(r'item/', include(build_item_urls)), + url(r'item/', include([ + url(r'^(?P\d+)/', include([ + url('^edit/?', views.BuildItemEdit.as_view(), name='build-item-edit'), + url('^delete/?', views.BuildItemDelete.as_view(), name='build-item-delete'), + ])), + url('^new/', views.BuildItemCreate.as_view(), name='build-item-create'), + ])), url(r'new/', views.BuildCreate.as_view(), name='build-create'), diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py index feda6ee9eb..6f651a7628 100644 --- a/InvenTree/build/views.py +++ b/InvenTree/build/views.py @@ -125,7 +125,7 @@ class BuildAutoAllocate(AjaxUpdateView): if confirm is False: form.errors['confirm'] = [_('Confirm stock allocation')] - form.non_field_errors = _('Check the confirmation box at the bottom of the list') + form.non_field_errors = [_('Check the confirmation box at the bottom of the list')] else: build.autoAllocate() valid = True @@ -159,7 +159,7 @@ class BuildUnallocate(AjaxUpdateView): if confirm is False: form.errors['confirm'] = [_('Confirm unallocation of build stock')] - form.non_field_errors = _('Check the confirmation box') + form.non_field_errors = [_('Check the confirmation box')] else: build.unallocateStock() valid = True @@ -261,13 +261,13 @@ class BuildComplete(AjaxUpdateView): try: location = StockLocation.objects.get(id=loc_id) valid = True - except StockLocation.DoesNotExist: + except (ValueError, StockLocation.DoesNotExist): form.errors['location'] = [_('Invalid location selected')] serials = [] if build.part.trackable: - # A build for a trackable part must specify serial numbers + # A build for a trackable part may optionally specify serial numbers. sn = request.POST.get('serial_numbers', '') @@ -295,7 +295,9 @@ class BuildComplete(AjaxUpdateView): valid = False if valid: - build.completeBuild(location, serials, request.user) + if not build.completeBuild(location, serials, request.user): + form.non_field_errors = [('Build could not be completed')] + valid = False data = { 'form_valid': valid, @@ -393,13 +395,15 @@ class BuildCreate(AjaxCreateView): initials = super(BuildCreate, self).get_initial().copy() - part_id = self.request.GET.get('part', None) + # User has provided a Part ID + initials['part'] = self.request.GET.get('part', None) - if part_id: - try: - initials['part'] = Part.objects.get(pk=part_id) - except Part.DoesNotExist: - pass + initials['parent'] = self.request.GET.get('parent', None) + + # User has provided a SalesOrder ID + initials['sales_order'] = self.request.GET.get('sales_order', None) + + initials['quantity'] = self.request.GET.get('quantity', 1) return initials @@ -540,27 +544,64 @@ class BuildItemCreate(AjaxCreateView): build_id = self.get_param('build') part_id = self.get_param('part') + # Reference to a Part object + part = None + + # Reference to a StockItem object + item = None + + # Reference to a Build object + build = None + if part_id: try: part = Part.objects.get(pk=part_id) + initials['part'] = part except Part.DoesNotExist: - part = None - else: - part = None + pass if build_id: try: build = Build.objects.get(pk=build_id) initials['build'] = build - - # Try to work out how many parts to allocate - if part: - unallocated = build.getUnallocatedQuantity(part) - initials['quantity'] = unallocated - except Build.DoesNotExist: pass + quantity = self.request.GET.get('quantity', None) + + if quantity is not None: + quantity = float(quantity) + + if quantity is None: + # Work out how many parts remain to be alloacted for the build + if part: + quantity = build.getUnallocatedQuantity(part) + + item_id = self.get_param('item') + + # If the request specifies a particular StockItem + if item_id: + try: + item = StockItem.objects.get(pk=item_id) + except: + pass + + # If a StockItem is not selected, try to auto-select one + if item is None and part is not None: + items = StockItem.objects.filter(part=part) + if items.count() == 1: + item = items.first() + + # Finally, if a StockItem is selected, ensure the quantity is not too much + if item is not None: + if quantity is None: + quantity = item.unallocated_quantity() + else: + quantity = min(quantity, item.unallocated_quantity()) + + if quantity is not None: + initials['quantity'] = quantity + return initials diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index 3b8f058bfd..ec87619cdb 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -25,7 +25,7 @@ from stdimage.models import StdImageField from InvenTree.helpers import getMediaUrl, getBlankImage, getBlankThumbnail from InvenTree.helpers import normalize from InvenTree.fields import InvenTreeURLField, RoundingDecimalField -from InvenTree.status_codes import OrderStatus +from InvenTree.status_codes import PurchaseOrderStatus from common.models import Currency @@ -185,11 +185,11 @@ class Company(models.Model): def outstanding_purchase_orders(self): """ Return purchase orders which are 'outstanding' """ - return self.purchase_orders.filter(status__in=OrderStatus.OPEN) + return self.purchase_orders.filter(status__in=PurchaseOrderStatus.OPEN) def pending_purchase_orders(self): """ Return purchase orders which are PENDING (not yet issued) """ - return self.purchase_orders.filter(status=OrderStatus.PENDING) + return self.purchase_orders.filter(status=PurchaseOrderStatus.PENDING) def closed_purchase_orders(self): """ Return purchase orders which are not 'outstanding' @@ -199,15 +199,15 @@ class Company(models.Model): - Returned """ - return self.purchase_orders.exclude(status__in=OrderStatus.OPEN) + return self.purchase_orders.exclude(status__in=PurchaseOrderStatus.OPEN) def complete_purchase_orders(self): - return self.purchase_orders.filter(status=OrderStatus.COMPLETE) + return self.purchase_orders.filter(status=PurchaseOrderStatus.COMPLETE) def failed_purchase_orders(self): """ Return any purchase orders which were not successful """ - return self.purchase_orders.filter(status__in=OrderStatus.FAILED) + return self.purchase_orders.filter(status__in=PurchaseOrderStatus.FAILED) class Contact(models.Model): @@ -384,7 +384,7 @@ class SupplierPart(models.Model): limited to purchase orders that are open / outstanding. """ - return self.purchase_order_line_items.prefetch_related('order').filter(order__status__in=OrderStatus.OPEN) + return self.purchase_order_line_items.prefetch_related('order').filter(order__status__in=PurchaseOrderStatus.OPEN) def on_order(self): """ Return the total quantity of items currently on order. diff --git a/InvenTree/company/serializers.py b/InvenTree/company/serializers.py index 701c1faabf..780492cdd5 100644 --- a/InvenTree/company/serializers.py +++ b/InvenTree/company/serializers.py @@ -64,15 +64,11 @@ class CompanySerializer(InvenTreeModelSerializer): class SupplierPartSerializer(InvenTreeModelSerializer): """ Serializer for SupplierPart object """ - url = serializers.CharField(source='get_absolute_url', read_only=True) - part_detail = PartBriefSerializer(source='part', many=False, read_only=True) supplier_detail = CompanyBriefSerializer(source='supplier', many=False, read_only=True) manufacturer_detail = CompanyBriefSerializer(source='manufacturer', many=False, read_only=True) - pricing = serializers.CharField(source='unit_pricing', read_only=True) - def __init__(self, *args, **kwargs): part_detail = kwargs.pop('part_detail', False) @@ -94,7 +90,6 @@ class SupplierPartSerializer(InvenTreeModelSerializer): model = SupplierPart fields = [ 'pk', - 'url', 'part', 'part_detail', 'supplier', @@ -105,7 +100,6 @@ class SupplierPartSerializer(InvenTreeModelSerializer): 'description', 'MPN', 'link', - 'pricing', ] diff --git a/InvenTree/company/templates/company/company_base.html b/InvenTree/company/templates/company/company_base.html index 8ec68a401c..da73a7b7b3 100644 --- a/InvenTree/company/templates/company/company_base.html +++ b/InvenTree/company/templates/company/company_base.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "two_column.html" %} {% load static %} {% load i18n %} @@ -7,100 +7,81 @@ InvenTree | {% trans "Company" %} - {{ company.name }} {% endblock %} -{% block content %} - -
-
-
-
-
- -
-
-
-

{{ company.name }}

-

{{ company.description }}

-
- {% if company.is_supplier %} - - {% endif %} - - -
-
-
-
-
- - - {% if company.website %} - - - - - - {% endif %} - {% if company.address %} - - - - - - {% endif %} - {% if company.phone %} - - - - - - {% endif %} - {% if company.email %} - - - - - - {% endif %} - {% if company.contact %} - - - - - - {% endif %} -
{% trans "Website" %}{{ company.website }}
{% trans "Address" %}{{ company.address }}
{% trans "Phone" %}{{ company.phone }}
{% trans "Email" %}{{ company.email }}
{% trans "Contact" %}{{ company.contact }}
-
+{% block thumbnail %} +
+
+{% endblock %} +{% block page_data %} +

{% trans "Company" %}


- -
- -{% block details %} - -{% endblock %} - +

{{ company.name }}

+

{{ company.description }}

+
+ {% if company.is_supplier %} + + {% endif %} + +
- {% endblock %} -{% block js_load %} -{{ block.super }} - +{% block page_details %} +

{% trans "Company Details" %}

+ + +{% if company.website %} + + + + + +{% endif %} +{% if company.address %} + + + + + +{% endif %} +{% if company.phone %} + + + + + +{% endif %} +{% if company.email %} + + + + + +{% endif %} +{% if company.contact %} + + + + + +{% endif %} +
{% trans "Website" %}{{ company.website }}
{% trans "Address" %}{{ company.address }}
{% trans "Phone" %}{{ company.phone }}
{% trans "Email" %}{{ company.email }}
{% trans "Contact" %}{{ company.contact }}
{% endblock %} {% block js_ready %} +{{ block.super }} $('#company-edit').click(function() { launchModalForm( diff --git a/InvenTree/company/templates/company/detail_purchase_orders.html b/InvenTree/company/templates/company/purchase_orders.html similarity index 78% rename from InvenTree/company/templates/company/detail_purchase_orders.html rename to InvenTree/company/templates/company/purchase_orders.html index c83bb90eb1..bab5cd4bce 100644 --- a/InvenTree/company/templates/company/detail_purchase_orders.html +++ b/InvenTree/company/templates/company/purchase_orders.html @@ -1,8 +1,9 @@ {% extends "company/company_base.html" %} {% load static %} -{% block details %} {% load i18n %} +{% block details %} + {% include 'company/tabs.html' with tab='po' %}

{% trans "Purchase Orders" %}

@@ -10,8 +11,8 @@
- -
+ +
@@ -26,7 +27,10 @@ {{ block.super }} loadPurchaseOrderTable("#purchase-order-table", { - url: "{% url 'api-po-list' %}?supplier={{ company.id }}", + url: "{% url 'api-po-list' %}", + params: { + supplier: {{ company.id }}, + } }); diff --git a/InvenTree/company/templates/company/sales_orders.html b/InvenTree/company/templates/company/sales_orders.html new file mode 100644 index 0000000000..facfbce189 --- /dev/null +++ b/InvenTree/company/templates/company/sales_orders.html @@ -0,0 +1,41 @@ +{% extends "company/company_base.html" %} +{% load static %} +{% load i18n %} + +{% block details %} + +{% include 'company/tabs.html' with tab='co' %} + +

{% trans "Sales Orders" %}

+
+ +
+
+ +
+ +
+
+
+ + +
+ +{% endblock %} + +{% block js_ready %} +{{ block.super }} + + loadSalesOrderTable("#sales-order-table", { + url: "{% url 'api-so-list' %}", + params: { + customer: {{ company.id }}, + } + }); + + + $("#new-sales-order").click(function() { + // TODO - Create a new sales order + }); + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/company/templates/company/supplier_part_base.html b/InvenTree/company/templates/company/supplier_part_base.html index fec430628b..5083af4f3a 100644 --- a/InvenTree/company/templates/company/supplier_part_base.html +++ b/InvenTree/company/templates/company/supplier_part_base.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "two_column.html" %} {% load static %} {% load i18n %} @@ -6,99 +6,86 @@ InvenTree | {% trans "Supplier Part" %} {% endblock %} -{% block content %} +{% block thumbnail %} + +{% endblock %} -
-
-

{% trans "Supplier Part" %}

-
-
- - -
-
-
- -
-
-
-

{% trans "Supplier Part Details" %}

- - - - - - - - {% if part.description %} - - - - - {% endif %} - {% if part.link %} - - - - - - {% endif %} - - - - - - - - - - {% if part.manufacturer %} - - - - - - - - - - {% endif %} - {% if part.note %} - - - - - - {% endif %} -
{% trans "Internal Part" %} - {% if part.part %} - {{ part.part.full_name }} - {% endif %} -
{% trans "Description" %}{{ part.description }}
{% trans "External Link" %}{{ part.link }}
{% trans "Supplier" %}{{ part.supplier.name }}
{% trans "SKU" %}{{ part.SKU }}
{% trans "Manufacturer" %}{{ part.manufacturer.name }}
{% trans "MPN" %}{{ part.MPN }}
{% trans "Note" %}{{ part.note }}
+{% block page_data %} +

{% trans "Supplier Part" %}

+

{{ part.supplier.name }} - {{ part.SKU }}

+
+
+ +
+{% endblock %} +{% block page_details %} - - - -
- -
- {% block details %} - - {% endblock %} -
- +

{% trans "Supplier Part Details" %}

+ + + + + + + + {% if part.description %} + + + + + + {% endif %} + {% if part.link %} + + + + + + {% endif %} + + + + + + + + + + {% if part.manufacturer %} + + + + + + + + + + {% endif %} + {% if part.note %} + + + + + + {% endif %} +
{% trans "Internal Part" %} + {% if part.part %} + {{ part.part.full_name }} + {% endif %} +
{% trans "Description" %}{{ part.description }}
{% trans "External Link" %}{{ part.link }}
{% trans "Supplier" %}{{ part.supplier.name }}
{% trans "SKU" %}{{ part.SKU }}
{% trans "Manufacturer" %}{{ part.manufacturer.name }}
{% trans "MPN" %}{{ part.MPN }}
{% trans "Note" %}{{ part.note }}
{% endblock %} {% block js_ready %} diff --git a/InvenTree/company/templates/company/tabs.html b/InvenTree/company/templates/company/tabs.html index ea61c40574..8e01bf30c0 100644 --- a/InvenTree/company/templates/company/tabs.html +++ b/InvenTree/company/templates/company/tabs.html @@ -18,12 +18,10 @@ {% endif %} {% if company.is_customer %} - {% if 0 %} - {% trans "Sales Orders" %} + {% trans "Sales Orders" %} {{ company.sales_orders.count }} {% endif %} - {% endif %} {% trans "Notes" %}{% if company.notes %} {% endif %} diff --git a/InvenTree/company/urls.py b/InvenTree/company/urls.py index 3140b7c2d7..af8e1846e1 100644 --- a/InvenTree/company/urls.py +++ b/InvenTree/company/urls.py @@ -15,7 +15,8 @@ company_detail_urls = [ url(r'parts/', views.CompanyDetail.as_view(template_name='company/detail_part.html'), name='company-detail-parts'), url(r'stock/?', views.CompanyDetail.as_view(template_name='company/detail_stock.html'), name='company-detail-stock'), - url(r'purchase-orders/?', views.CompanyDetail.as_view(template_name='company/detail_purchase_orders.html'), name='company-detail-purchase-orders'), + url(r'purchase-orders/?', views.CompanyDetail.as_view(template_name='company/purchase_orders.html'), name='company-detail-purchase-orders'), + url(r'sales-orders/?', views.CompanyDetail.as_view(template_name='company/sales_orders.html'), name='company-detail-sales-orders'), url(r'notes/?', views.CompanyNotes.as_view(), name='company-notes'), url(r'thumbnail/?', views.CompanyImage.as_view(), name='company-image'), diff --git a/InvenTree/company/views.py b/InvenTree/company/views.py index ae88629505..3f8cde21d3 100644 --- a/InvenTree/company/views.py +++ b/InvenTree/company/views.py @@ -13,7 +13,6 @@ from django.urls import reverse from django.forms import HiddenInput from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView -from InvenTree.status_codes import OrderStatus from InvenTree.helpers import str2bool from common.models import Currency @@ -137,7 +136,6 @@ class CompanyDetail(DetailView): def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) - ctx['OrderStatus'] = OrderStatus return ctx @@ -244,7 +242,6 @@ class SupplierPartDetail(DetailView): def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) - ctx['OrderStatus'] = OrderStatus return ctx diff --git a/InvenTree/locale/de/LC_MESSAGES/django.mo b/InvenTree/locale/de/LC_MESSAGES/django.mo index 4038658f79..6cd6ed477e 100644 Binary files a/InvenTree/locale/de/LC_MESSAGES/django.mo and b/InvenTree/locale/de/LC_MESSAGES/django.mo differ diff --git a/InvenTree/locale/de/LC_MESSAGES/django.po b/InvenTree/locale/de/LC_MESSAGES/django.po index 582a41652c..85fc571d46 100644 --- a/InvenTree/locale/de/LC_MESSAGES/django.po +++ b/InvenTree/locale/de/LC_MESSAGES/django.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-04-11 15:00+0000\n" +"POT-Creation-Date: 2020-04-22 23:17+0000\n" "PO-Revision-Date: 2020-02-02 08:07+0100\n" "Last-Translator: Christian Schlüter \n" "Language-Team: C \n" @@ -17,30 +17,56 @@ msgstr "" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Generator: Lokalize 19.12.0\n" -#: InvenTree/helpers.py:259 order/models.py:164 order/models.py:215 +#: InvenTree/api.py:61 +#, fuzzy +#| msgid "No lines specified" +msgid "No action specified" +msgstr "Keine Zeilen angegeben" + +#: InvenTree/api.py:75 +msgid "No matching action found" +msgstr "" + +#: InvenTree/api.py:106 +msgid "No barcode data provided" +msgstr "" + +#: InvenTree/api.py:121 +msgid "Barcode successfully decoded" +msgstr "" + +#: InvenTree/api.py:124 +msgid "Barcode plugin returned incorrect response" +msgstr "" + +#: InvenTree/api.py:134 +msgid "Unknown barcode format" +msgstr "" + +#: InvenTree/helpers.py:258 order/models.py:173 order/models.py:224 msgid "Invalid quantity provided" msgstr "Keine gültige Menge" -#: InvenTree/helpers.py:262 +#: InvenTree/helpers.py:261 msgid "Empty serial number string" msgstr "Keine Seriennummer angegeben" -#: InvenTree/helpers.py:283 InvenTree/helpers.py:300 +#: InvenTree/helpers.py:282 InvenTree/helpers.py:299 #, python-brace-format msgid "Duplicate serial: {n}" msgstr "Doppelte Seriennummer: {n}" -#: InvenTree/helpers.py:287 InvenTree/helpers.py:290 InvenTree/helpers.py:293 -#: InvenTree/helpers.py:304 +#: InvenTree/helpers.py:286 InvenTree/helpers.py:289 InvenTree/helpers.py:292 +#: InvenTree/helpers.py:303 #, python-brace-format msgid "Invalid group: {g}" msgstr "Ungültige Gruppe: {g}" -#: InvenTree/helpers.py:310 +#: InvenTree/helpers.py:309 msgid "No serial numbers found" msgstr "Keine Seriennummern gefunden" -#: InvenTree/helpers.py:314 +#: InvenTree/helpers.py:313 #, python-brace-format msgid "Number of unique serial number ({s}) must match quantity ({q})" msgstr "" @@ -71,47 +97,51 @@ msgstr "Französisch" msgid "Polish" msgstr "Polnisch" -#: InvenTree/status_codes.py:86 InvenTree/status_codes.py:162 +#: InvenTree/status_codes.py:84 InvenTree/status_codes.py:172 msgid "Pending" msgstr "Ausstehend" -#: InvenTree/status_codes.py:87 +#: InvenTree/status_codes.py:85 msgid "Placed" msgstr "Platziert" -#: InvenTree/status_codes.py:88 InvenTree/status_codes.py:165 +#: InvenTree/status_codes.py:86 InvenTree/status_codes.py:175 msgid "Complete" msgstr "Fertig" -#: InvenTree/status_codes.py:89 InvenTree/status_codes.py:164 +#: InvenTree/status_codes.py:87 InvenTree/status_codes.py:174 msgid "Cancelled" msgstr "Storniert" -#: InvenTree/status_codes.py:90 InvenTree/status_codes.py:130 +#: InvenTree/status_codes.py:88 InvenTree/status_codes.py:135 msgid "Lost" msgstr "Verloren" -#: InvenTree/status_codes.py:91 +#: InvenTree/status_codes.py:89 InvenTree/status_codes.py:137 msgid "Returned" msgstr "Zurückgegeben" -#: InvenTree/status_codes.py:126 +#: InvenTree/status_codes.py:131 msgid "OK" msgstr "OK" -#: InvenTree/status_codes.py:127 +#: InvenTree/status_codes.py:132 msgid "Attention needed" msgstr "erfordert Eingriff" -#: InvenTree/status_codes.py:128 +#: InvenTree/status_codes.py:133 msgid "Damaged" msgstr "Beschädigt" -#: InvenTree/status_codes.py:129 +#: InvenTree/status_codes.py:134 msgid "Destroyed" msgstr "Zerstört" -#: InvenTree/status_codes.py:163 build/templates/build/allocate_edit.html:28 +#: InvenTree/status_codes.py:136 +msgid "Shipped" +msgstr "" + +#: InvenTree/status_codes.py:173 build/templates/build/allocate_edit.html:28 #: build/templates/build/allocate_view.html:21 #: part/templates/part/part_base.html:114 part/templates/part/tabs.html:21 msgid "Allocated" @@ -142,7 +172,7 @@ msgstr "Überschuss darf 100% nicht überschreiten" msgid "Overage must be an integer value or a percentage" msgstr "Überschuss muss eine Ganzzahl oder ein Prozentwert sein" -#: InvenTree/views.py:549 +#: InvenTree/views.py:536 msgid "Database Statistics" msgstr "" @@ -178,7 +208,7 @@ msgstr "" msgid "Number of parts to build" msgstr "Anzahl der zu bauenden Teile" -#: build/models.py:82 templates/table_filters.html:42 +#: build/models.py:82 templates/table_filters.html:47 msgid "Build status" msgstr "Bau-Status" @@ -186,7 +216,7 @@ msgstr "Bau-Status" msgid "Batch code for this build output" msgstr "Chargennummer für diese Bau-Ausgabe" -#: build/models.py:97 stock/models.py:331 +#: build/models.py:97 stock/models.py:336 msgid "Link to external URL" msgstr "Link zu einer externen URL" @@ -205,15 +235,33 @@ msgid "Allocated quantity ({n}) must not exceed available quantity ({q})" msgstr "" "zugewiesene Anzahl ({n}) darf nicht die verfügbare ({q}) Anzahl überschreiten" -#: build/models.py:409 +#: build/models.py:397 order/models.py:448 +#, fuzzy +#| msgid "Stock Item to allocate to build" +msgid "StockItem is over-allocated" +msgstr "Lagerobjekt dem Bau zuweisen" + +#: build/models.py:400 order/models.py:451 +#, fuzzy +#| msgid "Quantity must be greater than zero" +msgid "Allocation quantity must be greater than zero" +msgstr "Anzahl muss größer Null sein" + +#: build/models.py:403 +#, fuzzy +#| msgid "Quantity must be 1 for item with a serial number" +msgid "Quantity must be 1 for serialized stock" +msgstr "Anzahl muss für Objekte mit Seriennummer \"1\" sein" + +#: build/models.py:418 msgid "Build to allocate parts" msgstr "Bau starten um Teile zuzuweisen" -#: build/models.py:416 +#: build/models.py:425 msgid "Stock Item to allocate to build" msgstr "Lagerobjekt dem Bau zuweisen" -#: build/models.py:424 +#: build/models.py:433 msgid "Stock quantity to allocate to build" msgstr "Lagerobjekt-Anzahl dem Bau zuweisen" @@ -231,8 +279,7 @@ msgstr "Zuweisung aufheben" #: build/templates/build/allocate_edit.html:19 #: build/templates/build/allocate_view.html:17 -#: build/templates/build/detail.html:22 -#: company/templates/company/detail_part.html:65 +#: build/templates/build/detail.html:22 order/models.py:385 #: order/templates/order/order_wizard/select_parts.html:30 #: order/templates/order/purchase_order_detail.html:26 #: part/templates/part/part_app_base.html:7 @@ -258,12 +305,11 @@ msgid "Allocate" msgstr "zuweisen" #: build/templates/build/allocate_view.html:10 -#: company/templates/company/detail_part.html:18 order/views.py:526 +#: company/templates/company/detail_part.html:18 order/views.py:671 msgid "Order Parts" msgstr "Teile bestellen" #: build/templates/build/allocate_view.html:18 -#: company/templates/company/index.html:54 #: company/templates/company/supplier_part_base.html:50 #: company/templates/company/supplier_part_detail.html:27 #: order/templates/order/purchase_order_detail.html:27 @@ -276,47 +322,49 @@ msgstr "Beschreibung" msgid "On Order" msgstr "bestellt" -#: build/templates/build/build_base.html:27 part/templates/part/tabs.html:28 -#: stock/templates/stock/item_base.html:122 templates/navbar.html:12 +#: build/templates/build/build_base.html:8 +#: build/templates/build/build_base.html:21 part/templates/part/tabs.html:28 +#: stock/templates/stock/item_base.html:159 templates/navbar.html:12 msgid "Build" msgstr "Bau" -#: build/templates/build/build_base.html:52 build/templates/build/detail.html:9 +#: build/templates/build/build_base.html:48 build/templates/build/detail.html:9 msgid "Build Details" msgstr "Bau-Status" -#: build/templates/build/build_base.html:56 +#: build/templates/build/build_base.html:52 #, fuzzy #| msgid "Build Notes" msgid "Build Title" msgstr "Bau-Bemerkungen" -#: build/templates/build/build_base.html:66 +#: build/templates/build/build_base.html:62 #: build/templates/build/detail.html:27 #: company/templates/company/supplier_part_pricing.html:27 #: order/templates/order/order_wizard/select_parts.html:32 #: order/templates/order/purchase_order_detail.html:30 -#: stock/templates/stock/item_base.html:108 +#: stock/templates/stock/item_base.html:20 +#: stock/templates/stock/item_base.html:26 +#: stock/templates/stock/item_base.html:145 #: stock/templates/stock/stock_adjust.html:18 msgid "Quantity" msgstr "Anzahl" -#: build/templates/build/build_base.html:71 +#: build/templates/build/build_base.html:67 #: build/templates/build/detail.html:42 -#: order/templates/order/order_base.html:72 -#: stock/templates/stock/item_base.html:175 +#: stock/templates/stock/item_base.html:212 msgid "Status" msgstr "Status" -#: build/templates/build/build_base.html:76 +#: build/templates/build/build_base.html:72 msgid "BOM Price" msgstr "" -#: build/templates/build/build_base.html:81 +#: build/templates/build/build_base.html:77 msgid "BOM pricing is incomplete" msgstr "" -#: build/templates/build/build_base.html:84 +#: build/templates/build/build_base.html:80 #, fuzzy #| msgid "Show pricing information" msgid "No pricing information" @@ -348,20 +396,21 @@ msgid "Stock can be taken from any available location." msgstr "Bestand kann jedem verfügbaren Lagerort entnommen werden." #: build/templates/build/detail.html:48 -#: stock/templates/stock/item_base.html:115 +#: stock/templates/stock/item_base.html:152 msgid "Batch" msgstr "Los" #: build/templates/build/detail.html:55 -#: company/templates/company/supplier_part_base.html:47 +#: company/templates/company/supplier_part_base.html:57 #: company/templates/company/supplier_part_detail.html:24 #: part/templates/part/detail.html:67 part/templates/part/part_base.html:85 -#: stock/templates/stock/item_base.html:143 +#: stock/templates/stock/item_base.html:180 msgid "External Link" msgstr "" #: build/templates/build/detail.html:61 -#: order/templates/order/order_base.html:84 +#: order/templates/order/order_base.html:93 +#: order/templates/order/sales_order_base.html:82 msgid "Created" msgstr "Erstellt" @@ -386,14 +435,16 @@ msgid "Build Notes" msgstr "Bau-Bemerkungen" #: build/templates/build/notes.html:20 company/templates/company/notes.html:17 -#: order/templates/order/order_notes.html:21 part/templates/part/notes.html:20 -#: stock/templates/stock/item_notes.html:21 +#: order/templates/order/order_notes.html:21 +#: order/templates/order/sales_order_notes.html:26 +#: part/templates/part/notes.html:20 stock/templates/stock/item_notes.html:21 msgid "Save" msgstr "Speichern" #: build/templates/build/notes.html:33 company/templates/company/notes.html:30 -#: order/templates/order/order_notes.html:32 part/templates/part/notes.html:32 -#: stock/templates/stock/item_notes.html:32 +#: order/templates/order/order_notes.html:32 +#: order/templates/order/sales_order_notes.html:37 +#: part/templates/part/notes.html:32 stock/templates/stock/item_notes.html:32 msgid "Edit notes" msgstr "Bermerkungen bearbeiten" @@ -406,9 +457,10 @@ msgstr "Details" msgid "Outputs" msgstr "" -#: build/templates/build/tabs.html:11 company/models.py:264 -#: company/templates/company/tabs.html:26 order/templates/order/tabs.html:15 -#: part/templates/part/tabs.html:58 stock/templates/stock/tabs.html:17 +#: build/templates/build/tabs.html:11 company/models.py:302 +#: company/templates/company/tabs.html:26 order/templates/order/po_tabs.html:15 +#: order/templates/order/so_tabs.html:15 part/templates/part/tabs.html:63 +#: stock/templates/stock/tabs.html:17 msgid "Notes" msgstr "Notizen" @@ -597,132 +649,144 @@ msgstr "" msgid "Delete Currency" msgstr "" -#: company/models.py:76 +#: company/models.py:83 msgid "Company name" msgstr "Firmenname" -#: company/models.py:78 +#: company/models.py:85 msgid "Description of the company" msgstr "Firmenbeschreibung" -#: company/models.py:80 +#: company/models.py:87 msgid "Company website URL" msgstr "Firmenwebsite" -#: company/models.py:83 +#: company/models.py:90 msgid "Company address" msgstr "Firmenadresse" -#: company/models.py:86 +#: company/models.py:93 msgid "Contact phone number" msgstr "Kontakt-Tel." -#: company/models.py:88 +#: company/models.py:95 msgid "Contact email address" msgstr "Kontakt-Email" -#: company/models.py:91 +#: company/models.py:98 msgid "Point of contact" msgstr "Anlaufstelle" -#: company/models.py:93 +#: company/models.py:100 msgid "Link to external company information" msgstr "Link auf externe Firmeninformation" -#: company/models.py:105 +#: company/models.py:112 msgid "Do you sell items to this company?" msgstr "Verkaufen Sie Teile an diese Firma?" -#: company/models.py:107 +#: company/models.py:114 msgid "Do you purchase items from this company?" msgstr "Kaufen Sie Teile von dieser Firma?" -#: company/models.py:245 +#: company/models.py:116 +#, fuzzy +#| msgid "Is this part a template part?" +msgid "Does this company manufacture parts?" +msgstr "Ist dieses Teil eine Vorlage?" + +#: company/models.py:276 msgid "Select part" msgstr "Teil auswählen" -#: company/models.py:251 +#: company/models.py:282 msgid "Select supplier" msgstr "Zulieferer auswählen" -#: company/models.py:254 +#: company/models.py:285 msgid "Supplier stock keeping unit" msgstr "Stock Keeping Units (SKU) des Zulieferers" -#: company/models.py:256 company/templates/company/detail_part.html:96 -#: company/templates/company/supplier_part_base.html:53 -#: company/templates/company/supplier_part_detail.html:30 -msgid "Manufacturer" +#: company/models.py:292 +#, fuzzy +#| msgid "Manufacturer" +msgid "Select manufacturer" msgstr "Hersteller" -#: company/models.py:258 +#: company/models.py:296 msgid "Manufacturer part number" msgstr "Hersteller-Teilenummer" -#: company/models.py:260 +#: company/models.py:298 msgid "URL for external supplier part link" msgstr "Teil-URL des Zulieferers" -#: company/models.py:262 +#: company/models.py:300 msgid "Supplier part description" msgstr "Zuliefererbeschreibung des Teils" -#: company/models.py:266 +#: company/models.py:304 msgid "Minimum charge (e.g. stocking fee)" msgstr "Mindestpreis" -#: company/models.py:268 +#: company/models.py:306 msgid "Part packaging" msgstr "Teile-Packaging" -#: company/templates/company/company_base.html:7 order/models.py:131 +#: company/templates/company/company_base.html:7 +#: company/templates/company/company_base.html:22 msgid "Company" msgstr "Firma" -#: company/templates/company/company_base.html:50 -#: company/templates/company/index.html:59 -msgid "Website" -msgstr "" - -#: company/templates/company/company_base.html:57 -msgid "Address" -msgstr "" - -#: company/templates/company/company_base.html:64 -msgid "Phone" -msgstr "" - -#: company/templates/company/company_base.html:71 -msgid "Email" -msgstr "" - -#: company/templates/company/company_base.html:78 -msgid "Contact" -msgstr "" - +#: company/templates/company/company_base.html:42 #: company/templates/company/detail.html:8 #, fuzzy #| msgid "Company Notes" msgid "Company Details" msgstr "Firmenbemerkungen" +#: company/templates/company/company_base.html:48 +msgid "Website" +msgstr "" + +#: company/templates/company/company_base.html:55 +msgid "Address" +msgstr "" + +#: company/templates/company/company_base.html:62 +msgid "Phone" +msgstr "" + +#: company/templates/company/company_base.html:69 +msgid "Email" +msgstr "" + +#: company/templates/company/company_base.html:76 +msgid "Contact" +msgstr "" + #: company/templates/company/detail.html:16 -#: stock/templates/stock/item_base.html:136 -msgid "Customer" -msgstr "Kunde" +#: company/templates/company/supplier_part_base.html:73 +#: company/templates/company/supplier_part_detail.html:30 +msgid "Manufacturer" +msgstr "Hersteller" #: company/templates/company/detail.html:21 -#: company/templates/company/index.html:46 -#: company/templates/company/supplier_part_base.html:44 -#: company/templates/company/supplier_part_detail.html:21 -#: order/templates/order/order_base.html:67 +#: company/templates/company/supplier_part_base.html:63 +#: company/templates/company/supplier_part_detail.html:21 order/models.py:138 +#: order/templates/order/order_base.html:74 #: order/templates/order/order_wizard/select_pos.html:30 -#: stock/templates/stock/item_base.html:150 +#: stock/templates/stock/item_base.html:187 msgid "Supplier" msgstr "Zulieferer" +#: company/templates/company/detail.html:26 order/models.py:275 +#: order/templates/order/sales_order_base.html:63 +#: stock/templates/stock/item_base.html:173 +msgid "Customer" +msgstr "Kunde" + #: company/templates/company/detail_part.html:8 -#: company/templates/company/tabs.html:9 msgid "Supplier Parts" msgstr "Zulieferer-Teile" @@ -743,26 +807,42 @@ msgstr "" msgid "Delete Parts" msgstr "Anhang löschen" -#: company/templates/company/detail_part.html:88 -#: company/templates/company/supplier_part_base.html:45 -#: company/templates/company/supplier_part_detail.html:22 -msgid "SKU" -msgstr "" - -#: company/templates/company/detail_part.html:105 -msgid "Link" -msgstr "" - -#: company/templates/company/detail_purchase_orders.html:8 -#: company/templates/company/tabs.html:15 part/templates/part/tabs.html:43 -msgid "Purchase Orders" -msgstr "Bestellungen" - -#: company/templates/company/detail_purchase_orders.html:13 +#: company/templates/company/detail_part.html:43 +#: part/templates/part/stock.html:75 #, fuzzy -#| msgid "Purchase Order" -msgid "New Purchase Order" -msgstr "Kaufvertrag" +#| msgid "Part" +msgid "New Part" +msgstr "Teil" + +#: company/templates/company/detail_part.html:44 +#, fuzzy +#| msgid "Create new Stock Item" +msgid "Create new Part" +msgstr "Neues Lagerobjekt hinzufügen" + +#: company/templates/company/detail_part.html:49 company/views.py:52 +#, fuzzy +#| msgid "Supplier" +msgid "New Supplier" +msgstr "Zulieferer" + +#: company/templates/company/detail_part.html:50 company/views.py:184 +#, fuzzy +#| msgid "Supplier Part" +msgid "Create new Supplier" +msgstr "Zulieferer-Teil" + +#: company/templates/company/detail_part.html:55 company/views.py:58 +#, fuzzy +#| msgid "Manufacturer" +msgid "New Manufacturer" +msgstr "Hersteller" + +#: company/templates/company/detail_part.html:56 company/views.py:187 +#, fuzzy +#| msgid "Manufacturer" +msgid "Create new Manufacturer" +msgstr "Hersteller" #: company/templates/company/detail_stock.html:9 #, fuzzy @@ -770,34 +850,18 @@ msgstr "Kaufvertrag" msgid "Supplier Stock" msgstr "Zulieferer-Teil" -#: company/templates/company/detail_stock.html:33 +#: company/templates/company/detail_stock.html:34 #: company/templates/company/supplier_part_stock.html:38 #: part/templates/part/stock.html:53 templates/stock_table.html:5 msgid "Export" msgstr "" #: company/templates/company/index.html:7 -#: company/templates/company/index.html:12 #, fuzzy #| msgid "Suppliers" msgid "Supplier List" msgstr "Zulieferer" -#: company/templates/company/index.html:17 -#, fuzzy -#| msgid "Supplier" -msgid "New Supplier" -msgstr "Zulieferer" - -#: company/templates/company/index.html:41 -msgid "ID" -msgstr "" - -#: company/templates/company/index.html:69 part/templates/part/category.html:83 -#: templates/navbar.html:10 templates/stats.html:8 templates/stats.html:17 -msgid "Parts" -msgstr "Teile" - #: company/templates/company/notes.html:10 #: company/templates/company/notes.html:27 msgid "Company Notes" @@ -808,34 +872,85 @@ msgid "Are you sure you want to delete the following Supplier Parts?" msgstr "" "Sind Sie sicher, dass sie die folgenden Zulieferer-Teile löschen möchten?" +#: company/templates/company/purchase_orders.html:9 +#: company/templates/company/tabs.html:17 +#: order/templates/order/purchase_orders.html:7 +#: order/templates/order/purchase_orders.html:12 +#: part/templates/part/orders.html:9 part/templates/part/tabs.html:43 +#: templates/navbar.html:18 +msgid "Purchase Orders" +msgstr "Bestellungen" + +#: company/templates/company/purchase_orders.html:14 +#: order/templates/order/purchase_orders.html:17 +#, fuzzy +#| msgid "Purchase Order" +msgid "Create new purchase order" +msgstr "Kaufvertrag" + +#: company/templates/company/purchase_orders.html:14 +#: order/templates/order/purchase_orders.html:17 +#, fuzzy +#| msgid "Purchase Order" +msgid "New Purchase Order" +msgstr "Kaufvertrag" + +#: company/templates/company/sales_orders.html:9 +#: company/templates/company/tabs.html:22 +#: order/templates/order/sales_orders.html:7 +#: order/templates/order/sales_orders.html:12 +#: part/templates/part/sales_orders.html:9 part/templates/part/tabs.html:48 +#: templates/navbar.html:25 +msgid "Sales Orders" +msgstr "Bestellungen" + +#: company/templates/company/sales_orders.html:14 +#: order/templates/order/sales_orders.html:17 +#, fuzzy +#| msgid "Create new Stock Item" +msgid "Create new sales order" +msgstr "Neues Lagerobjekt hinzufügen" + +#: company/templates/company/sales_orders.html:14 +#: order/templates/order/sales_orders.html:17 +#, fuzzy +#| msgid "Sales Orders" +msgid "New Sales Order" +msgstr "Bestellungen" + #: company/templates/company/supplier_part_base.html:6 -#: company/templates/company/supplier_part_base.html:13 -#: stock/templates/stock/item_base.html:155 +#: company/templates/company/supplier_part_base.html:19 +#: stock/templates/stock/item_base.html:192 msgid "Supplier Part" msgstr "Zulieferer-Teil" -#: company/templates/company/supplier_part_base.html:34 +#: company/templates/company/supplier_part_base.html:35 #: company/templates/company/supplier_part_detail.html:11 #, fuzzy #| msgid "Supplier Parts" msgid "Supplier Part Details" msgstr "Zulieferer-Teile" -#: company/templates/company/supplier_part_base.html:37 +#: company/templates/company/supplier_part_base.html:40 #: company/templates/company/supplier_part_detail.html:14 #, fuzzy #| msgid "Internal Part Number" msgid "Internal Part" msgstr "Interne Teilenummer" -#: company/templates/company/supplier_part_base.html:54 +#: company/templates/company/supplier_part_base.html:67 +#: company/templates/company/supplier_part_detail.html:22 +msgid "SKU" +msgstr "" + +#: company/templates/company/supplier_part_base.html:77 #: company/templates/company/supplier_part_detail.html:31 #, fuzzy #| msgid "IPN" msgid "MPN" msgstr "IPN (Interne Produktnummer)" -#: company/templates/company/supplier_part_base.html:57 +#: company/templates/company/supplier_part_base.html:84 #: company/templates/company/supplier_part_detail.html:34 #: order/templates/order/purchase_order_detail.html:34 msgid "Note" @@ -912,178 +1027,306 @@ msgid "Stock" msgstr "Lagerbestand" #: company/templates/company/supplier_part_tabs.html:11 -#: templates/navbar.html:14 #, fuzzy #| msgid "On Order" msgid "Orders" msgstr "bestellt" -#: company/templates/company/tabs.html:21 -msgid "Sales Orders" -msgstr "Bestellungen" +#: company/templates/company/tabs.html:9 part/templates/part/category.html:83 +#: templates/navbar.html:10 templates/stats.html:8 templates/stats.html:17 +msgid "Parts" +msgstr "Teile" -#: company/views.py:99 +#: company/views.py:51 part/templates/part/tabs.html:37 +#: templates/navbar.html:16 +msgid "Suppliers" +msgstr "Zulieferer" + +#: company/views.py:57 templates/navbar.html:17 +#, fuzzy +#| msgid "Manufacturer" +msgid "Manufacturers" +msgstr "Hersteller" + +#: company/views.py:63 templates/navbar.html:24 +#, fuzzy +#| msgid "Customer" +msgid "Customers" +msgstr "Kunde" + +#: company/views.py:64 +#, fuzzy +#| msgid "Customer" +msgid "New Customer" +msgstr "Kunde" + +#: company/views.py:71 +#, fuzzy +#| msgid "Company" +msgid "Companies" +msgstr "Firma" + +#: company/views.py:72 +#, fuzzy +#| msgid "Company" +msgid "New Company" +msgstr "Firma" + +#: company/views.py:149 #, fuzzy #| msgid "Company name" msgid "Update Company Image" msgstr "Firmenname" -#: company/views.py:104 +#: company/views.py:154 msgid "Updated company image" msgstr "" -#: company/views.py:114 +#: company/views.py:164 #, fuzzy #| msgid "Company" msgid "Edit Company" msgstr "Firma" -#: company/views.py:118 +#: company/views.py:168 #, fuzzy #| msgid "Link to external company information" msgid "Edited company information" msgstr "Link auf externe Firmeninformation" -#: company/views.py:128 +#: company/views.py:190 +#, fuzzy +#| msgid "Create new Stock Item" +msgid "Create new Customer" +msgstr "Neues Lagerobjekt hinzufügen" + +#: company/views.py:192 #, fuzzy #| msgid "Create new Stock Item" msgid "Create new Company" msgstr "Neues Lagerobjekt hinzufügen" -#: company/views.py:132 +#: company/views.py:219 #, fuzzy #| msgid "Created new stock item" msgid "Created new company" msgstr "Neues Lagerobjekt erstellt" -#: company/views.py:142 +#: company/views.py:229 #, fuzzy #| msgid "Company" msgid "Delete Company" msgstr "Firma" -#: company/views.py:147 +#: company/views.py:234 #, fuzzy #| msgid "Company address" msgid "Company was deleted" msgstr "Firmenadresse" -#: company/views.py:172 +#: company/views.py:259 #, fuzzy #| msgid "Supplier Part" msgid "Edit Supplier Part" msgstr "Zulieferer-Teil" -#: company/views.py:181 part/templates/part/stock.html:82 +#: company/views.py:268 part/templates/part/stock.html:82 #, fuzzy #| msgid "Supplier Part" msgid "Create new Supplier Part" msgstr "Zulieferer-Teil" -#: company/views.py:238 +#: company/views.py:328 #, fuzzy #| msgid "Supplier Part" msgid "Delete Supplier Part" msgstr "Zulieferer-Teil" -#: company/views.py:308 +#: company/views.py:398 msgid "Add Price Break" msgstr "" -#: company/views.py:350 +#: company/views.py:440 msgid "Edit Price Break" msgstr "" -#: company/views.py:365 +#: company/views.py:455 #, fuzzy #| msgid "Delete attachment" msgid "Delete Price Break" msgstr "Anhang löschen" -#: order/forms.py:22 +#: order/forms.py:24 msgid "Place order" msgstr "Bestellung aufgeben" -#: order/forms.py:33 +#: order/forms.py:35 msgid "Mark order as complete" msgstr "Bestellung als vollständig markieren" -#: order/forms.py:44 +#: order/forms.py:46 msgid "Cancel order" msgstr "Bestellung stornieren" -#: order/forms.py:55 +#: order/forms.py:57 msgid "Receive parts to this location" msgstr "Teile in diesen Ort empfangen" -#: order/models.py:68 +#: order/models.py:71 msgid "Order reference" msgstr "Bestell-Referenz" -#: order/models.py:70 +#: order/models.py:73 msgid "Order description" msgstr "Bestellungs-Beschreibung" -#: order/models.py:72 +#: order/models.py:75 msgid "Link to external page" msgstr "Link auf externe Seite" -#: order/models.py:89 +#: order/models.py:92 msgid "Order notes" msgstr "Bestell-Notizen" -#: order/models.py:162 order/models.py:213 part/views.py:1119 -#: stock/models.py:467 +#: order/models.py:141 +#, fuzzy +#| msgid "Order reference" +msgid "Supplier order reference code" +msgstr "Bestell-Referenz" + +#: order/models.py:171 order/models.py:222 part/views.py:1119 +#: stock/models.py:519 msgid "Quantity must be greater than zero" msgstr "Anzahl muss größer Null sein" -#: order/models.py:167 +#: order/models.py:176 msgid "Part supplier must match PO supplier" msgstr "Teile-Zulieferer muss dem Zulieferer des Kaufvertrags entsprechen" -#: order/models.py:208 +#: order/models.py:217 msgid "Lines can only be received against an order marked as 'Placed'" msgstr "Nur Teile aufgegebener Bestllungen können empfangen werden" -#: order/models.py:268 +#: order/models.py:278 +#, fuzzy +#| msgid "Order reference" +msgid "Customer order reference code" +msgstr "Bestell-Referenz" + +#: order/models.py:324 msgid "Item quantity" msgstr "Anzahl" -#: order/models.py:270 +#: order/models.py:326 msgid "Line item reference" msgstr "Position - Referenz" -#: order/models.py:272 +#: order/models.py:328 msgid "Line item notes" msgstr "Position - Notizen" -#: order/models.py:298 stock/templates/stock/item_base.html:129 +#: order/models.py:354 order/templates/order/order_base.html:9 +#: order/templates/order/order_base.html:23 +#: stock/templates/stock/item_base.html:166 msgid "Purchase Order" msgstr "Kaufvertrag" -#: order/models.py:307 +#: order/models.py:363 msgid "Supplier part" msgstr "Zulieferer-Teil" -#: order/models.py:310 +#: order/models.py:366 msgid "Number of items received" msgstr "Empfangene Objekt-Anzahl" -#: order/templates/order/order_base.html:62 +#: order/models.py:383 order/templates/order/sales_order_base.html:9 +#: order/templates/order/sales_order_base.html:31 +#: order/templates/order/sales_order_notes.html:10 +#, fuzzy +#| msgid "Sales Orders" +msgid "Sales Order" +msgstr "Bestellungen" + +#: order/models.py:440 +msgid "Cannot allocate stock item to a line with a different part" +msgstr "" + +#: order/models.py:442 +msgid "Cannot allocate stock to a line without a part" +msgstr "" + +#: order/models.py:445 +#, fuzzy +#| msgid "Allocated quantity ({n}) must not exceed available quantity ({q})" +msgid "Allocation quantity cannot exceed stock quantity" +msgstr "" +"zugewiesene Anzahl ({n}) darf nicht die verfügbare ({q}) Anzahl überschreiten" + +#: order/models.py:454 +#, fuzzy +#| msgid "Quantity must be 1 for item with a serial number" +msgid "Quantity must be 1 for serialized stock item" +msgstr "Anzahl muss für Objekte mit Seriennummer \"1\" sein" + +#: order/models.py:466 +#, fuzzy +#| msgid "Stock Item to allocate to build" +msgid "Select stock item to allocate" +msgstr "Lagerobjekt dem Bau zuweisen" + +#: order/models.py:469 +#, fuzzy +#| msgid "Enter a valid quantity" +msgid "Enter stock allocation quantity" +msgstr "Bitte eine gültige Anzahl eingeben" + +#: order/templates/order/delete_attachment.html:5 +#: part/templates/part/attachment_delete.html:5 +#, fuzzy +#| msgid "Are you sure you want to delete the following Supplier Parts?" +msgid "Are you sure you want to delete this attachment?" +msgstr "" +"Sind Sie sicher, dass sie die folgenden Zulieferer-Teile löschen möchten?" + +#: order/templates/order/order_base.html:59 msgid "Purchase Order Details" msgstr "Bestelldetails" -#: order/templates/order/order_base.html:90 +#: order/templates/order/order_base.html:64 +#: order/templates/order/sales_order_base.html:53 +#, fuzzy +#| msgid "Order reference" +msgid "Order Reference" +msgstr "Bestell-Referenz" + +#: order/templates/order/order_base.html:69 +#: order/templates/order/sales_order_base.html:58 +#, fuzzy +#| msgid "Order Parts" +msgid "Order Status" +msgstr "Teile bestellen" + +#: order/templates/order/order_base.html:80 +#, fuzzy +#| msgid "Reference" +msgid "Supplier Reference" +msgstr "Referenz" + +#: order/templates/order/order_base.html:99 +#: order/templates/order/sales_order_base.html:88 msgid "Issued" msgstr "Aufgegeben" -#: order/templates/order/order_base.html:97 +#: order/templates/order/order_base.html:106 #: order/templates/order/purchase_order_detail.html:32 +#: order/templates/order/sales_order_base.html:95 msgid "Received" msgstr "Empfangen" #: order/templates/order/order_notes.html:13 #: order/templates/order/order_notes.html:29 +#: order/templates/order/sales_order_notes.html:18 +#: order/templates/order/sales_order_notes.html:34 msgid "Order Notes" msgstr "Bestellungsbemerkungen" @@ -1124,7 +1367,7 @@ msgid "Select existing purchase orders, or create new orders." msgstr "" #: order/templates/order/order_wizard/select_pos.html:31 -#: order/templates/order/tabs.html:5 +#: order/templates/order/po_tabs.html:5 order/templates/order/so_tabs.html:5 msgid "Items" msgstr "Positionen" @@ -1147,45 +1390,51 @@ msgid "Purchase Order Attachments" msgstr "Bestelldetails" #: order/templates/order/po_attachments.html:17 +#: order/templates/order/so_attachments.html:17 #: part/templates/part/attachments.html:14 msgid "Add Attachment" msgstr "Anhang hinzufügen" #: order/templates/order/po_attachments.html:24 +#: order/templates/order/so_attachments.html:24 #: part/templates/part/attachments.html:22 msgid "File" msgstr "Datei" #: order/templates/order/po_attachments.html:25 +#: order/templates/order/so_attachments.html:25 #: part/templates/part/attachments.html:23 msgid "Comment" msgstr "Kommentar" #: order/templates/order/po_attachments.html:36 +#: order/templates/order/so_attachments.html:36 #: part/templates/part/attachments.html:34 part/views.py:119 msgid "Edit attachment" msgstr "Anhang bearbeiten" #: order/templates/order/po_attachments.html:39 +#: order/templates/order/so_attachments.html:39 #: part/templates/part/attachments.html:37 msgid "Delete attachment" msgstr "Anhang löschen" -#: order/templates/order/po_delete.html:5 -#: part/templates/part/attachment_delete.html:5 -#, fuzzy -#| msgid "Are you sure you want to delete the following Supplier Parts?" -msgid "Are you sure you want to delete this attachment?" -msgstr "" -"Sind Sie sicher, dass sie die folgenden Zulieferer-Teile löschen möchten?" +#: order/templates/order/po_tabs.html:8 order/templates/order/so_tabs.html:8 +#: part/templates/part/tabs.html:60 +msgid "Attachments" +msgstr "Anhänge" -#: order/templates/order/purchase_order_detail.html:16 order/views.py:825 +#: order/templates/order/purchase_order_detail.html:16 +#: order/templates/order/sales_order_detail.html:17 order/views.py:970 +#: order/views.py:1084 msgid "Add Line Item" msgstr "Position hinzufügen" #: order/templates/order/purchase_order_detail.html:20 -msgid "Order Items" -msgstr "Bestellungspositionen" +#, fuzzy +#| msgid "Purchase Orders" +msgid "Purchase Order Items" +msgstr "Bestellungen" #: order/templates/order/purchase_order_detail.html:25 msgid "Line" @@ -1199,140 +1448,248 @@ msgstr "Bestellnummer" msgid "Reference" msgstr "Referenz" -#: order/templates/order/tabs.html:8 part/templates/part/tabs.html:55 -msgid "Attachments" -msgstr "Anhänge" +#: order/templates/order/sales_order_base.html:15 +msgid "This SalesOrder has not been fully allocated" +msgstr "" -#: order/views.py:80 +#: order/templates/order/sales_order_base.html:40 +#, fuzzy +#| msgid "Parts" +msgid "Packing List" +msgstr "Teile" + +#: order/templates/order/sales_order_base.html:48 +#, fuzzy +#| msgid "Purchase Order Details" +msgid "Sales Order Details" +msgstr "Bestelldetails" + +#: order/templates/order/sales_order_base.html:69 +#, fuzzy +#| msgid "Reference" +msgid "Customer Reference" +msgstr "Referenz" + +#: order/templates/order/sales_order_detail.html:14 +#, fuzzy +#| msgid "Sales Orders" +msgid "Sales Order Items" +msgstr "Bestellungen" + +#: order/templates/order/sales_order_detail.html:90 +#, fuzzy +#| msgid "Edit Stock Location" +msgid "Edit stock allocation" +msgstr "Lagerobjekt-Standort bearbeiten" + +#: order/templates/order/sales_order_detail.html:91 +#, fuzzy +#| msgid "Delete Stock Location" +msgid "Delete stock allocation" +msgstr "Standort löschen" + +#: order/templates/order/sales_order_detail.html:178 +#, fuzzy +#| msgid "All parts" +msgid "Buy parts" +msgstr "Alle Teile" + +#: order/templates/order/sales_order_detail.html:182 +#, fuzzy +#| msgid "Build status" +msgid "Build parts" +msgstr "Bau-Status" + +#: order/templates/order/sales_order_detail.html:185 +#, fuzzy +#| msgid "All parts" +msgid "Allocate parts" +msgstr "Alle Teile" + +#: order/templates/order/sales_order_detail.html:189 +#, fuzzy +#| msgid "Add Line Item" +msgid "Edit line item" +msgstr "Position hinzufügen" + +#: order/templates/order/sales_order_detail.html:190 +#, fuzzy +#| msgid "Deleted {n} stock items" +msgid "Delete line item " +msgstr "{n} Teile im Lager gelöscht" + +#: order/templates/order/so_attachments.html:11 +#, fuzzy +#| msgid "Purchase Order Details" +msgid "Sales Order Attachments" +msgstr "Bestelldetails" + +#: order/views.py:97 #, fuzzy #| msgid "Purchase Order Details" msgid "Add Purchase Order Attachment" msgstr "Bestelldetails" -#: order/views.py:85 part/views.py:80 +#: order/views.py:102 order/views.py:142 part/views.py:80 #, fuzzy #| msgid "Add Attachment" msgid "Added attachment" msgstr "Anhang hinzufügen" -#: order/views.py:121 +#: order/views.py:138 +#, fuzzy +#| msgid "Purchase Order Details" +msgid "Add Sales Order Attachment" +msgstr "Bestelldetails" + +#: order/views.py:166 order/views.py:187 #, fuzzy #| msgid "Edit attachment" msgid "Edit Attachment" msgstr "Anhang bearbeiten" -#: order/views.py:125 +#: order/views.py:170 order/views.py:191 #, fuzzy #| msgid "Part Attachments" msgid "Attachment updated" msgstr "Anhänge" -#: order/views.py:141 +#: order/views.py:206 order/views.py:220 #, fuzzy #| msgid "Delete attachment" msgid "Delete Attachment" msgstr "Anhang löschen" -#: order/views.py:147 +#: order/views.py:212 order/views.py:226 #, fuzzy #| msgid "Delete attachment" msgid "Deleted attachment" msgstr "Anhang löschen" -#: order/views.py:177 +#: order/views.py:277 #, fuzzy #| msgid "Purchase Order" msgid "Create Purchase Order" msgstr "Kaufvertrag" -#: order/views.py:207 +#: order/views.py:307 +#, fuzzy +#| msgid "Purchase Order" +msgid "Create Sales Order" +msgstr "Kaufvertrag" + +#: order/views.py:336 #, fuzzy #| msgid "Purchase Order" msgid "Edit Purchase Order" msgstr "Kaufvertrag" -#: order/views.py:227 +#: order/views.py:356 +#, fuzzy +#| msgid "Sales Orders" +msgid "Edit Sales Order" +msgstr "Bestellungen" + +#: order/views.py:372 #, fuzzy #| msgid "Cancel order" msgid "Cancel Order" msgstr "Bestellung stornieren" -#: order/views.py:242 +#: order/views.py:387 msgid "Confirm order cancellation" msgstr "Bestell-Stornierung bestätigen" -#: order/views.py:260 +#: order/views.py:405 #, fuzzy #| msgid "Issued" msgid "Issue Order" msgstr "Aufgegeben" -#: order/views.py:275 +#: order/views.py:420 msgid "Confirm order placement" msgstr "Bestellungstätigung bestätigen" -#: order/views.py:296 +#: order/views.py:441 #, fuzzy #| msgid "Completed" msgid "Complete Order" msgstr "Fertig" -#: order/views.py:362 +#: order/views.py:507 #, fuzzy #| msgid "Required Parts" msgid "Receive Parts" msgstr "benötigte Teile" -#: order/views.py:429 +#: order/views.py:574 msgid "Items received" msgstr "Anzahl empfangener Positionen" -#: order/views.py:443 +#: order/views.py:588 msgid "No destination set" msgstr "Kein Ziel gesetzt" -#: order/views.py:474 +#: order/views.py:619 msgid "Error converting quantity to number" msgstr "Fehler beim Konvertieren zu Zahl" -#: order/views.py:480 +#: order/views.py:625 msgid "Receive quantity less than zero" msgstr "Anzahl kleiner null empfangen" -#: order/views.py:486 +#: order/views.py:631 msgid "No lines specified" msgstr "Keine Zeilen angegeben" -#: order/views.py:845 +#: order/views.py:990 msgid "Invalid Purchase Order" msgstr "Ungültige Bestellung" -#: order/views.py:853 +#: order/views.py:998 msgid "Supplier must match for Part and Order" msgstr "Zulieferer muss zum Teil und zur Bestellung passen" -#: order/views.py:858 +#: order/views.py:1003 msgid "Invalid SupplierPart selection" msgstr "Ungültige Wahl des Zulieferer-Teils" -#: order/views.py:940 +#: order/views.py:1123 order/views.py:1141 #, fuzzy #| msgid "Add Line Item" msgid "Edit Line Item" msgstr "Position hinzufügen" -#: order/views.py:956 +#: order/views.py:1157 order/views.py:1169 #, fuzzy #| msgid "Delete Stock Item" msgid "Delete Line Item" msgstr "Lagerobjekt löschen" -#: order/views.py:961 +#: order/views.py:1162 order/views.py:1174 #, fuzzy #| msgid "Deleted {n} stock items" msgid "Deleted line item" msgstr "{n} Teile im Lager gelöscht" +#: order/views.py:1183 +#, fuzzy +#| msgid "Allocate Stock to Build" +msgid "Allocate Stock to Order" +msgstr "Lagerbestand dem Bau zuweisen" + +#: order/views.py:1252 +#, fuzzy +#| msgid "Edit Stock Location" +msgid "Edit Allocation Quantity" +msgstr "Lagerobjekt-Standort bearbeiten" + +#: order/views.py:1267 +#, fuzzy +#| msgid "Receive parts to this location" +msgid "Remove allocation" +msgstr "Teile in diesen Ort empfangen" + #: part/bom.py:140 #, python-brace-format msgid "Unsupported file format: {f}" @@ -1502,63 +1859,63 @@ msgstr "Bemerkungen - unterstüzt Markdown-Formatierung" msgid "Stored BOM checksum" msgstr "Prüfsumme der Stückliste gespeichert" -#: part/models.py:1049 +#: part/models.py:1065 msgid "Parameter template name must be unique" msgstr "Vorlagen-Name des Parameters muss eindeutig sein" -#: part/models.py:1054 +#: part/models.py:1070 msgid "Parameter Name" msgstr "Name des Parameters" -#: part/models.py:1056 +#: part/models.py:1072 msgid "Parameter Units" msgstr "Parameter Einheit" -#: part/models.py:1082 +#: part/models.py:1098 msgid "Parent Part" msgstr "Ausgangsteil" -#: part/models.py:1084 +#: part/models.py:1100 msgid "Parameter Template" msgstr "Parameter Vorlage" -#: part/models.py:1086 +#: part/models.py:1102 msgid "Parameter Value" msgstr "Parameter Wert" -#: part/models.py:1110 +#: part/models.py:1126 msgid "Select parent part" msgstr "Ausgangsteil auswählen" -#: part/models.py:1119 +#: part/models.py:1135 msgid "Select part to be used in BOM" msgstr "Teil für die Nutzung in der Stückliste auswählen" -#: part/models.py:1126 +#: part/models.py:1142 msgid "BOM quantity for this BOM item" msgstr "Stücklisten-Anzahl für dieses Stücklisten-Teil" -#: part/models.py:1129 +#: part/models.py:1145 msgid "Estimated build wastage quantity (absolute or percentage)" msgstr "Geschätzter Ausschuss (absolut oder prozentual)" -#: part/models.py:1132 +#: part/models.py:1148 msgid "BOM item reference" msgstr "Referenz des Objekts auf der Stückliste" -#: part/models.py:1135 +#: part/models.py:1151 msgid "BOM item notes" msgstr "Notizen zum Stücklisten-Objekt" -#: part/models.py:1137 +#: part/models.py:1153 msgid "BOM line checksum" msgstr "Prüfsumme der Stückliste" -#: part/models.py:1200 +#: part/models.py:1216 msgid "Part cannot be added to its own Bill of Materials" msgstr "Teil kann nicht zu seiner eigenen Stückliste hinzugefügt werden" -#: part/models.py:1207 +#: part/models.py:1223 #, python-brace-format msgid "Part '{p1}' is used in BOM for '{p2}' (recursive)" msgstr "Teil '{p1}' wird in Stückliste für Teil '{p2}' benutzt (rekursiv)" @@ -1664,7 +2021,7 @@ msgstr "Teil ist virtuell (kein physisches Teil)" msgid "Part is not a virtual part" msgstr "Teil ist nicht virtuell" -#: part/templates/part/detail.html:132 templates/table_filters.html:86 +#: part/templates/part/detail.html:132 templates/table_filters.html:91 msgid "Assembly" msgstr "Baugruppe" @@ -1676,7 +2033,7 @@ msgstr "Teil kann aus anderen Teilen angefertigt werden" msgid "Part cannot be assembled from other parts" msgstr "Teil kann nicht aus anderen Teilen angefertigt werden" -#: part/templates/part/detail.html:141 templates/table_filters.html:90 +#: part/templates/part/detail.html:141 templates/table_filters.html:95 msgid "Component" msgstr "Komponente" @@ -1708,15 +2065,17 @@ msgstr "Kaufbar" msgid "Part can be purchased from external suppliers" msgstr "Teil kann von externen Zulieferern gekauft werden" -#: part/templates/part/detail.html:169 -msgid "Sellable" +#: part/templates/part/detail.html:168 templates/table_filters.html:103 +#, fuzzy +#| msgid "Sellable" +msgid "Salable" msgstr "Verkaufbar" -#: part/templates/part/detail.html:172 +#: part/templates/part/detail.html:171 msgid "Part can be sold to customers" msgstr "Teil kann an Kunden verkauft werden" -#: part/templates/part/detail.html:174 +#: part/templates/part/detail.html:173 msgid "Part cannot be sold to customers" msgstr "Teil kann nicht an Kunden verkauft werden" @@ -1724,6 +2083,18 @@ msgstr "Teil kann nicht an Kunden verkauft werden" msgid "Part Notes" msgstr "Teil-Bemerkungen" +#: part/templates/part/orders.html:14 +#, fuzzy +#| msgid "Order Parts" +msgid "Order part" +msgstr "Teile bestellen" + +#: part/templates/part/orders.html:14 +#, fuzzy +#| msgid "Order Parts" +msgid "Order Part" +msgstr "Teile bestellen" + #: part/templates/part/part_app_base.html:9 #, fuzzy #| msgid "Part category" @@ -1794,11 +2165,17 @@ msgstr "Teil auswählen" msgid "Upload new image" msgstr "" -#: part/templates/part/stock.html:75 +#: part/templates/part/sales_orders.html:14 #, fuzzy -#| msgid "Part" -msgid "New Part" -msgstr "Teil" +#| msgid "Sales Orders" +msgid "New sales order" +msgstr "Bestellungen" + +#: part/templates/part/sales_orders.html:14 +#, fuzzy +#| msgid "On Order" +msgid "New Order" +msgstr "bestellt" #: part/templates/part/stock.html:76 #, fuzzy @@ -1812,7 +2189,7 @@ msgstr "Neues Lagerobjekt hinzufügen" msgid "No Stock" msgstr "Lagerbestand" -#: part/templates/part/stock_count.html:9 +#: part/templates/part/stock_count.html:9 templates/InvenTree/low_stock.html:7 #, fuzzy #| msgid "Stock" msgid "Low Stock" @@ -1834,11 +2211,7 @@ msgstr "Stückliste" msgid "Used In" msgstr "Benutzt in" -#: part/templates/part/tabs.html:37 templates/navbar.html:13 -msgid "Suppliers" -msgstr "Zulieferer" - -#: part/templates/part/tabs.html:48 stock/templates/stock/tabs.html:5 +#: part/templates/part/tabs.html:53 stock/templates/stock/tabs.html:5 msgid "Tracking" msgstr "Tracking" @@ -1965,102 +2338,124 @@ msgstr "Teil auswählen" msgid "Specify quantity" msgstr "Anzahl angeben" -#: part/views.py:1366 +#: part/views.py:1364 msgid "Export Bill of Materials" msgstr "" -#: part/views.py:1404 +#: part/views.py:1402 #, fuzzy #| msgid "Confirm part creation" msgid "Confirm Part Deletion" msgstr "Erstellen des Teils bestätigen" -#: part/views.py:1411 +#: part/views.py:1409 msgid "Part was deleted" msgstr "" -#: part/views.py:1420 +#: part/views.py:1418 #, fuzzy #| msgid "Part packaging" msgid "Part Pricing" msgstr "Teile-Packaging" -#: part/views.py:1542 +#: part/views.py:1540 #, fuzzy #| msgid "Parameter Template" msgid "Create Part Parameter Template" msgstr "Parameter Vorlage" -#: part/views.py:1550 +#: part/views.py:1548 #, fuzzy #| msgid "Parameter Template" msgid "Edit Part Parameter Template" msgstr "Parameter Vorlage" -#: part/views.py:1557 +#: part/views.py:1555 #, fuzzy #| msgid "Parameter Template" msgid "Delete Part Parameter Template" msgstr "Parameter Vorlage" -#: part/views.py:1565 +#: part/views.py:1563 msgid "Create Part Parameter" msgstr "" -#: part/views.py:1615 +#: part/views.py:1613 #, fuzzy #| msgid "Edit attachment" msgid "Edit Part Parameter" msgstr "Anhang bearbeiten" -#: part/views.py:1629 +#: part/views.py:1627 #, fuzzy #| msgid "Delete attachment" msgid "Delete Part Parameter" msgstr "Anhang löschen" -#: part/views.py:1645 +#: part/views.py:1643 #, fuzzy #| msgid "Part category" msgid "Edit Part Category" msgstr "Teile-Kategorie" -#: part/views.py:1680 +#: part/views.py:1678 #, fuzzy #| msgid "Select part category" msgid "Delete Part Category" msgstr "Teilekategorie wählen" -#: part/views.py:1686 +#: part/views.py:1684 #, fuzzy #| msgid "Part category" msgid "Part category was deleted" msgstr "Teile-Kategorie" -#: part/views.py:1694 +#: part/views.py:1692 #, fuzzy #| msgid "Select part category" msgid "Create new part category" msgstr "Teilekategorie wählen" -#: part/views.py:1745 +#: part/views.py:1743 #, fuzzy #| msgid "Created new stock item" msgid "Create BOM item" msgstr "Neues Lagerobjekt erstellt" -#: part/views.py:1811 +#: part/views.py:1809 #, fuzzy #| msgid "Edit Stock Item" msgid "Edit BOM item" msgstr "Lagerobjekt bearbeiten" -#: part/views.py:1859 +#: part/views.py:1857 #, fuzzy #| msgid "Confirm build completion" msgid "Confim BOM item deletion" msgstr "Bau-Fertigstellung bestätigen" +#: plugins/barcode/inventree.py:70 +#, fuzzy +#| msgid "Part Notes" +msgid "Part does not exist" +msgstr "Teil-Bemerkungen" + +#: plugins/barcode/inventree.py:79 +#, fuzzy +#| msgid "Stock Location QR code" +msgid "StockLocation does not exist" +msgstr "QR-Code für diesen Standort" + +#: plugins/barcode/inventree.py:89 +#, fuzzy +#| msgid "Stock Item Notes" +msgid "StockItem does not exist" +msgstr "Lagerobjekt-Notizen" + +#: plugins/barcode/inventree.py:92 +msgid "No matching data" +msgstr "" + #: stock/forms.py:93 msgid "Include stock items in sub locations" msgstr "Lagerobjekte in untergeordneten Lagerorten einschließen" @@ -2077,7 +2472,7 @@ msgstr "Bewegung der Lagerobjekte bestätigen" msgid "Set the destination as the default location for selected parts" msgstr "Setze das Ziel als Standard-Ziel für ausgewählte Teile" -#: stock/models.py:205 +#: stock/models.py:210 #, python-brace-format msgid "" "A stock item with this serial number already exists for template part {part}" @@ -2085,116 +2480,116 @@ msgstr "" "Ein Teil mit dieser Seriennummer existiert bereits für die Teilevorlage " "{part}" -#: stock/models.py:210 +#: stock/models.py:215 msgid "A stock item with this serial number already exists" msgstr "Ein Teil mit dieser Seriennummer existiert bereits" -#: stock/models.py:229 +#: stock/models.py:234 #, python-brace-format msgid "Part type ('{pf}') must be {pe}" msgstr "Teile-Typ ('{pf}') muss {pe} sein" -#: stock/models.py:239 stock/models.py:248 +#: stock/models.py:244 stock/models.py:253 msgid "Quantity must be 1 for item with a serial number" msgstr "Anzahl muss für Objekte mit Seriennummer \"1\" sein" -#: stock/models.py:240 +#: stock/models.py:245 msgid "Serial number cannot be set if quantity greater than 1" msgstr "" "Seriennummer kann nicht gesetzt werden wenn die Anzahl größer als \"1\" ist" -#: stock/models.py:256 +#: stock/models.py:261 msgid "Stock item cannot be created for a template Part" msgstr "Lagerobjekt kann nicht für Vorlagen-Teile angelegt werden" -#: stock/models.py:265 +#: stock/models.py:270 msgid "Item cannot belong to itself" msgstr "Teil kann nicht zu sich selbst gehören" -#: stock/models.py:306 +#: stock/models.py:311 msgid "Base part" msgstr "Basis-Teil" -#: stock/models.py:314 +#: stock/models.py:319 msgid "Select a matching supplier part for this stock item" msgstr "Passenden Zulieferer für dieses Lagerobjekt auswählen" -#: stock/models.py:318 +#: stock/models.py:323 msgid "Where is this stock item located?" msgstr "Wo wird dieses Teil normalerweise gelagert?" -#: stock/models.py:322 +#: stock/models.py:327 msgid "Is this item installed in another item?" msgstr "Ist dieses Teil in einem anderen verbaut?" -#: stock/models.py:326 +#: stock/models.py:331 msgid "Item assigned to customer?" msgstr "Ist dieses Objekt einem Kunden zugeteilt?" -#: stock/models.py:329 +#: stock/models.py:334 msgid "Serial number for this item" msgstr "Seriennummer für dieses Teil" -#: stock/models.py:334 +#: stock/models.py:339 msgid "Batch code for this stock item" msgstr "Losnummer für dieses Lagerobjekt" -#: stock/models.py:343 +#: stock/models.py:348 msgid "Build for this stock item" msgstr "Bau für dieses Lagerobjekt" -#: stock/models.py:352 +#: stock/models.py:357 msgid "Purchase order for this stock item" msgstr "Bestellung für dieses Teil" -#: stock/models.py:363 +#: stock/models.py:374 msgid "Delete this Stock Item when stock is depleted" msgstr "Objekt löschen wenn Lagerbestand aufgebraucht" -#: stock/models.py:370 stock/templates/stock/item_notes.html:13 +#: stock/models.py:381 stock/templates/stock/item_notes.html:13 #: stock/templates/stock/item_notes.html:29 msgid "Stock Item Notes" msgstr "Lagerobjekt-Notizen" -#: stock/models.py:464 +#: stock/models.py:516 msgid "Quantity must be integer" msgstr "Anzahl muss eine Ganzzahl sein" -#: stock/models.py:470 +#: stock/models.py:522 #, python-brace-format msgid "Quantity must not exceed available stock quantity ({n})" msgstr "Anzahl darf nicht die verfügbare Anzahl überschreiten ({n})" -#: stock/models.py:473 stock/models.py:476 +#: stock/models.py:525 stock/models.py:528 msgid "Serial numbers must be a list of integers" msgstr "Seriennummern muss eine Liste von Ganzzahlen sein" -#: stock/models.py:479 +#: stock/models.py:531 msgid "Quantity does not match serial numbers" msgstr "Anzahl stimmt nicht mit den Seriennummern überein" -#: stock/models.py:489 +#: stock/models.py:541 msgid "Serial numbers already exist: " msgstr "Seriennummern existieren bereits:" -#: stock/models.py:511 +#: stock/models.py:563 msgid "Add serial number" msgstr "Seriennummer hinzufügen" -#: stock/models.py:514 +#: stock/models.py:566 #, python-brace-format msgid "Serialized {n} items" msgstr "{n} Teile serialisiert" -#: stock/models.py:814 +#: stock/models.py:866 msgid "Tracking entry title" msgstr "Name des Eintrags-Trackings" -#: stock/models.py:816 +#: stock/models.py:868 msgid "Entry notes" msgstr "Eintrags-Notizen" -#: stock/models.py:818 +#: stock/models.py:870 msgid "Link to external page for further information" msgstr "Link auf externe Seite für weitere Informationen" @@ -2202,11 +2597,25 @@ msgstr "Link auf externe Seite für weitere Informationen" msgid "Stock Tracking Information" msgstr "Informationen zum Lagerbestands-Tracking" -#: stock/templates/stock/item_base.html:11 -msgid "Stock Item Details" -msgstr "Lagerbestands-Details" - +#: stock/templates/stock/item_base.html:8 #: stock/templates/stock/item_base.html:56 +#: stock/templates/stock/stock_adjust.html:16 +msgid "Stock Item" +msgstr "Lagerobjekt" + +#: stock/templates/stock/item_base.html:20 +#, fuzzy +#| msgid "Stock Item to allocate to build" +msgid "This stock item is allocated to Sales Order" +msgstr "Lagerobjekt dem Bau zuweisen" + +#: stock/templates/stock/item_base.html:26 +#, fuzzy +#| msgid "Stock Item to allocate to build" +msgid "This stock item is allocated to Build" +msgstr "Lagerobjekt dem Bau zuweisen" + +#: stock/templates/stock/item_base.html:32 msgid "" "This stock item is serialized - it has a unique serial number and the " "quantity cannot be adjusted." @@ -2214,45 +2623,53 @@ msgstr "" "Dieses Lagerobjekt ist serialisiert. Es hat eine eindeutige Seriennummer und " "die Anzahl kann nicht angepasst werden." -#: stock/templates/stock/item_base.html:60 +#: stock/templates/stock/item_base.html:36 #, fuzzy #| msgid "Stock item cannot be created for a template Part" msgid "This stock item cannot be deleted as it has child items" msgstr "Lagerobjekt kann nicht für Vorlagen-Teile angelegt werden" -#: stock/templates/stock/item_base.html:64 +#: stock/templates/stock/item_base.html:40 msgid "" "This stock item will be automatically deleted when all stock is depleted." msgstr "" "Dieses Lagerobjekt wird automatisch gelöscht wenn der Lagerbestand " "aufgebraucht ist." -#: stock/templates/stock/item_base.html:69 +#: stock/templates/stock/item_base.html:45 msgid "This stock item was split from " msgstr "" -#: stock/templates/stock/item_base.html:89 +#: stock/templates/stock/item_base.html:105 +msgid "Stock Item Details" +msgstr "Lagerbestands-Details" + +#: stock/templates/stock/item_base.html:119 msgid "Belongs To" msgstr "Gehört zu" -#: stock/templates/stock/item_base.html:95 +#: stock/templates/stock/item_base.html:125 #: stock/templates/stock/stock_adjust.html:17 msgid "Location" msgstr "Standort" -#: stock/templates/stock/item_base.html:102 +#: stock/templates/stock/item_base.html:132 +msgid "Unique Identifier" +msgstr "" + +#: stock/templates/stock/item_base.html:139 msgid "Serial Number" msgstr "Seriennummer" -#: stock/templates/stock/item_base.html:161 +#: stock/templates/stock/item_base.html:198 msgid "Last Updated" msgstr "Zuletzt aktualisiert" -#: stock/templates/stock/item_base.html:166 +#: stock/templates/stock/item_base.html:203 msgid "Last Stocktake" msgstr "Letzte Inventur" -#: stock/templates/stock/item_base.html:170 +#: stock/templates/stock/item_base.html:207 msgid "No stocktake performed" msgstr "Keine Inventur ausgeführt" @@ -2297,12 +2714,7 @@ msgstr "Objekt-Details" msgid "Stock Locations" msgstr "Lagerobjekt-Standorte" -#: stock/templates/stock/stock_adjust.html:16 #: stock/templates/stock/stock_app_base.html:7 -msgid "Stock Item" -msgstr "Lagerobjekt" - -#: stock/templates/stock/stock_app_base.html:9 #, fuzzy #| msgid "Stock Locations" msgid "Stock Location" @@ -2470,6 +2882,12 @@ msgstr "" msgid "No results found" msgstr "Keine Seriennummern gefunden" +#: templates/InvenTree/starred_parts.html:7 +#, fuzzy +#| msgid "Required Parts" +msgid "Starred Parts" +msgstr "benötigte Teile" + #: templates/about.html:13 msgid "InvenTree Version Information" msgstr "InvenTree-Versionsinformationen" @@ -2485,48 +2903,64 @@ msgid "InvenTree Version" msgstr "InvenTree-Versionsinformationen" #: templates/about.html:30 +#, fuzzy +#| msgid "Version" +msgid "Django Version" +msgstr "Version" + +#: templates/about.html:34 msgid "Commit Hash" msgstr "Commit-Hash" -#: templates/about.html:34 +#: templates/about.html:38 msgid "Commit Date" msgstr "Commit-Datum" -#: templates/about.html:38 +#: templates/about.html:42 msgid "InvenTree Documentation" msgstr "InvenTree-Dokumentation" -#: templates/about.html:43 +#: templates/about.html:47 msgid "View Code on GitHub" msgstr "Code auf GitHub ansehen" -#: templates/about.html:47 +#: templates/about.html:51 msgid "Submit Bug Report" msgstr "" -#: templates/navbar.html:23 +#: templates/navbar.html:14 +msgid "Buy" +msgstr "" + +#: templates/navbar.html:22 +#, fuzzy +#| msgid "Sellable" +msgid "Sell" +msgstr "Verkaufbar" + +#: templates/navbar.html:36 msgid "Admin" msgstr "" -#: templates/navbar.html:26 +#: templates/navbar.html:39 #, fuzzy #| msgid "Settings value" msgid "Settings" msgstr "Einstellungs-Wert" -#: templates/navbar.html:27 +#: templates/navbar.html:40 msgid "Logout" msgstr "" -#: templates/navbar.html:29 +#: templates/navbar.html:42 msgid "Login" msgstr "" -#: templates/navbar.html:32 +#: templates/navbar.html:45 msgid "About InvenTree" msgstr "" -#: templates/navbar.html:33 +#: templates/navbar.html:46 #, fuzzy #| msgid "Status" msgid "Statistics" @@ -2606,54 +3040,74 @@ msgstr "" msgid "Stock status" msgstr "Objekt-Details" -#: templates/table_filters.html:53 +#: templates/table_filters.html:37 +#, fuzzy +#| msgid "Allocated" +msgid "Is allocated" +msgstr "Zugeordnet" + +#: templates/table_filters.html:38 +msgid "Item has been alloacted" +msgstr "" + +#: templates/table_filters.html:58 #, fuzzy #| msgid "Order Parts" msgid "Order status" msgstr "Teile bestellen" -#: templates/table_filters.html:64 +#: templates/table_filters.html:69 #, fuzzy #| msgid "Parts (Including subcategories)" msgid "Include subcategories" msgstr "Teile (inklusive Unter-Kategorien)" -#: templates/table_filters.html:65 +#: templates/table_filters.html:70 #, fuzzy #| msgid "Parts (Including subcategories)" msgid "Include parts in subcategories" msgstr "Teile (inklusive Unter-Kategorien)" -#: templates/table_filters.html:69 +#: templates/table_filters.html:74 msgid "Active" msgstr "" -#: templates/table_filters.html:70 +#: templates/table_filters.html:75 #, fuzzy #| msgid "Build to allocate parts" msgid "Show active parts" msgstr "Bau starten um Teile zuzuweisen" -#: templates/table_filters.html:74 +#: templates/table_filters.html:79 #, fuzzy #| msgid "Parameter Template" msgid "Template" msgstr "Parameter Vorlage" -#: templates/table_filters.html:78 +#: templates/table_filters.html:83 #, fuzzy #| msgid "Available" msgid "Stock available" msgstr "verfügbar" -#: templates/table_filters.html:82 +#: templates/table_filters.html:87 #, fuzzy #| msgid "Stock" msgid "Low stock" msgstr "Lagerbestand" +#: templates/table_filters.html:99 +msgid "Starred" +msgstr "" + +#: templates/table_filters.html:107 +#, fuzzy +#| msgid "Purchaseable" +msgid "Purchasable" +msgstr "Kaufbar" + +#~ msgid "Order Items" +#~ msgstr "Bestellungspositionen" + #~ msgid "URL" #~ msgstr "URL" - -#~ msgid "Version" -#~ msgstr "Version" diff --git a/InvenTree/locale/en/LC_MESSAGES/django.po b/InvenTree/locale/en/LC_MESSAGES/django.po index fcb353a3ca..ee9168f369 100644 --- a/InvenTree/locale/en/LC_MESSAGES/django.po +++ b/InvenTree/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-04-11 15:00+0000\n" +"POT-Creation-Date: 2020-04-22 23:17+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,30 +18,54 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: InvenTree/helpers.py:259 order/models.py:164 order/models.py:215 +#: InvenTree/api.py:61 +msgid "No action specified" +msgstr "" + +#: InvenTree/api.py:75 +msgid "No matching action found" +msgstr "" + +#: InvenTree/api.py:106 +msgid "No barcode data provided" +msgstr "" + +#: InvenTree/api.py:121 +msgid "Barcode successfully decoded" +msgstr "" + +#: InvenTree/api.py:124 +msgid "Barcode plugin returned incorrect response" +msgstr "" + +#: InvenTree/api.py:134 +msgid "Unknown barcode format" +msgstr "" + +#: InvenTree/helpers.py:258 order/models.py:173 order/models.py:224 msgid "Invalid quantity provided" msgstr "" -#: InvenTree/helpers.py:262 +#: InvenTree/helpers.py:261 msgid "Empty serial number string" msgstr "" -#: InvenTree/helpers.py:283 InvenTree/helpers.py:300 +#: InvenTree/helpers.py:282 InvenTree/helpers.py:299 #, python-brace-format msgid "Duplicate serial: {n}" msgstr "" -#: InvenTree/helpers.py:287 InvenTree/helpers.py:290 InvenTree/helpers.py:293 -#: InvenTree/helpers.py:304 +#: InvenTree/helpers.py:286 InvenTree/helpers.py:289 InvenTree/helpers.py:292 +#: InvenTree/helpers.py:303 #, python-brace-format msgid "Invalid group: {g}" msgstr "" -#: InvenTree/helpers.py:310 +#: InvenTree/helpers.py:309 msgid "No serial numbers found" msgstr "" -#: InvenTree/helpers.py:314 +#: InvenTree/helpers.py:313 #, python-brace-format msgid "Number of unique serial number ({s}) must match quantity ({q})" msgstr "" @@ -70,47 +94,51 @@ msgstr "" msgid "Polish" msgstr "" -#: InvenTree/status_codes.py:86 InvenTree/status_codes.py:162 +#: InvenTree/status_codes.py:84 InvenTree/status_codes.py:172 msgid "Pending" msgstr "" -#: InvenTree/status_codes.py:87 +#: InvenTree/status_codes.py:85 msgid "Placed" msgstr "" -#: InvenTree/status_codes.py:88 InvenTree/status_codes.py:165 +#: InvenTree/status_codes.py:86 InvenTree/status_codes.py:175 msgid "Complete" msgstr "" -#: InvenTree/status_codes.py:89 InvenTree/status_codes.py:164 +#: InvenTree/status_codes.py:87 InvenTree/status_codes.py:174 msgid "Cancelled" msgstr "" -#: InvenTree/status_codes.py:90 InvenTree/status_codes.py:130 +#: InvenTree/status_codes.py:88 InvenTree/status_codes.py:135 msgid "Lost" msgstr "" -#: InvenTree/status_codes.py:91 +#: InvenTree/status_codes.py:89 InvenTree/status_codes.py:137 msgid "Returned" msgstr "" -#: InvenTree/status_codes.py:126 +#: InvenTree/status_codes.py:131 msgid "OK" msgstr "" -#: InvenTree/status_codes.py:127 +#: InvenTree/status_codes.py:132 msgid "Attention needed" msgstr "" -#: InvenTree/status_codes.py:128 +#: InvenTree/status_codes.py:133 msgid "Damaged" msgstr "" -#: InvenTree/status_codes.py:129 +#: InvenTree/status_codes.py:134 msgid "Destroyed" msgstr "" -#: InvenTree/status_codes.py:163 build/templates/build/allocate_edit.html:28 +#: InvenTree/status_codes.py:136 +msgid "Shipped" +msgstr "" + +#: InvenTree/status_codes.py:173 build/templates/build/allocate_edit.html:28 #: build/templates/build/allocate_view.html:21 #: part/templates/part/part_base.html:114 part/templates/part/tabs.html:21 msgid "Allocated" @@ -141,7 +169,7 @@ msgstr "" msgid "Overage must be an integer value or a percentage" msgstr "" -#: InvenTree/views.py:549 +#: InvenTree/views.py:536 msgid "Database Statistics" msgstr "" @@ -175,7 +203,7 @@ msgstr "" msgid "Number of parts to build" msgstr "" -#: build/models.py:82 templates/table_filters.html:42 +#: build/models.py:82 templates/table_filters.html:47 msgid "Build status" msgstr "" @@ -183,7 +211,7 @@ msgstr "" msgid "Batch code for this build output" msgstr "" -#: build/models.py:97 stock/models.py:331 +#: build/models.py:97 stock/models.py:336 msgid "Link to external URL" msgstr "" @@ -201,15 +229,27 @@ msgstr "" msgid "Allocated quantity ({n}) must not exceed available quantity ({q})" msgstr "" -#: build/models.py:409 +#: build/models.py:397 order/models.py:448 +msgid "StockItem is over-allocated" +msgstr "" + +#: build/models.py:400 order/models.py:451 +msgid "Allocation quantity must be greater than zero" +msgstr "" + +#: build/models.py:403 +msgid "Quantity must be 1 for serialized stock" +msgstr "" + +#: build/models.py:418 msgid "Build to allocate parts" msgstr "" -#: build/models.py:416 +#: build/models.py:425 msgid "Stock Item to allocate to build" msgstr "" -#: build/models.py:424 +#: build/models.py:433 msgid "Stock quantity to allocate to build" msgstr "" @@ -227,8 +267,7 @@ msgstr "" #: build/templates/build/allocate_edit.html:19 #: build/templates/build/allocate_view.html:17 -#: build/templates/build/detail.html:22 -#: company/templates/company/detail_part.html:65 +#: build/templates/build/detail.html:22 order/models.py:385 #: order/templates/order/order_wizard/select_parts.html:30 #: order/templates/order/purchase_order_detail.html:26 #: part/templates/part/part_app_base.html:7 @@ -254,12 +293,11 @@ msgid "Allocate" msgstr "" #: build/templates/build/allocate_view.html:10 -#: company/templates/company/detail_part.html:18 order/views.py:526 +#: company/templates/company/detail_part.html:18 order/views.py:671 msgid "Order Parts" msgstr "" #: build/templates/build/allocate_view.html:18 -#: company/templates/company/index.html:54 #: company/templates/company/supplier_part_base.html:50 #: company/templates/company/supplier_part_detail.html:27 #: order/templates/order/purchase_order_detail.html:27 @@ -272,45 +310,47 @@ msgstr "" msgid "On Order" msgstr "" -#: build/templates/build/build_base.html:27 part/templates/part/tabs.html:28 -#: stock/templates/stock/item_base.html:122 templates/navbar.html:12 +#: build/templates/build/build_base.html:8 +#: build/templates/build/build_base.html:21 part/templates/part/tabs.html:28 +#: stock/templates/stock/item_base.html:159 templates/navbar.html:12 msgid "Build" msgstr "" -#: build/templates/build/build_base.html:52 build/templates/build/detail.html:9 +#: build/templates/build/build_base.html:48 build/templates/build/detail.html:9 msgid "Build Details" msgstr "" -#: build/templates/build/build_base.html:56 +#: build/templates/build/build_base.html:52 msgid "Build Title" msgstr "" -#: build/templates/build/build_base.html:66 +#: build/templates/build/build_base.html:62 #: build/templates/build/detail.html:27 #: company/templates/company/supplier_part_pricing.html:27 #: order/templates/order/order_wizard/select_parts.html:32 #: order/templates/order/purchase_order_detail.html:30 -#: stock/templates/stock/item_base.html:108 +#: stock/templates/stock/item_base.html:20 +#: stock/templates/stock/item_base.html:26 +#: stock/templates/stock/item_base.html:145 #: stock/templates/stock/stock_adjust.html:18 msgid "Quantity" msgstr "" -#: build/templates/build/build_base.html:71 +#: build/templates/build/build_base.html:67 #: build/templates/build/detail.html:42 -#: order/templates/order/order_base.html:72 -#: stock/templates/stock/item_base.html:175 +#: stock/templates/stock/item_base.html:212 msgid "Status" msgstr "" -#: build/templates/build/build_base.html:76 +#: build/templates/build/build_base.html:72 msgid "BOM Price" msgstr "" -#: build/templates/build/build_base.html:81 +#: build/templates/build/build_base.html:77 msgid "BOM pricing is incomplete" msgstr "" -#: build/templates/build/build_base.html:84 +#: build/templates/build/build_base.html:80 msgid "No pricing information" msgstr "" @@ -335,20 +375,21 @@ msgid "Stock can be taken from any available location." msgstr "" #: build/templates/build/detail.html:48 -#: stock/templates/stock/item_base.html:115 +#: stock/templates/stock/item_base.html:152 msgid "Batch" msgstr "" #: build/templates/build/detail.html:55 -#: company/templates/company/supplier_part_base.html:47 +#: company/templates/company/supplier_part_base.html:57 #: company/templates/company/supplier_part_detail.html:24 #: part/templates/part/detail.html:67 part/templates/part/part_base.html:85 -#: stock/templates/stock/item_base.html:143 +#: stock/templates/stock/item_base.html:180 msgid "External Link" msgstr "" #: build/templates/build/detail.html:61 -#: order/templates/order/order_base.html:84 +#: order/templates/order/order_base.html:93 +#: order/templates/order/sales_order_base.html:82 msgid "Created" msgstr "" @@ -373,14 +414,16 @@ msgid "Build Notes" msgstr "" #: build/templates/build/notes.html:20 company/templates/company/notes.html:17 -#: order/templates/order/order_notes.html:21 part/templates/part/notes.html:20 -#: stock/templates/stock/item_notes.html:21 +#: order/templates/order/order_notes.html:21 +#: order/templates/order/sales_order_notes.html:26 +#: part/templates/part/notes.html:20 stock/templates/stock/item_notes.html:21 msgid "Save" msgstr "" #: build/templates/build/notes.html:33 company/templates/company/notes.html:30 -#: order/templates/order/order_notes.html:32 part/templates/part/notes.html:32 -#: stock/templates/stock/item_notes.html:32 +#: order/templates/order/order_notes.html:32 +#: order/templates/order/sales_order_notes.html:37 +#: part/templates/part/notes.html:32 stock/templates/stock/item_notes.html:32 msgid "Edit notes" msgstr "" @@ -393,9 +436,10 @@ msgstr "" msgid "Outputs" msgstr "" -#: build/templates/build/tabs.html:11 company/models.py:264 -#: company/templates/company/tabs.html:26 order/templates/order/tabs.html:15 -#: part/templates/part/tabs.html:58 stock/templates/stock/tabs.html:17 +#: build/templates/build/tabs.html:11 company/models.py:302 +#: company/templates/company/tabs.html:26 order/templates/order/po_tabs.html:15 +#: order/templates/order/so_tabs.html:15 part/templates/part/tabs.html:63 +#: stock/templates/stock/tabs.html:17 msgid "Notes" msgstr "" @@ -552,130 +596,138 @@ msgstr "" msgid "Delete Currency" msgstr "" -#: company/models.py:76 +#: company/models.py:83 msgid "Company name" msgstr "" -#: company/models.py:78 +#: company/models.py:85 msgid "Description of the company" msgstr "" -#: company/models.py:80 +#: company/models.py:87 msgid "Company website URL" msgstr "" -#: company/models.py:83 +#: company/models.py:90 msgid "Company address" msgstr "" -#: company/models.py:86 +#: company/models.py:93 msgid "Contact phone number" msgstr "" -#: company/models.py:88 +#: company/models.py:95 msgid "Contact email address" msgstr "" -#: company/models.py:91 +#: company/models.py:98 msgid "Point of contact" msgstr "" -#: company/models.py:93 +#: company/models.py:100 msgid "Link to external company information" msgstr "" -#: company/models.py:105 +#: company/models.py:112 msgid "Do you sell items to this company?" msgstr "" -#: company/models.py:107 +#: company/models.py:114 msgid "Do you purchase items from this company?" msgstr "" -#: company/models.py:245 +#: company/models.py:116 +msgid "Does this company manufacture parts?" +msgstr "" + +#: company/models.py:276 msgid "Select part" msgstr "" -#: company/models.py:251 +#: company/models.py:282 msgid "Select supplier" msgstr "" -#: company/models.py:254 +#: company/models.py:285 msgid "Supplier stock keeping unit" msgstr "" -#: company/models.py:256 company/templates/company/detail_part.html:96 -#: company/templates/company/supplier_part_base.html:53 -#: company/templates/company/supplier_part_detail.html:30 -msgid "Manufacturer" +#: company/models.py:292 +msgid "Select manufacturer" msgstr "" -#: company/models.py:258 +#: company/models.py:296 msgid "Manufacturer part number" msgstr "" -#: company/models.py:260 +#: company/models.py:298 msgid "URL for external supplier part link" msgstr "" -#: company/models.py:262 +#: company/models.py:300 msgid "Supplier part description" msgstr "" -#: company/models.py:266 +#: company/models.py:304 msgid "Minimum charge (e.g. stocking fee)" msgstr "" -#: company/models.py:268 +#: company/models.py:306 msgid "Part packaging" msgstr "" -#: company/templates/company/company_base.html:7 order/models.py:131 +#: company/templates/company/company_base.html:7 +#: company/templates/company/company_base.html:22 msgid "Company" msgstr "" -#: company/templates/company/company_base.html:50 -#: company/templates/company/index.html:59 -msgid "Website" -msgstr "" - -#: company/templates/company/company_base.html:57 -msgid "Address" -msgstr "" - -#: company/templates/company/company_base.html:64 -msgid "Phone" -msgstr "" - -#: company/templates/company/company_base.html:71 -msgid "Email" -msgstr "" - -#: company/templates/company/company_base.html:78 -msgid "Contact" -msgstr "" - +#: company/templates/company/company_base.html:42 #: company/templates/company/detail.html:8 msgid "Company Details" msgstr "" +#: company/templates/company/company_base.html:48 +msgid "Website" +msgstr "" + +#: company/templates/company/company_base.html:55 +msgid "Address" +msgstr "" + +#: company/templates/company/company_base.html:62 +msgid "Phone" +msgstr "" + +#: company/templates/company/company_base.html:69 +msgid "Email" +msgstr "" + +#: company/templates/company/company_base.html:76 +msgid "Contact" +msgstr "" + #: company/templates/company/detail.html:16 -#: stock/templates/stock/item_base.html:136 -msgid "Customer" +#: company/templates/company/supplier_part_base.html:73 +#: company/templates/company/supplier_part_detail.html:30 +msgid "Manufacturer" msgstr "" #: company/templates/company/detail.html:21 -#: company/templates/company/index.html:46 -#: company/templates/company/supplier_part_base.html:44 -#: company/templates/company/supplier_part_detail.html:21 -#: order/templates/order/order_base.html:67 +#: company/templates/company/supplier_part_base.html:63 +#: company/templates/company/supplier_part_detail.html:21 order/models.py:138 +#: order/templates/order/order_base.html:74 #: order/templates/order/order_wizard/select_pos.html:30 -#: stock/templates/stock/item_base.html:150 +#: stock/templates/stock/item_base.html:187 msgid "Supplier" msgstr "" +#: company/templates/company/detail.html:26 order/models.py:275 +#: order/templates/order/sales_order_base.html:63 +#: stock/templates/stock/item_base.html:173 +msgid "Customer" +msgstr "" + #: company/templates/company/detail_part.html:8 -#: company/templates/company/tabs.html:9 msgid "Supplier Parts" msgstr "" @@ -692,53 +744,45 @@ msgstr "" msgid "Delete Parts" msgstr "" -#: company/templates/company/detail_part.html:88 -#: company/templates/company/supplier_part_base.html:45 -#: company/templates/company/supplier_part_detail.html:22 -msgid "SKU" +#: company/templates/company/detail_part.html:43 +#: part/templates/part/stock.html:75 +msgid "New Part" msgstr "" -#: company/templates/company/detail_part.html:105 -msgid "Link" +#: company/templates/company/detail_part.html:44 +msgid "Create new Part" msgstr "" -#: company/templates/company/detail_purchase_orders.html:8 -#: company/templates/company/tabs.html:15 part/templates/part/tabs.html:43 -msgid "Purchase Orders" +#: company/templates/company/detail_part.html:49 company/views.py:52 +msgid "New Supplier" msgstr "" -#: company/templates/company/detail_purchase_orders.html:13 -msgid "New Purchase Order" +#: company/templates/company/detail_part.html:50 company/views.py:184 +msgid "Create new Supplier" +msgstr "" + +#: company/templates/company/detail_part.html:55 company/views.py:58 +msgid "New Manufacturer" +msgstr "" + +#: company/templates/company/detail_part.html:56 company/views.py:187 +msgid "Create new Manufacturer" msgstr "" #: company/templates/company/detail_stock.html:9 msgid "Supplier Stock" msgstr "" -#: company/templates/company/detail_stock.html:33 +#: company/templates/company/detail_stock.html:34 #: company/templates/company/supplier_part_stock.html:38 #: part/templates/part/stock.html:53 templates/stock_table.html:5 msgid "Export" msgstr "" #: company/templates/company/index.html:7 -#: company/templates/company/index.html:12 msgid "Supplier List" msgstr "" -#: company/templates/company/index.html:17 -msgid "New Supplier" -msgstr "" - -#: company/templates/company/index.html:41 -msgid "ID" -msgstr "" - -#: company/templates/company/index.html:69 part/templates/part/category.html:83 -#: templates/navbar.html:10 templates/stats.html:8 templates/stats.html:17 -msgid "Parts" -msgstr "" - #: company/templates/company/notes.html:10 #: company/templates/company/notes.html:27 msgid "Company Notes" @@ -748,28 +792,71 @@ msgstr "" msgid "Are you sure you want to delete the following Supplier Parts?" msgstr "" +#: company/templates/company/purchase_orders.html:9 +#: company/templates/company/tabs.html:17 +#: order/templates/order/purchase_orders.html:7 +#: order/templates/order/purchase_orders.html:12 +#: part/templates/part/orders.html:9 part/templates/part/tabs.html:43 +#: templates/navbar.html:18 +msgid "Purchase Orders" +msgstr "" + +#: company/templates/company/purchase_orders.html:14 +#: order/templates/order/purchase_orders.html:17 +msgid "Create new purchase order" +msgstr "" + +#: company/templates/company/purchase_orders.html:14 +#: order/templates/order/purchase_orders.html:17 +msgid "New Purchase Order" +msgstr "" + +#: company/templates/company/sales_orders.html:9 +#: company/templates/company/tabs.html:22 +#: order/templates/order/sales_orders.html:7 +#: order/templates/order/sales_orders.html:12 +#: part/templates/part/sales_orders.html:9 part/templates/part/tabs.html:48 +#: templates/navbar.html:25 +msgid "Sales Orders" +msgstr "" + +#: company/templates/company/sales_orders.html:14 +#: order/templates/order/sales_orders.html:17 +msgid "Create new sales order" +msgstr "" + +#: company/templates/company/sales_orders.html:14 +#: order/templates/order/sales_orders.html:17 +msgid "New Sales Order" +msgstr "" + #: company/templates/company/supplier_part_base.html:6 -#: company/templates/company/supplier_part_base.html:13 -#: stock/templates/stock/item_base.html:155 +#: company/templates/company/supplier_part_base.html:19 +#: stock/templates/stock/item_base.html:192 msgid "Supplier Part" msgstr "" -#: company/templates/company/supplier_part_base.html:34 +#: company/templates/company/supplier_part_base.html:35 #: company/templates/company/supplier_part_detail.html:11 msgid "Supplier Part Details" msgstr "" -#: company/templates/company/supplier_part_base.html:37 +#: company/templates/company/supplier_part_base.html:40 #: company/templates/company/supplier_part_detail.html:14 msgid "Internal Part" msgstr "" -#: company/templates/company/supplier_part_base.html:54 +#: company/templates/company/supplier_part_base.html:67 +#: company/templates/company/supplier_part_detail.html:22 +msgid "SKU" +msgstr "" + +#: company/templates/company/supplier_part_base.html:77 #: company/templates/company/supplier_part_detail.html:31 msgid "MPN" msgstr "" -#: company/templates/company/supplier_part_base.html:57 +#: company/templates/company/supplier_part_base.html:84 #: company/templates/company/supplier_part_detail.html:34 #: order/templates/order/purchase_order_detail.html:34 msgid "Note" @@ -832,154 +919,246 @@ msgid "Stock" msgstr "" #: company/templates/company/supplier_part_tabs.html:11 -#: templates/navbar.html:14 msgid "Orders" msgstr "" -#: company/templates/company/tabs.html:21 -msgid "Sales Orders" +#: company/templates/company/tabs.html:9 part/templates/part/category.html:83 +#: templates/navbar.html:10 templates/stats.html:8 templates/stats.html:17 +msgid "Parts" msgstr "" -#: company/views.py:99 +#: company/views.py:51 part/templates/part/tabs.html:37 +#: templates/navbar.html:16 +msgid "Suppliers" +msgstr "" + +#: company/views.py:57 templates/navbar.html:17 +msgid "Manufacturers" +msgstr "" + +#: company/views.py:63 templates/navbar.html:24 +msgid "Customers" +msgstr "" + +#: company/views.py:64 +msgid "New Customer" +msgstr "" + +#: company/views.py:71 +msgid "Companies" +msgstr "" + +#: company/views.py:72 +msgid "New Company" +msgstr "" + +#: company/views.py:149 msgid "Update Company Image" msgstr "" -#: company/views.py:104 +#: company/views.py:154 msgid "Updated company image" msgstr "" -#: company/views.py:114 +#: company/views.py:164 msgid "Edit Company" msgstr "" -#: company/views.py:118 +#: company/views.py:168 msgid "Edited company information" msgstr "" -#: company/views.py:128 +#: company/views.py:190 +msgid "Create new Customer" +msgstr "" + +#: company/views.py:192 msgid "Create new Company" msgstr "" -#: company/views.py:132 +#: company/views.py:219 msgid "Created new company" msgstr "" -#: company/views.py:142 +#: company/views.py:229 msgid "Delete Company" msgstr "" -#: company/views.py:147 +#: company/views.py:234 msgid "Company was deleted" msgstr "" -#: company/views.py:172 +#: company/views.py:259 msgid "Edit Supplier Part" msgstr "" -#: company/views.py:181 part/templates/part/stock.html:82 +#: company/views.py:268 part/templates/part/stock.html:82 msgid "Create new Supplier Part" msgstr "" -#: company/views.py:238 +#: company/views.py:328 msgid "Delete Supplier Part" msgstr "" -#: company/views.py:308 +#: company/views.py:398 msgid "Add Price Break" msgstr "" -#: company/views.py:350 +#: company/views.py:440 msgid "Edit Price Break" msgstr "" -#: company/views.py:365 +#: company/views.py:455 msgid "Delete Price Break" msgstr "" -#: order/forms.py:22 +#: order/forms.py:24 msgid "Place order" msgstr "" -#: order/forms.py:33 +#: order/forms.py:35 msgid "Mark order as complete" msgstr "" -#: order/forms.py:44 +#: order/forms.py:46 msgid "Cancel order" msgstr "" -#: order/forms.py:55 +#: order/forms.py:57 msgid "Receive parts to this location" msgstr "" -#: order/models.py:68 +#: order/models.py:71 msgid "Order reference" msgstr "" -#: order/models.py:70 +#: order/models.py:73 msgid "Order description" msgstr "" -#: order/models.py:72 +#: order/models.py:75 msgid "Link to external page" msgstr "" -#: order/models.py:89 +#: order/models.py:92 msgid "Order notes" msgstr "" -#: order/models.py:162 order/models.py:213 part/views.py:1119 -#: stock/models.py:467 +#: order/models.py:141 +msgid "Supplier order reference code" +msgstr "" + +#: order/models.py:171 order/models.py:222 part/views.py:1119 +#: stock/models.py:519 msgid "Quantity must be greater than zero" msgstr "" -#: order/models.py:167 +#: order/models.py:176 msgid "Part supplier must match PO supplier" msgstr "" -#: order/models.py:208 +#: order/models.py:217 msgid "Lines can only be received against an order marked as 'Placed'" msgstr "" -#: order/models.py:268 +#: order/models.py:278 +msgid "Customer order reference code" +msgstr "" + +#: order/models.py:324 msgid "Item quantity" msgstr "" -#: order/models.py:270 +#: order/models.py:326 msgid "Line item reference" msgstr "" -#: order/models.py:272 +#: order/models.py:328 msgid "Line item notes" msgstr "" -#: order/models.py:298 stock/templates/stock/item_base.html:129 +#: order/models.py:354 order/templates/order/order_base.html:9 +#: order/templates/order/order_base.html:23 +#: stock/templates/stock/item_base.html:166 msgid "Purchase Order" msgstr "" -#: order/models.py:307 +#: order/models.py:363 msgid "Supplier part" msgstr "" -#: order/models.py:310 +#: order/models.py:366 msgid "Number of items received" msgstr "" -#: order/templates/order/order_base.html:62 +#: order/models.py:383 order/templates/order/sales_order_base.html:9 +#: order/templates/order/sales_order_base.html:31 +#: order/templates/order/sales_order_notes.html:10 +msgid "Sales Order" +msgstr "" + +#: order/models.py:440 +msgid "Cannot allocate stock item to a line with a different part" +msgstr "" + +#: order/models.py:442 +msgid "Cannot allocate stock to a line without a part" +msgstr "" + +#: order/models.py:445 +msgid "Allocation quantity cannot exceed stock quantity" +msgstr "" + +#: order/models.py:454 +msgid "Quantity must be 1 for serialized stock item" +msgstr "" + +#: order/models.py:466 +msgid "Select stock item to allocate" +msgstr "" + +#: order/models.py:469 +msgid "Enter stock allocation quantity" +msgstr "" + +#: order/templates/order/delete_attachment.html:5 +#: part/templates/part/attachment_delete.html:5 +msgid "Are you sure you want to delete this attachment?" +msgstr "" + +#: order/templates/order/order_base.html:59 msgid "Purchase Order Details" msgstr "" -#: order/templates/order/order_base.html:90 +#: order/templates/order/order_base.html:64 +#: order/templates/order/sales_order_base.html:53 +msgid "Order Reference" +msgstr "" + +#: order/templates/order/order_base.html:69 +#: order/templates/order/sales_order_base.html:58 +msgid "Order Status" +msgstr "" + +#: order/templates/order/order_base.html:80 +msgid "Supplier Reference" +msgstr "" + +#: order/templates/order/order_base.html:99 +#: order/templates/order/sales_order_base.html:88 msgid "Issued" msgstr "" -#: order/templates/order/order_base.html:97 +#: order/templates/order/order_base.html:106 #: order/templates/order/purchase_order_detail.html:32 +#: order/templates/order/sales_order_base.html:95 msgid "Received" msgstr "" #: order/templates/order/order_notes.html:13 #: order/templates/order/order_notes.html:29 +#: order/templates/order/sales_order_notes.html:18 +#: order/templates/order/sales_order_notes.html:34 msgid "Order Notes" msgstr "" @@ -1012,7 +1191,7 @@ msgid "Select existing purchase orders, or create new orders." msgstr "" #: order/templates/order/order_wizard/select_pos.html:31 -#: order/templates/order/tabs.html:5 +#: order/templates/order/po_tabs.html:5 order/templates/order/so_tabs.html:5 msgid "Items" msgstr "" @@ -1029,41 +1208,48 @@ msgid "Purchase Order Attachments" msgstr "" #: order/templates/order/po_attachments.html:17 +#: order/templates/order/so_attachments.html:17 #: part/templates/part/attachments.html:14 msgid "Add Attachment" msgstr "" #: order/templates/order/po_attachments.html:24 +#: order/templates/order/so_attachments.html:24 #: part/templates/part/attachments.html:22 msgid "File" msgstr "" #: order/templates/order/po_attachments.html:25 +#: order/templates/order/so_attachments.html:25 #: part/templates/part/attachments.html:23 msgid "Comment" msgstr "" #: order/templates/order/po_attachments.html:36 +#: order/templates/order/so_attachments.html:36 #: part/templates/part/attachments.html:34 part/views.py:119 msgid "Edit attachment" msgstr "" #: order/templates/order/po_attachments.html:39 +#: order/templates/order/so_attachments.html:39 #: part/templates/part/attachments.html:37 msgid "Delete attachment" msgstr "" -#: order/templates/order/po_delete.html:5 -#: part/templates/part/attachment_delete.html:5 -msgid "Are you sure you want to delete this attachment?" +#: order/templates/order/po_tabs.html:8 order/templates/order/so_tabs.html:8 +#: part/templates/part/tabs.html:60 +msgid "Attachments" msgstr "" -#: order/templates/order/purchase_order_detail.html:16 order/views.py:825 +#: order/templates/order/purchase_order_detail.html:16 +#: order/templates/order/sales_order_detail.html:17 order/views.py:970 +#: order/views.py:1084 msgid "Add Line Item" msgstr "" #: order/templates/order/purchase_order_detail.html:20 -msgid "Order Items" +msgid "Purchase Order Items" msgstr "" #: order/templates/order/purchase_order_detail.html:25 @@ -1078,110 +1264,182 @@ msgstr "" msgid "Reference" msgstr "" -#: order/templates/order/tabs.html:8 part/templates/part/tabs.html:55 -msgid "Attachments" +#: order/templates/order/sales_order_base.html:15 +msgid "This SalesOrder has not been fully allocated" msgstr "" -#: order/views.py:80 +#: order/templates/order/sales_order_base.html:40 +msgid "Packing List" +msgstr "" + +#: order/templates/order/sales_order_base.html:48 +msgid "Sales Order Details" +msgstr "" + +#: order/templates/order/sales_order_base.html:69 +msgid "Customer Reference" +msgstr "" + +#: order/templates/order/sales_order_detail.html:14 +msgid "Sales Order Items" +msgstr "" + +#: order/templates/order/sales_order_detail.html:90 +msgid "Edit stock allocation" +msgstr "" + +#: order/templates/order/sales_order_detail.html:91 +msgid "Delete stock allocation" +msgstr "" + +#: order/templates/order/sales_order_detail.html:178 +msgid "Buy parts" +msgstr "" + +#: order/templates/order/sales_order_detail.html:182 +msgid "Build parts" +msgstr "" + +#: order/templates/order/sales_order_detail.html:185 +msgid "Allocate parts" +msgstr "" + +#: order/templates/order/sales_order_detail.html:189 +msgid "Edit line item" +msgstr "" + +#: order/templates/order/sales_order_detail.html:190 +msgid "Delete line item " +msgstr "" + +#: order/templates/order/so_attachments.html:11 +msgid "Sales Order Attachments" +msgstr "" + +#: order/views.py:97 msgid "Add Purchase Order Attachment" msgstr "" -#: order/views.py:85 part/views.py:80 +#: order/views.py:102 order/views.py:142 part/views.py:80 msgid "Added attachment" msgstr "" -#: order/views.py:121 +#: order/views.py:138 +msgid "Add Sales Order Attachment" +msgstr "" + +#: order/views.py:166 order/views.py:187 msgid "Edit Attachment" msgstr "" -#: order/views.py:125 +#: order/views.py:170 order/views.py:191 msgid "Attachment updated" msgstr "" -#: order/views.py:141 +#: order/views.py:206 order/views.py:220 msgid "Delete Attachment" msgstr "" -#: order/views.py:147 +#: order/views.py:212 order/views.py:226 msgid "Deleted attachment" msgstr "" -#: order/views.py:177 +#: order/views.py:277 msgid "Create Purchase Order" msgstr "" -#: order/views.py:207 +#: order/views.py:307 +msgid "Create Sales Order" +msgstr "" + +#: order/views.py:336 msgid "Edit Purchase Order" msgstr "" -#: order/views.py:227 +#: order/views.py:356 +msgid "Edit Sales Order" +msgstr "" + +#: order/views.py:372 msgid "Cancel Order" msgstr "" -#: order/views.py:242 +#: order/views.py:387 msgid "Confirm order cancellation" msgstr "" -#: order/views.py:260 +#: order/views.py:405 msgid "Issue Order" msgstr "" -#: order/views.py:275 +#: order/views.py:420 msgid "Confirm order placement" msgstr "" -#: order/views.py:296 +#: order/views.py:441 msgid "Complete Order" msgstr "" -#: order/views.py:362 +#: order/views.py:507 msgid "Receive Parts" msgstr "" -#: order/views.py:429 +#: order/views.py:574 msgid "Items received" msgstr "" -#: order/views.py:443 +#: order/views.py:588 msgid "No destination set" msgstr "" -#: order/views.py:474 +#: order/views.py:619 msgid "Error converting quantity to number" msgstr "" -#: order/views.py:480 +#: order/views.py:625 msgid "Receive quantity less than zero" msgstr "" -#: order/views.py:486 +#: order/views.py:631 msgid "No lines specified" msgstr "" -#: order/views.py:845 +#: order/views.py:990 msgid "Invalid Purchase Order" msgstr "" -#: order/views.py:853 +#: order/views.py:998 msgid "Supplier must match for Part and Order" msgstr "" -#: order/views.py:858 +#: order/views.py:1003 msgid "Invalid SupplierPart selection" msgstr "" -#: order/views.py:940 +#: order/views.py:1123 order/views.py:1141 msgid "Edit Line Item" msgstr "" -#: order/views.py:956 +#: order/views.py:1157 order/views.py:1169 msgid "Delete Line Item" msgstr "" -#: order/views.py:961 +#: order/views.py:1162 order/views.py:1174 msgid "Deleted line item" msgstr "" +#: order/views.py:1183 +msgid "Allocate Stock to Order" +msgstr "" + +#: order/views.py:1252 +msgid "Edit Allocation Quantity" +msgstr "" + +#: order/views.py:1267 +msgid "Remove allocation" +msgstr "" + #: part/bom.py:140 #, python-brace-format msgid "Unsupported file format: {f}" @@ -1347,63 +1605,63 @@ msgstr "" msgid "Stored BOM checksum" msgstr "" -#: part/models.py:1049 +#: part/models.py:1065 msgid "Parameter template name must be unique" msgstr "" -#: part/models.py:1054 +#: part/models.py:1070 msgid "Parameter Name" msgstr "" -#: part/models.py:1056 +#: part/models.py:1072 msgid "Parameter Units" msgstr "" -#: part/models.py:1082 +#: part/models.py:1098 msgid "Parent Part" msgstr "" -#: part/models.py:1084 +#: part/models.py:1100 msgid "Parameter Template" msgstr "" -#: part/models.py:1086 +#: part/models.py:1102 msgid "Parameter Value" msgstr "" -#: part/models.py:1110 +#: part/models.py:1126 msgid "Select parent part" msgstr "" -#: part/models.py:1119 +#: part/models.py:1135 msgid "Select part to be used in BOM" msgstr "" -#: part/models.py:1126 +#: part/models.py:1142 msgid "BOM quantity for this BOM item" msgstr "" -#: part/models.py:1129 +#: part/models.py:1145 msgid "Estimated build wastage quantity (absolute or percentage)" msgstr "" -#: part/models.py:1132 +#: part/models.py:1148 msgid "BOM item reference" msgstr "" -#: part/models.py:1135 +#: part/models.py:1151 msgid "BOM item notes" msgstr "" -#: part/models.py:1137 +#: part/models.py:1153 msgid "BOM line checksum" msgstr "" -#: part/models.py:1200 +#: part/models.py:1216 msgid "Part cannot be added to its own Bill of Materials" msgstr "" -#: part/models.py:1207 +#: part/models.py:1223 #, python-brace-format msgid "Part '{p1}' is used in BOM for '{p2}' (recursive)" msgstr "" @@ -1505,7 +1763,7 @@ msgstr "" msgid "Part is not a virtual part" msgstr "" -#: part/templates/part/detail.html:132 templates/table_filters.html:86 +#: part/templates/part/detail.html:132 templates/table_filters.html:91 msgid "Assembly" msgstr "" @@ -1517,7 +1775,7 @@ msgstr "" msgid "Part cannot be assembled from other parts" msgstr "" -#: part/templates/part/detail.html:141 templates/table_filters.html:90 +#: part/templates/part/detail.html:141 templates/table_filters.html:95 msgid "Component" msgstr "" @@ -1549,15 +1807,15 @@ msgstr "" msgid "Part can be purchased from external suppliers" msgstr "" -#: part/templates/part/detail.html:169 -msgid "Sellable" +#: part/templates/part/detail.html:168 templates/table_filters.html:103 +msgid "Salable" msgstr "" -#: part/templates/part/detail.html:172 +#: part/templates/part/detail.html:171 msgid "Part can be sold to customers" msgstr "" -#: part/templates/part/detail.html:174 +#: part/templates/part/detail.html:173 msgid "Part cannot be sold to customers" msgstr "" @@ -1565,6 +1823,14 @@ msgstr "" msgid "Part Notes" msgstr "" +#: part/templates/part/orders.html:14 +msgid "Order part" +msgstr "" + +#: part/templates/part/orders.html:14 +msgid "Order Part" +msgstr "" + #: part/templates/part/part_app_base.html:9 msgid "Part Category" msgstr "" @@ -1625,8 +1891,12 @@ msgstr "" msgid "Upload new image" msgstr "" -#: part/templates/part/stock.html:75 -msgid "New Part" +#: part/templates/part/sales_orders.html:14 +msgid "New sales order" +msgstr "" + +#: part/templates/part/sales_orders.html:14 +msgid "New Order" msgstr "" #: part/templates/part/stock.html:76 @@ -1637,7 +1907,7 @@ msgstr "" msgid "No Stock" msgstr "" -#: part/templates/part/stock_count.html:9 +#: part/templates/part/stock_count.html:9 templates/InvenTree/low_stock.html:7 msgid "Low Stock" msgstr "" @@ -1657,11 +1927,7 @@ msgstr "" msgid "Used In" msgstr "" -#: part/templates/part/tabs.html:37 templates/navbar.html:13 -msgid "Suppliers" -msgstr "" - -#: part/templates/part/tabs.html:48 stock/templates/stock/tabs.html:5 +#: part/templates/part/tabs.html:53 stock/templates/stock/tabs.html:5 msgid "Tracking" msgstr "" @@ -1762,74 +2028,90 @@ msgstr "" msgid "Specify quantity" msgstr "" -#: part/views.py:1366 +#: part/views.py:1364 msgid "Export Bill of Materials" msgstr "" -#: part/views.py:1404 +#: part/views.py:1402 msgid "Confirm Part Deletion" msgstr "" -#: part/views.py:1411 +#: part/views.py:1409 msgid "Part was deleted" msgstr "" -#: part/views.py:1420 +#: part/views.py:1418 msgid "Part Pricing" msgstr "" -#: part/views.py:1542 +#: part/views.py:1540 msgid "Create Part Parameter Template" msgstr "" -#: part/views.py:1550 +#: part/views.py:1548 msgid "Edit Part Parameter Template" msgstr "" -#: part/views.py:1557 +#: part/views.py:1555 msgid "Delete Part Parameter Template" msgstr "" -#: part/views.py:1565 +#: part/views.py:1563 msgid "Create Part Parameter" msgstr "" -#: part/views.py:1615 +#: part/views.py:1613 msgid "Edit Part Parameter" msgstr "" -#: part/views.py:1629 +#: part/views.py:1627 msgid "Delete Part Parameter" msgstr "" -#: part/views.py:1645 +#: part/views.py:1643 msgid "Edit Part Category" msgstr "" -#: part/views.py:1680 +#: part/views.py:1678 msgid "Delete Part Category" msgstr "" -#: part/views.py:1686 +#: part/views.py:1684 msgid "Part category was deleted" msgstr "" -#: part/views.py:1694 +#: part/views.py:1692 msgid "Create new part category" msgstr "" -#: part/views.py:1745 +#: part/views.py:1743 msgid "Create BOM item" msgstr "" -#: part/views.py:1811 +#: part/views.py:1809 msgid "Edit BOM item" msgstr "" -#: part/views.py:1859 +#: part/views.py:1857 msgid "Confim BOM item deletion" msgstr "" +#: plugins/barcode/inventree.py:70 +msgid "Part does not exist" +msgstr "" + +#: plugins/barcode/inventree.py:79 +msgid "StockLocation does not exist" +msgstr "" + +#: plugins/barcode/inventree.py:89 +msgid "StockItem does not exist" +msgstr "" + +#: plugins/barcode/inventree.py:92 +msgid "No matching data" +msgstr "" + #: stock/forms.py:93 msgid "Include stock items in sub locations" msgstr "" @@ -1846,121 +2128,121 @@ msgstr "" msgid "Set the destination as the default location for selected parts" msgstr "" -#: stock/models.py:205 +#: stock/models.py:210 #, python-brace-format msgid "" "A stock item with this serial number already exists for template part {part}" msgstr "" -#: stock/models.py:210 +#: stock/models.py:215 msgid "A stock item with this serial number already exists" msgstr "" -#: stock/models.py:229 +#: stock/models.py:234 #, python-brace-format msgid "Part type ('{pf}') must be {pe}" msgstr "" -#: stock/models.py:239 stock/models.py:248 +#: stock/models.py:244 stock/models.py:253 msgid "Quantity must be 1 for item with a serial number" msgstr "" -#: stock/models.py:240 +#: stock/models.py:245 msgid "Serial number cannot be set if quantity greater than 1" msgstr "" -#: stock/models.py:256 +#: stock/models.py:261 msgid "Stock item cannot be created for a template Part" msgstr "" -#: stock/models.py:265 +#: stock/models.py:270 msgid "Item cannot belong to itself" msgstr "" -#: stock/models.py:306 +#: stock/models.py:311 msgid "Base part" msgstr "" -#: stock/models.py:314 +#: stock/models.py:319 msgid "Select a matching supplier part for this stock item" msgstr "" -#: stock/models.py:318 +#: stock/models.py:323 msgid "Where is this stock item located?" msgstr "" -#: stock/models.py:322 +#: stock/models.py:327 msgid "Is this item installed in another item?" msgstr "" -#: stock/models.py:326 +#: stock/models.py:331 msgid "Item assigned to customer?" msgstr "" -#: stock/models.py:329 +#: stock/models.py:334 msgid "Serial number for this item" msgstr "" -#: stock/models.py:334 +#: stock/models.py:339 msgid "Batch code for this stock item" msgstr "" -#: stock/models.py:343 +#: stock/models.py:348 msgid "Build for this stock item" msgstr "" -#: stock/models.py:352 +#: stock/models.py:357 msgid "Purchase order for this stock item" msgstr "" -#: stock/models.py:363 +#: stock/models.py:374 msgid "Delete this Stock Item when stock is depleted" msgstr "" -#: stock/models.py:370 stock/templates/stock/item_notes.html:13 +#: stock/models.py:381 stock/templates/stock/item_notes.html:13 #: stock/templates/stock/item_notes.html:29 msgid "Stock Item Notes" msgstr "" -#: stock/models.py:464 +#: stock/models.py:516 msgid "Quantity must be integer" msgstr "" -#: stock/models.py:470 +#: stock/models.py:522 #, python-brace-format msgid "Quantity must not exceed available stock quantity ({n})" msgstr "" -#: stock/models.py:473 stock/models.py:476 +#: stock/models.py:525 stock/models.py:528 msgid "Serial numbers must be a list of integers" msgstr "" -#: stock/models.py:479 +#: stock/models.py:531 msgid "Quantity does not match serial numbers" msgstr "" -#: stock/models.py:489 +#: stock/models.py:541 msgid "Serial numbers already exist: " msgstr "" -#: stock/models.py:511 +#: stock/models.py:563 msgid "Add serial number" msgstr "" -#: stock/models.py:514 +#: stock/models.py:566 #, python-brace-format msgid "Serialized {n} items" msgstr "" -#: stock/models.py:814 +#: stock/models.py:866 msgid "Tracking entry title" msgstr "" -#: stock/models.py:816 +#: stock/models.py:868 msgid "Entry notes" msgstr "" -#: stock/models.py:818 +#: stock/models.py:870 msgid "Link to external page for further information" msgstr "" @@ -1968,51 +2250,69 @@ msgstr "" msgid "Stock Tracking Information" msgstr "" -#: stock/templates/stock/item_base.html:11 -msgid "Stock Item Details" +#: stock/templates/stock/item_base.html:8 +#: stock/templates/stock/item_base.html:56 +#: stock/templates/stock/stock_adjust.html:16 +msgid "Stock Item" msgstr "" -#: stock/templates/stock/item_base.html:56 +#: stock/templates/stock/item_base.html:20 +msgid "This stock item is allocated to Sales Order" +msgstr "" + +#: stock/templates/stock/item_base.html:26 +msgid "This stock item is allocated to Build" +msgstr "" + +#: stock/templates/stock/item_base.html:32 msgid "" "This stock item is serialized - it has a unique serial number and the " "quantity cannot be adjusted." msgstr "" -#: stock/templates/stock/item_base.html:60 +#: stock/templates/stock/item_base.html:36 msgid "This stock item cannot be deleted as it has child items" msgstr "" -#: stock/templates/stock/item_base.html:64 +#: stock/templates/stock/item_base.html:40 msgid "" "This stock item will be automatically deleted when all stock is depleted." msgstr "" -#: stock/templates/stock/item_base.html:69 +#: stock/templates/stock/item_base.html:45 msgid "This stock item was split from " msgstr "" -#: stock/templates/stock/item_base.html:89 +#: stock/templates/stock/item_base.html:105 +msgid "Stock Item Details" +msgstr "" + +#: stock/templates/stock/item_base.html:119 msgid "Belongs To" msgstr "" -#: stock/templates/stock/item_base.html:95 +#: stock/templates/stock/item_base.html:125 #: stock/templates/stock/stock_adjust.html:17 msgid "Location" msgstr "" -#: stock/templates/stock/item_base.html:102 +#: stock/templates/stock/item_base.html:132 +msgid "Unique Identifier" +msgstr "" + +#: stock/templates/stock/item_base.html:139 msgid "Serial Number" msgstr "" -#: stock/templates/stock/item_base.html:161 +#: stock/templates/stock/item_base.html:198 msgid "Last Updated" msgstr "" -#: stock/templates/stock/item_base.html:166 +#: stock/templates/stock/item_base.html:203 msgid "Last Stocktake" msgstr "" -#: stock/templates/stock/item_base.html:170 +#: stock/templates/stock/item_base.html:207 msgid "No stocktake performed" msgstr "" @@ -2055,12 +2355,7 @@ msgstr "" msgid "Stock Locations" msgstr "" -#: stock/templates/stock/stock_adjust.html:16 #: stock/templates/stock/stock_app_base.html:7 -msgid "Stock Item" -msgstr "" - -#: stock/templates/stock/stock_app_base.html:9 msgid "Stock Location" msgstr "" @@ -2223,6 +2518,10 @@ msgstr "" msgid "No results found" msgstr "" +#: templates/InvenTree/starred_parts.html:7 +msgid "Starred Parts" +msgstr "" + #: templates/about.html:13 msgid "InvenTree Version Information" msgstr "" @@ -2236,46 +2535,58 @@ msgid "InvenTree Version" msgstr "" #: templates/about.html:30 -msgid "Commit Hash" +msgid "Django Version" msgstr "" #: templates/about.html:34 -msgid "Commit Date" +msgid "Commit Hash" msgstr "" #: templates/about.html:38 +msgid "Commit Date" +msgstr "" + +#: templates/about.html:42 msgid "InvenTree Documentation" msgstr "" -#: templates/about.html:43 +#: templates/about.html:47 msgid "View Code on GitHub" msgstr "" -#: templates/about.html:47 +#: templates/about.html:51 msgid "Submit Bug Report" msgstr "" -#: templates/navbar.html:23 +#: templates/navbar.html:14 +msgid "Buy" +msgstr "" + +#: templates/navbar.html:22 +msgid "Sell" +msgstr "" + +#: templates/navbar.html:36 msgid "Admin" msgstr "" -#: templates/navbar.html:26 +#: templates/navbar.html:39 msgid "Settings" msgstr "" -#: templates/navbar.html:27 +#: templates/navbar.html:40 msgid "Logout" msgstr "" -#: templates/navbar.html:29 +#: templates/navbar.html:42 msgid "Login" msgstr "" -#: templates/navbar.html:32 +#: templates/navbar.html:45 msgid "About InvenTree" msgstr "" -#: templates/navbar.html:33 +#: templates/navbar.html:46 msgid "Statistics" msgstr "" @@ -2331,34 +2642,50 @@ msgstr "" msgid "Stock status" msgstr "" -#: templates/table_filters.html:53 +#: templates/table_filters.html:37 +msgid "Is allocated" +msgstr "" + +#: templates/table_filters.html:38 +msgid "Item has been alloacted" +msgstr "" + +#: templates/table_filters.html:58 msgid "Order status" msgstr "" -#: templates/table_filters.html:64 +#: templates/table_filters.html:69 msgid "Include subcategories" msgstr "" -#: templates/table_filters.html:65 +#: templates/table_filters.html:70 msgid "Include parts in subcategories" msgstr "" -#: templates/table_filters.html:69 +#: templates/table_filters.html:74 msgid "Active" msgstr "" -#: templates/table_filters.html:70 +#: templates/table_filters.html:75 msgid "Show active parts" msgstr "" -#: templates/table_filters.html:74 +#: templates/table_filters.html:79 msgid "Template" msgstr "" -#: templates/table_filters.html:78 +#: templates/table_filters.html:83 msgid "Stock available" msgstr "" -#: templates/table_filters.html:82 +#: templates/table_filters.html:87 msgid "Low stock" msgstr "" + +#: templates/table_filters.html:99 +msgid "Starred" +msgstr "" + +#: templates/table_filters.html:107 +msgid "Purchasable" +msgstr "" diff --git a/InvenTree/locale/es/LC_MESSAGES/django.po b/InvenTree/locale/es/LC_MESSAGES/django.po index fcb353a3ca..ee9168f369 100644 --- a/InvenTree/locale/es/LC_MESSAGES/django.po +++ b/InvenTree/locale/es/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-04-11 15:00+0000\n" +"POT-Creation-Date: 2020-04-22 23:17+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,30 +18,54 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: InvenTree/helpers.py:259 order/models.py:164 order/models.py:215 +#: InvenTree/api.py:61 +msgid "No action specified" +msgstr "" + +#: InvenTree/api.py:75 +msgid "No matching action found" +msgstr "" + +#: InvenTree/api.py:106 +msgid "No barcode data provided" +msgstr "" + +#: InvenTree/api.py:121 +msgid "Barcode successfully decoded" +msgstr "" + +#: InvenTree/api.py:124 +msgid "Barcode plugin returned incorrect response" +msgstr "" + +#: InvenTree/api.py:134 +msgid "Unknown barcode format" +msgstr "" + +#: InvenTree/helpers.py:258 order/models.py:173 order/models.py:224 msgid "Invalid quantity provided" msgstr "" -#: InvenTree/helpers.py:262 +#: InvenTree/helpers.py:261 msgid "Empty serial number string" msgstr "" -#: InvenTree/helpers.py:283 InvenTree/helpers.py:300 +#: InvenTree/helpers.py:282 InvenTree/helpers.py:299 #, python-brace-format msgid "Duplicate serial: {n}" msgstr "" -#: InvenTree/helpers.py:287 InvenTree/helpers.py:290 InvenTree/helpers.py:293 -#: InvenTree/helpers.py:304 +#: InvenTree/helpers.py:286 InvenTree/helpers.py:289 InvenTree/helpers.py:292 +#: InvenTree/helpers.py:303 #, python-brace-format msgid "Invalid group: {g}" msgstr "" -#: InvenTree/helpers.py:310 +#: InvenTree/helpers.py:309 msgid "No serial numbers found" msgstr "" -#: InvenTree/helpers.py:314 +#: InvenTree/helpers.py:313 #, python-brace-format msgid "Number of unique serial number ({s}) must match quantity ({q})" msgstr "" @@ -70,47 +94,51 @@ msgstr "" msgid "Polish" msgstr "" -#: InvenTree/status_codes.py:86 InvenTree/status_codes.py:162 +#: InvenTree/status_codes.py:84 InvenTree/status_codes.py:172 msgid "Pending" msgstr "" -#: InvenTree/status_codes.py:87 +#: InvenTree/status_codes.py:85 msgid "Placed" msgstr "" -#: InvenTree/status_codes.py:88 InvenTree/status_codes.py:165 +#: InvenTree/status_codes.py:86 InvenTree/status_codes.py:175 msgid "Complete" msgstr "" -#: InvenTree/status_codes.py:89 InvenTree/status_codes.py:164 +#: InvenTree/status_codes.py:87 InvenTree/status_codes.py:174 msgid "Cancelled" msgstr "" -#: InvenTree/status_codes.py:90 InvenTree/status_codes.py:130 +#: InvenTree/status_codes.py:88 InvenTree/status_codes.py:135 msgid "Lost" msgstr "" -#: InvenTree/status_codes.py:91 +#: InvenTree/status_codes.py:89 InvenTree/status_codes.py:137 msgid "Returned" msgstr "" -#: InvenTree/status_codes.py:126 +#: InvenTree/status_codes.py:131 msgid "OK" msgstr "" -#: InvenTree/status_codes.py:127 +#: InvenTree/status_codes.py:132 msgid "Attention needed" msgstr "" -#: InvenTree/status_codes.py:128 +#: InvenTree/status_codes.py:133 msgid "Damaged" msgstr "" -#: InvenTree/status_codes.py:129 +#: InvenTree/status_codes.py:134 msgid "Destroyed" msgstr "" -#: InvenTree/status_codes.py:163 build/templates/build/allocate_edit.html:28 +#: InvenTree/status_codes.py:136 +msgid "Shipped" +msgstr "" + +#: InvenTree/status_codes.py:173 build/templates/build/allocate_edit.html:28 #: build/templates/build/allocate_view.html:21 #: part/templates/part/part_base.html:114 part/templates/part/tabs.html:21 msgid "Allocated" @@ -141,7 +169,7 @@ msgstr "" msgid "Overage must be an integer value or a percentage" msgstr "" -#: InvenTree/views.py:549 +#: InvenTree/views.py:536 msgid "Database Statistics" msgstr "" @@ -175,7 +203,7 @@ msgstr "" msgid "Number of parts to build" msgstr "" -#: build/models.py:82 templates/table_filters.html:42 +#: build/models.py:82 templates/table_filters.html:47 msgid "Build status" msgstr "" @@ -183,7 +211,7 @@ msgstr "" msgid "Batch code for this build output" msgstr "" -#: build/models.py:97 stock/models.py:331 +#: build/models.py:97 stock/models.py:336 msgid "Link to external URL" msgstr "" @@ -201,15 +229,27 @@ msgstr "" msgid "Allocated quantity ({n}) must not exceed available quantity ({q})" msgstr "" -#: build/models.py:409 +#: build/models.py:397 order/models.py:448 +msgid "StockItem is over-allocated" +msgstr "" + +#: build/models.py:400 order/models.py:451 +msgid "Allocation quantity must be greater than zero" +msgstr "" + +#: build/models.py:403 +msgid "Quantity must be 1 for serialized stock" +msgstr "" + +#: build/models.py:418 msgid "Build to allocate parts" msgstr "" -#: build/models.py:416 +#: build/models.py:425 msgid "Stock Item to allocate to build" msgstr "" -#: build/models.py:424 +#: build/models.py:433 msgid "Stock quantity to allocate to build" msgstr "" @@ -227,8 +267,7 @@ msgstr "" #: build/templates/build/allocate_edit.html:19 #: build/templates/build/allocate_view.html:17 -#: build/templates/build/detail.html:22 -#: company/templates/company/detail_part.html:65 +#: build/templates/build/detail.html:22 order/models.py:385 #: order/templates/order/order_wizard/select_parts.html:30 #: order/templates/order/purchase_order_detail.html:26 #: part/templates/part/part_app_base.html:7 @@ -254,12 +293,11 @@ msgid "Allocate" msgstr "" #: build/templates/build/allocate_view.html:10 -#: company/templates/company/detail_part.html:18 order/views.py:526 +#: company/templates/company/detail_part.html:18 order/views.py:671 msgid "Order Parts" msgstr "" #: build/templates/build/allocate_view.html:18 -#: company/templates/company/index.html:54 #: company/templates/company/supplier_part_base.html:50 #: company/templates/company/supplier_part_detail.html:27 #: order/templates/order/purchase_order_detail.html:27 @@ -272,45 +310,47 @@ msgstr "" msgid "On Order" msgstr "" -#: build/templates/build/build_base.html:27 part/templates/part/tabs.html:28 -#: stock/templates/stock/item_base.html:122 templates/navbar.html:12 +#: build/templates/build/build_base.html:8 +#: build/templates/build/build_base.html:21 part/templates/part/tabs.html:28 +#: stock/templates/stock/item_base.html:159 templates/navbar.html:12 msgid "Build" msgstr "" -#: build/templates/build/build_base.html:52 build/templates/build/detail.html:9 +#: build/templates/build/build_base.html:48 build/templates/build/detail.html:9 msgid "Build Details" msgstr "" -#: build/templates/build/build_base.html:56 +#: build/templates/build/build_base.html:52 msgid "Build Title" msgstr "" -#: build/templates/build/build_base.html:66 +#: build/templates/build/build_base.html:62 #: build/templates/build/detail.html:27 #: company/templates/company/supplier_part_pricing.html:27 #: order/templates/order/order_wizard/select_parts.html:32 #: order/templates/order/purchase_order_detail.html:30 -#: stock/templates/stock/item_base.html:108 +#: stock/templates/stock/item_base.html:20 +#: stock/templates/stock/item_base.html:26 +#: stock/templates/stock/item_base.html:145 #: stock/templates/stock/stock_adjust.html:18 msgid "Quantity" msgstr "" -#: build/templates/build/build_base.html:71 +#: build/templates/build/build_base.html:67 #: build/templates/build/detail.html:42 -#: order/templates/order/order_base.html:72 -#: stock/templates/stock/item_base.html:175 +#: stock/templates/stock/item_base.html:212 msgid "Status" msgstr "" -#: build/templates/build/build_base.html:76 +#: build/templates/build/build_base.html:72 msgid "BOM Price" msgstr "" -#: build/templates/build/build_base.html:81 +#: build/templates/build/build_base.html:77 msgid "BOM pricing is incomplete" msgstr "" -#: build/templates/build/build_base.html:84 +#: build/templates/build/build_base.html:80 msgid "No pricing information" msgstr "" @@ -335,20 +375,21 @@ msgid "Stock can be taken from any available location." msgstr "" #: build/templates/build/detail.html:48 -#: stock/templates/stock/item_base.html:115 +#: stock/templates/stock/item_base.html:152 msgid "Batch" msgstr "" #: build/templates/build/detail.html:55 -#: company/templates/company/supplier_part_base.html:47 +#: company/templates/company/supplier_part_base.html:57 #: company/templates/company/supplier_part_detail.html:24 #: part/templates/part/detail.html:67 part/templates/part/part_base.html:85 -#: stock/templates/stock/item_base.html:143 +#: stock/templates/stock/item_base.html:180 msgid "External Link" msgstr "" #: build/templates/build/detail.html:61 -#: order/templates/order/order_base.html:84 +#: order/templates/order/order_base.html:93 +#: order/templates/order/sales_order_base.html:82 msgid "Created" msgstr "" @@ -373,14 +414,16 @@ msgid "Build Notes" msgstr "" #: build/templates/build/notes.html:20 company/templates/company/notes.html:17 -#: order/templates/order/order_notes.html:21 part/templates/part/notes.html:20 -#: stock/templates/stock/item_notes.html:21 +#: order/templates/order/order_notes.html:21 +#: order/templates/order/sales_order_notes.html:26 +#: part/templates/part/notes.html:20 stock/templates/stock/item_notes.html:21 msgid "Save" msgstr "" #: build/templates/build/notes.html:33 company/templates/company/notes.html:30 -#: order/templates/order/order_notes.html:32 part/templates/part/notes.html:32 -#: stock/templates/stock/item_notes.html:32 +#: order/templates/order/order_notes.html:32 +#: order/templates/order/sales_order_notes.html:37 +#: part/templates/part/notes.html:32 stock/templates/stock/item_notes.html:32 msgid "Edit notes" msgstr "" @@ -393,9 +436,10 @@ msgstr "" msgid "Outputs" msgstr "" -#: build/templates/build/tabs.html:11 company/models.py:264 -#: company/templates/company/tabs.html:26 order/templates/order/tabs.html:15 -#: part/templates/part/tabs.html:58 stock/templates/stock/tabs.html:17 +#: build/templates/build/tabs.html:11 company/models.py:302 +#: company/templates/company/tabs.html:26 order/templates/order/po_tabs.html:15 +#: order/templates/order/so_tabs.html:15 part/templates/part/tabs.html:63 +#: stock/templates/stock/tabs.html:17 msgid "Notes" msgstr "" @@ -552,130 +596,138 @@ msgstr "" msgid "Delete Currency" msgstr "" -#: company/models.py:76 +#: company/models.py:83 msgid "Company name" msgstr "" -#: company/models.py:78 +#: company/models.py:85 msgid "Description of the company" msgstr "" -#: company/models.py:80 +#: company/models.py:87 msgid "Company website URL" msgstr "" -#: company/models.py:83 +#: company/models.py:90 msgid "Company address" msgstr "" -#: company/models.py:86 +#: company/models.py:93 msgid "Contact phone number" msgstr "" -#: company/models.py:88 +#: company/models.py:95 msgid "Contact email address" msgstr "" -#: company/models.py:91 +#: company/models.py:98 msgid "Point of contact" msgstr "" -#: company/models.py:93 +#: company/models.py:100 msgid "Link to external company information" msgstr "" -#: company/models.py:105 +#: company/models.py:112 msgid "Do you sell items to this company?" msgstr "" -#: company/models.py:107 +#: company/models.py:114 msgid "Do you purchase items from this company?" msgstr "" -#: company/models.py:245 +#: company/models.py:116 +msgid "Does this company manufacture parts?" +msgstr "" + +#: company/models.py:276 msgid "Select part" msgstr "" -#: company/models.py:251 +#: company/models.py:282 msgid "Select supplier" msgstr "" -#: company/models.py:254 +#: company/models.py:285 msgid "Supplier stock keeping unit" msgstr "" -#: company/models.py:256 company/templates/company/detail_part.html:96 -#: company/templates/company/supplier_part_base.html:53 -#: company/templates/company/supplier_part_detail.html:30 -msgid "Manufacturer" +#: company/models.py:292 +msgid "Select manufacturer" msgstr "" -#: company/models.py:258 +#: company/models.py:296 msgid "Manufacturer part number" msgstr "" -#: company/models.py:260 +#: company/models.py:298 msgid "URL for external supplier part link" msgstr "" -#: company/models.py:262 +#: company/models.py:300 msgid "Supplier part description" msgstr "" -#: company/models.py:266 +#: company/models.py:304 msgid "Minimum charge (e.g. stocking fee)" msgstr "" -#: company/models.py:268 +#: company/models.py:306 msgid "Part packaging" msgstr "" -#: company/templates/company/company_base.html:7 order/models.py:131 +#: company/templates/company/company_base.html:7 +#: company/templates/company/company_base.html:22 msgid "Company" msgstr "" -#: company/templates/company/company_base.html:50 -#: company/templates/company/index.html:59 -msgid "Website" -msgstr "" - -#: company/templates/company/company_base.html:57 -msgid "Address" -msgstr "" - -#: company/templates/company/company_base.html:64 -msgid "Phone" -msgstr "" - -#: company/templates/company/company_base.html:71 -msgid "Email" -msgstr "" - -#: company/templates/company/company_base.html:78 -msgid "Contact" -msgstr "" - +#: company/templates/company/company_base.html:42 #: company/templates/company/detail.html:8 msgid "Company Details" msgstr "" +#: company/templates/company/company_base.html:48 +msgid "Website" +msgstr "" + +#: company/templates/company/company_base.html:55 +msgid "Address" +msgstr "" + +#: company/templates/company/company_base.html:62 +msgid "Phone" +msgstr "" + +#: company/templates/company/company_base.html:69 +msgid "Email" +msgstr "" + +#: company/templates/company/company_base.html:76 +msgid "Contact" +msgstr "" + #: company/templates/company/detail.html:16 -#: stock/templates/stock/item_base.html:136 -msgid "Customer" +#: company/templates/company/supplier_part_base.html:73 +#: company/templates/company/supplier_part_detail.html:30 +msgid "Manufacturer" msgstr "" #: company/templates/company/detail.html:21 -#: company/templates/company/index.html:46 -#: company/templates/company/supplier_part_base.html:44 -#: company/templates/company/supplier_part_detail.html:21 -#: order/templates/order/order_base.html:67 +#: company/templates/company/supplier_part_base.html:63 +#: company/templates/company/supplier_part_detail.html:21 order/models.py:138 +#: order/templates/order/order_base.html:74 #: order/templates/order/order_wizard/select_pos.html:30 -#: stock/templates/stock/item_base.html:150 +#: stock/templates/stock/item_base.html:187 msgid "Supplier" msgstr "" +#: company/templates/company/detail.html:26 order/models.py:275 +#: order/templates/order/sales_order_base.html:63 +#: stock/templates/stock/item_base.html:173 +msgid "Customer" +msgstr "" + #: company/templates/company/detail_part.html:8 -#: company/templates/company/tabs.html:9 msgid "Supplier Parts" msgstr "" @@ -692,53 +744,45 @@ msgstr "" msgid "Delete Parts" msgstr "" -#: company/templates/company/detail_part.html:88 -#: company/templates/company/supplier_part_base.html:45 -#: company/templates/company/supplier_part_detail.html:22 -msgid "SKU" +#: company/templates/company/detail_part.html:43 +#: part/templates/part/stock.html:75 +msgid "New Part" msgstr "" -#: company/templates/company/detail_part.html:105 -msgid "Link" +#: company/templates/company/detail_part.html:44 +msgid "Create new Part" msgstr "" -#: company/templates/company/detail_purchase_orders.html:8 -#: company/templates/company/tabs.html:15 part/templates/part/tabs.html:43 -msgid "Purchase Orders" +#: company/templates/company/detail_part.html:49 company/views.py:52 +msgid "New Supplier" msgstr "" -#: company/templates/company/detail_purchase_orders.html:13 -msgid "New Purchase Order" +#: company/templates/company/detail_part.html:50 company/views.py:184 +msgid "Create new Supplier" +msgstr "" + +#: company/templates/company/detail_part.html:55 company/views.py:58 +msgid "New Manufacturer" +msgstr "" + +#: company/templates/company/detail_part.html:56 company/views.py:187 +msgid "Create new Manufacturer" msgstr "" #: company/templates/company/detail_stock.html:9 msgid "Supplier Stock" msgstr "" -#: company/templates/company/detail_stock.html:33 +#: company/templates/company/detail_stock.html:34 #: company/templates/company/supplier_part_stock.html:38 #: part/templates/part/stock.html:53 templates/stock_table.html:5 msgid "Export" msgstr "" #: company/templates/company/index.html:7 -#: company/templates/company/index.html:12 msgid "Supplier List" msgstr "" -#: company/templates/company/index.html:17 -msgid "New Supplier" -msgstr "" - -#: company/templates/company/index.html:41 -msgid "ID" -msgstr "" - -#: company/templates/company/index.html:69 part/templates/part/category.html:83 -#: templates/navbar.html:10 templates/stats.html:8 templates/stats.html:17 -msgid "Parts" -msgstr "" - #: company/templates/company/notes.html:10 #: company/templates/company/notes.html:27 msgid "Company Notes" @@ -748,28 +792,71 @@ msgstr "" msgid "Are you sure you want to delete the following Supplier Parts?" msgstr "" +#: company/templates/company/purchase_orders.html:9 +#: company/templates/company/tabs.html:17 +#: order/templates/order/purchase_orders.html:7 +#: order/templates/order/purchase_orders.html:12 +#: part/templates/part/orders.html:9 part/templates/part/tabs.html:43 +#: templates/navbar.html:18 +msgid "Purchase Orders" +msgstr "" + +#: company/templates/company/purchase_orders.html:14 +#: order/templates/order/purchase_orders.html:17 +msgid "Create new purchase order" +msgstr "" + +#: company/templates/company/purchase_orders.html:14 +#: order/templates/order/purchase_orders.html:17 +msgid "New Purchase Order" +msgstr "" + +#: company/templates/company/sales_orders.html:9 +#: company/templates/company/tabs.html:22 +#: order/templates/order/sales_orders.html:7 +#: order/templates/order/sales_orders.html:12 +#: part/templates/part/sales_orders.html:9 part/templates/part/tabs.html:48 +#: templates/navbar.html:25 +msgid "Sales Orders" +msgstr "" + +#: company/templates/company/sales_orders.html:14 +#: order/templates/order/sales_orders.html:17 +msgid "Create new sales order" +msgstr "" + +#: company/templates/company/sales_orders.html:14 +#: order/templates/order/sales_orders.html:17 +msgid "New Sales Order" +msgstr "" + #: company/templates/company/supplier_part_base.html:6 -#: company/templates/company/supplier_part_base.html:13 -#: stock/templates/stock/item_base.html:155 +#: company/templates/company/supplier_part_base.html:19 +#: stock/templates/stock/item_base.html:192 msgid "Supplier Part" msgstr "" -#: company/templates/company/supplier_part_base.html:34 +#: company/templates/company/supplier_part_base.html:35 #: company/templates/company/supplier_part_detail.html:11 msgid "Supplier Part Details" msgstr "" -#: company/templates/company/supplier_part_base.html:37 +#: company/templates/company/supplier_part_base.html:40 #: company/templates/company/supplier_part_detail.html:14 msgid "Internal Part" msgstr "" -#: company/templates/company/supplier_part_base.html:54 +#: company/templates/company/supplier_part_base.html:67 +#: company/templates/company/supplier_part_detail.html:22 +msgid "SKU" +msgstr "" + +#: company/templates/company/supplier_part_base.html:77 #: company/templates/company/supplier_part_detail.html:31 msgid "MPN" msgstr "" -#: company/templates/company/supplier_part_base.html:57 +#: company/templates/company/supplier_part_base.html:84 #: company/templates/company/supplier_part_detail.html:34 #: order/templates/order/purchase_order_detail.html:34 msgid "Note" @@ -832,154 +919,246 @@ msgid "Stock" msgstr "" #: company/templates/company/supplier_part_tabs.html:11 -#: templates/navbar.html:14 msgid "Orders" msgstr "" -#: company/templates/company/tabs.html:21 -msgid "Sales Orders" +#: company/templates/company/tabs.html:9 part/templates/part/category.html:83 +#: templates/navbar.html:10 templates/stats.html:8 templates/stats.html:17 +msgid "Parts" msgstr "" -#: company/views.py:99 +#: company/views.py:51 part/templates/part/tabs.html:37 +#: templates/navbar.html:16 +msgid "Suppliers" +msgstr "" + +#: company/views.py:57 templates/navbar.html:17 +msgid "Manufacturers" +msgstr "" + +#: company/views.py:63 templates/navbar.html:24 +msgid "Customers" +msgstr "" + +#: company/views.py:64 +msgid "New Customer" +msgstr "" + +#: company/views.py:71 +msgid "Companies" +msgstr "" + +#: company/views.py:72 +msgid "New Company" +msgstr "" + +#: company/views.py:149 msgid "Update Company Image" msgstr "" -#: company/views.py:104 +#: company/views.py:154 msgid "Updated company image" msgstr "" -#: company/views.py:114 +#: company/views.py:164 msgid "Edit Company" msgstr "" -#: company/views.py:118 +#: company/views.py:168 msgid "Edited company information" msgstr "" -#: company/views.py:128 +#: company/views.py:190 +msgid "Create new Customer" +msgstr "" + +#: company/views.py:192 msgid "Create new Company" msgstr "" -#: company/views.py:132 +#: company/views.py:219 msgid "Created new company" msgstr "" -#: company/views.py:142 +#: company/views.py:229 msgid "Delete Company" msgstr "" -#: company/views.py:147 +#: company/views.py:234 msgid "Company was deleted" msgstr "" -#: company/views.py:172 +#: company/views.py:259 msgid "Edit Supplier Part" msgstr "" -#: company/views.py:181 part/templates/part/stock.html:82 +#: company/views.py:268 part/templates/part/stock.html:82 msgid "Create new Supplier Part" msgstr "" -#: company/views.py:238 +#: company/views.py:328 msgid "Delete Supplier Part" msgstr "" -#: company/views.py:308 +#: company/views.py:398 msgid "Add Price Break" msgstr "" -#: company/views.py:350 +#: company/views.py:440 msgid "Edit Price Break" msgstr "" -#: company/views.py:365 +#: company/views.py:455 msgid "Delete Price Break" msgstr "" -#: order/forms.py:22 +#: order/forms.py:24 msgid "Place order" msgstr "" -#: order/forms.py:33 +#: order/forms.py:35 msgid "Mark order as complete" msgstr "" -#: order/forms.py:44 +#: order/forms.py:46 msgid "Cancel order" msgstr "" -#: order/forms.py:55 +#: order/forms.py:57 msgid "Receive parts to this location" msgstr "" -#: order/models.py:68 +#: order/models.py:71 msgid "Order reference" msgstr "" -#: order/models.py:70 +#: order/models.py:73 msgid "Order description" msgstr "" -#: order/models.py:72 +#: order/models.py:75 msgid "Link to external page" msgstr "" -#: order/models.py:89 +#: order/models.py:92 msgid "Order notes" msgstr "" -#: order/models.py:162 order/models.py:213 part/views.py:1119 -#: stock/models.py:467 +#: order/models.py:141 +msgid "Supplier order reference code" +msgstr "" + +#: order/models.py:171 order/models.py:222 part/views.py:1119 +#: stock/models.py:519 msgid "Quantity must be greater than zero" msgstr "" -#: order/models.py:167 +#: order/models.py:176 msgid "Part supplier must match PO supplier" msgstr "" -#: order/models.py:208 +#: order/models.py:217 msgid "Lines can only be received against an order marked as 'Placed'" msgstr "" -#: order/models.py:268 +#: order/models.py:278 +msgid "Customer order reference code" +msgstr "" + +#: order/models.py:324 msgid "Item quantity" msgstr "" -#: order/models.py:270 +#: order/models.py:326 msgid "Line item reference" msgstr "" -#: order/models.py:272 +#: order/models.py:328 msgid "Line item notes" msgstr "" -#: order/models.py:298 stock/templates/stock/item_base.html:129 +#: order/models.py:354 order/templates/order/order_base.html:9 +#: order/templates/order/order_base.html:23 +#: stock/templates/stock/item_base.html:166 msgid "Purchase Order" msgstr "" -#: order/models.py:307 +#: order/models.py:363 msgid "Supplier part" msgstr "" -#: order/models.py:310 +#: order/models.py:366 msgid "Number of items received" msgstr "" -#: order/templates/order/order_base.html:62 +#: order/models.py:383 order/templates/order/sales_order_base.html:9 +#: order/templates/order/sales_order_base.html:31 +#: order/templates/order/sales_order_notes.html:10 +msgid "Sales Order" +msgstr "" + +#: order/models.py:440 +msgid "Cannot allocate stock item to a line with a different part" +msgstr "" + +#: order/models.py:442 +msgid "Cannot allocate stock to a line without a part" +msgstr "" + +#: order/models.py:445 +msgid "Allocation quantity cannot exceed stock quantity" +msgstr "" + +#: order/models.py:454 +msgid "Quantity must be 1 for serialized stock item" +msgstr "" + +#: order/models.py:466 +msgid "Select stock item to allocate" +msgstr "" + +#: order/models.py:469 +msgid "Enter stock allocation quantity" +msgstr "" + +#: order/templates/order/delete_attachment.html:5 +#: part/templates/part/attachment_delete.html:5 +msgid "Are you sure you want to delete this attachment?" +msgstr "" + +#: order/templates/order/order_base.html:59 msgid "Purchase Order Details" msgstr "" -#: order/templates/order/order_base.html:90 +#: order/templates/order/order_base.html:64 +#: order/templates/order/sales_order_base.html:53 +msgid "Order Reference" +msgstr "" + +#: order/templates/order/order_base.html:69 +#: order/templates/order/sales_order_base.html:58 +msgid "Order Status" +msgstr "" + +#: order/templates/order/order_base.html:80 +msgid "Supplier Reference" +msgstr "" + +#: order/templates/order/order_base.html:99 +#: order/templates/order/sales_order_base.html:88 msgid "Issued" msgstr "" -#: order/templates/order/order_base.html:97 +#: order/templates/order/order_base.html:106 #: order/templates/order/purchase_order_detail.html:32 +#: order/templates/order/sales_order_base.html:95 msgid "Received" msgstr "" #: order/templates/order/order_notes.html:13 #: order/templates/order/order_notes.html:29 +#: order/templates/order/sales_order_notes.html:18 +#: order/templates/order/sales_order_notes.html:34 msgid "Order Notes" msgstr "" @@ -1012,7 +1191,7 @@ msgid "Select existing purchase orders, or create new orders." msgstr "" #: order/templates/order/order_wizard/select_pos.html:31 -#: order/templates/order/tabs.html:5 +#: order/templates/order/po_tabs.html:5 order/templates/order/so_tabs.html:5 msgid "Items" msgstr "" @@ -1029,41 +1208,48 @@ msgid "Purchase Order Attachments" msgstr "" #: order/templates/order/po_attachments.html:17 +#: order/templates/order/so_attachments.html:17 #: part/templates/part/attachments.html:14 msgid "Add Attachment" msgstr "" #: order/templates/order/po_attachments.html:24 +#: order/templates/order/so_attachments.html:24 #: part/templates/part/attachments.html:22 msgid "File" msgstr "" #: order/templates/order/po_attachments.html:25 +#: order/templates/order/so_attachments.html:25 #: part/templates/part/attachments.html:23 msgid "Comment" msgstr "" #: order/templates/order/po_attachments.html:36 +#: order/templates/order/so_attachments.html:36 #: part/templates/part/attachments.html:34 part/views.py:119 msgid "Edit attachment" msgstr "" #: order/templates/order/po_attachments.html:39 +#: order/templates/order/so_attachments.html:39 #: part/templates/part/attachments.html:37 msgid "Delete attachment" msgstr "" -#: order/templates/order/po_delete.html:5 -#: part/templates/part/attachment_delete.html:5 -msgid "Are you sure you want to delete this attachment?" +#: order/templates/order/po_tabs.html:8 order/templates/order/so_tabs.html:8 +#: part/templates/part/tabs.html:60 +msgid "Attachments" msgstr "" -#: order/templates/order/purchase_order_detail.html:16 order/views.py:825 +#: order/templates/order/purchase_order_detail.html:16 +#: order/templates/order/sales_order_detail.html:17 order/views.py:970 +#: order/views.py:1084 msgid "Add Line Item" msgstr "" #: order/templates/order/purchase_order_detail.html:20 -msgid "Order Items" +msgid "Purchase Order Items" msgstr "" #: order/templates/order/purchase_order_detail.html:25 @@ -1078,110 +1264,182 @@ msgstr "" msgid "Reference" msgstr "" -#: order/templates/order/tabs.html:8 part/templates/part/tabs.html:55 -msgid "Attachments" +#: order/templates/order/sales_order_base.html:15 +msgid "This SalesOrder has not been fully allocated" msgstr "" -#: order/views.py:80 +#: order/templates/order/sales_order_base.html:40 +msgid "Packing List" +msgstr "" + +#: order/templates/order/sales_order_base.html:48 +msgid "Sales Order Details" +msgstr "" + +#: order/templates/order/sales_order_base.html:69 +msgid "Customer Reference" +msgstr "" + +#: order/templates/order/sales_order_detail.html:14 +msgid "Sales Order Items" +msgstr "" + +#: order/templates/order/sales_order_detail.html:90 +msgid "Edit stock allocation" +msgstr "" + +#: order/templates/order/sales_order_detail.html:91 +msgid "Delete stock allocation" +msgstr "" + +#: order/templates/order/sales_order_detail.html:178 +msgid "Buy parts" +msgstr "" + +#: order/templates/order/sales_order_detail.html:182 +msgid "Build parts" +msgstr "" + +#: order/templates/order/sales_order_detail.html:185 +msgid "Allocate parts" +msgstr "" + +#: order/templates/order/sales_order_detail.html:189 +msgid "Edit line item" +msgstr "" + +#: order/templates/order/sales_order_detail.html:190 +msgid "Delete line item " +msgstr "" + +#: order/templates/order/so_attachments.html:11 +msgid "Sales Order Attachments" +msgstr "" + +#: order/views.py:97 msgid "Add Purchase Order Attachment" msgstr "" -#: order/views.py:85 part/views.py:80 +#: order/views.py:102 order/views.py:142 part/views.py:80 msgid "Added attachment" msgstr "" -#: order/views.py:121 +#: order/views.py:138 +msgid "Add Sales Order Attachment" +msgstr "" + +#: order/views.py:166 order/views.py:187 msgid "Edit Attachment" msgstr "" -#: order/views.py:125 +#: order/views.py:170 order/views.py:191 msgid "Attachment updated" msgstr "" -#: order/views.py:141 +#: order/views.py:206 order/views.py:220 msgid "Delete Attachment" msgstr "" -#: order/views.py:147 +#: order/views.py:212 order/views.py:226 msgid "Deleted attachment" msgstr "" -#: order/views.py:177 +#: order/views.py:277 msgid "Create Purchase Order" msgstr "" -#: order/views.py:207 +#: order/views.py:307 +msgid "Create Sales Order" +msgstr "" + +#: order/views.py:336 msgid "Edit Purchase Order" msgstr "" -#: order/views.py:227 +#: order/views.py:356 +msgid "Edit Sales Order" +msgstr "" + +#: order/views.py:372 msgid "Cancel Order" msgstr "" -#: order/views.py:242 +#: order/views.py:387 msgid "Confirm order cancellation" msgstr "" -#: order/views.py:260 +#: order/views.py:405 msgid "Issue Order" msgstr "" -#: order/views.py:275 +#: order/views.py:420 msgid "Confirm order placement" msgstr "" -#: order/views.py:296 +#: order/views.py:441 msgid "Complete Order" msgstr "" -#: order/views.py:362 +#: order/views.py:507 msgid "Receive Parts" msgstr "" -#: order/views.py:429 +#: order/views.py:574 msgid "Items received" msgstr "" -#: order/views.py:443 +#: order/views.py:588 msgid "No destination set" msgstr "" -#: order/views.py:474 +#: order/views.py:619 msgid "Error converting quantity to number" msgstr "" -#: order/views.py:480 +#: order/views.py:625 msgid "Receive quantity less than zero" msgstr "" -#: order/views.py:486 +#: order/views.py:631 msgid "No lines specified" msgstr "" -#: order/views.py:845 +#: order/views.py:990 msgid "Invalid Purchase Order" msgstr "" -#: order/views.py:853 +#: order/views.py:998 msgid "Supplier must match for Part and Order" msgstr "" -#: order/views.py:858 +#: order/views.py:1003 msgid "Invalid SupplierPart selection" msgstr "" -#: order/views.py:940 +#: order/views.py:1123 order/views.py:1141 msgid "Edit Line Item" msgstr "" -#: order/views.py:956 +#: order/views.py:1157 order/views.py:1169 msgid "Delete Line Item" msgstr "" -#: order/views.py:961 +#: order/views.py:1162 order/views.py:1174 msgid "Deleted line item" msgstr "" +#: order/views.py:1183 +msgid "Allocate Stock to Order" +msgstr "" + +#: order/views.py:1252 +msgid "Edit Allocation Quantity" +msgstr "" + +#: order/views.py:1267 +msgid "Remove allocation" +msgstr "" + #: part/bom.py:140 #, python-brace-format msgid "Unsupported file format: {f}" @@ -1347,63 +1605,63 @@ msgstr "" msgid "Stored BOM checksum" msgstr "" -#: part/models.py:1049 +#: part/models.py:1065 msgid "Parameter template name must be unique" msgstr "" -#: part/models.py:1054 +#: part/models.py:1070 msgid "Parameter Name" msgstr "" -#: part/models.py:1056 +#: part/models.py:1072 msgid "Parameter Units" msgstr "" -#: part/models.py:1082 +#: part/models.py:1098 msgid "Parent Part" msgstr "" -#: part/models.py:1084 +#: part/models.py:1100 msgid "Parameter Template" msgstr "" -#: part/models.py:1086 +#: part/models.py:1102 msgid "Parameter Value" msgstr "" -#: part/models.py:1110 +#: part/models.py:1126 msgid "Select parent part" msgstr "" -#: part/models.py:1119 +#: part/models.py:1135 msgid "Select part to be used in BOM" msgstr "" -#: part/models.py:1126 +#: part/models.py:1142 msgid "BOM quantity for this BOM item" msgstr "" -#: part/models.py:1129 +#: part/models.py:1145 msgid "Estimated build wastage quantity (absolute or percentage)" msgstr "" -#: part/models.py:1132 +#: part/models.py:1148 msgid "BOM item reference" msgstr "" -#: part/models.py:1135 +#: part/models.py:1151 msgid "BOM item notes" msgstr "" -#: part/models.py:1137 +#: part/models.py:1153 msgid "BOM line checksum" msgstr "" -#: part/models.py:1200 +#: part/models.py:1216 msgid "Part cannot be added to its own Bill of Materials" msgstr "" -#: part/models.py:1207 +#: part/models.py:1223 #, python-brace-format msgid "Part '{p1}' is used in BOM for '{p2}' (recursive)" msgstr "" @@ -1505,7 +1763,7 @@ msgstr "" msgid "Part is not a virtual part" msgstr "" -#: part/templates/part/detail.html:132 templates/table_filters.html:86 +#: part/templates/part/detail.html:132 templates/table_filters.html:91 msgid "Assembly" msgstr "" @@ -1517,7 +1775,7 @@ msgstr "" msgid "Part cannot be assembled from other parts" msgstr "" -#: part/templates/part/detail.html:141 templates/table_filters.html:90 +#: part/templates/part/detail.html:141 templates/table_filters.html:95 msgid "Component" msgstr "" @@ -1549,15 +1807,15 @@ msgstr "" msgid "Part can be purchased from external suppliers" msgstr "" -#: part/templates/part/detail.html:169 -msgid "Sellable" +#: part/templates/part/detail.html:168 templates/table_filters.html:103 +msgid "Salable" msgstr "" -#: part/templates/part/detail.html:172 +#: part/templates/part/detail.html:171 msgid "Part can be sold to customers" msgstr "" -#: part/templates/part/detail.html:174 +#: part/templates/part/detail.html:173 msgid "Part cannot be sold to customers" msgstr "" @@ -1565,6 +1823,14 @@ msgstr "" msgid "Part Notes" msgstr "" +#: part/templates/part/orders.html:14 +msgid "Order part" +msgstr "" + +#: part/templates/part/orders.html:14 +msgid "Order Part" +msgstr "" + #: part/templates/part/part_app_base.html:9 msgid "Part Category" msgstr "" @@ -1625,8 +1891,12 @@ msgstr "" msgid "Upload new image" msgstr "" -#: part/templates/part/stock.html:75 -msgid "New Part" +#: part/templates/part/sales_orders.html:14 +msgid "New sales order" +msgstr "" + +#: part/templates/part/sales_orders.html:14 +msgid "New Order" msgstr "" #: part/templates/part/stock.html:76 @@ -1637,7 +1907,7 @@ msgstr "" msgid "No Stock" msgstr "" -#: part/templates/part/stock_count.html:9 +#: part/templates/part/stock_count.html:9 templates/InvenTree/low_stock.html:7 msgid "Low Stock" msgstr "" @@ -1657,11 +1927,7 @@ msgstr "" msgid "Used In" msgstr "" -#: part/templates/part/tabs.html:37 templates/navbar.html:13 -msgid "Suppliers" -msgstr "" - -#: part/templates/part/tabs.html:48 stock/templates/stock/tabs.html:5 +#: part/templates/part/tabs.html:53 stock/templates/stock/tabs.html:5 msgid "Tracking" msgstr "" @@ -1762,74 +2028,90 @@ msgstr "" msgid "Specify quantity" msgstr "" -#: part/views.py:1366 +#: part/views.py:1364 msgid "Export Bill of Materials" msgstr "" -#: part/views.py:1404 +#: part/views.py:1402 msgid "Confirm Part Deletion" msgstr "" -#: part/views.py:1411 +#: part/views.py:1409 msgid "Part was deleted" msgstr "" -#: part/views.py:1420 +#: part/views.py:1418 msgid "Part Pricing" msgstr "" -#: part/views.py:1542 +#: part/views.py:1540 msgid "Create Part Parameter Template" msgstr "" -#: part/views.py:1550 +#: part/views.py:1548 msgid "Edit Part Parameter Template" msgstr "" -#: part/views.py:1557 +#: part/views.py:1555 msgid "Delete Part Parameter Template" msgstr "" -#: part/views.py:1565 +#: part/views.py:1563 msgid "Create Part Parameter" msgstr "" -#: part/views.py:1615 +#: part/views.py:1613 msgid "Edit Part Parameter" msgstr "" -#: part/views.py:1629 +#: part/views.py:1627 msgid "Delete Part Parameter" msgstr "" -#: part/views.py:1645 +#: part/views.py:1643 msgid "Edit Part Category" msgstr "" -#: part/views.py:1680 +#: part/views.py:1678 msgid "Delete Part Category" msgstr "" -#: part/views.py:1686 +#: part/views.py:1684 msgid "Part category was deleted" msgstr "" -#: part/views.py:1694 +#: part/views.py:1692 msgid "Create new part category" msgstr "" -#: part/views.py:1745 +#: part/views.py:1743 msgid "Create BOM item" msgstr "" -#: part/views.py:1811 +#: part/views.py:1809 msgid "Edit BOM item" msgstr "" -#: part/views.py:1859 +#: part/views.py:1857 msgid "Confim BOM item deletion" msgstr "" +#: plugins/barcode/inventree.py:70 +msgid "Part does not exist" +msgstr "" + +#: plugins/barcode/inventree.py:79 +msgid "StockLocation does not exist" +msgstr "" + +#: plugins/barcode/inventree.py:89 +msgid "StockItem does not exist" +msgstr "" + +#: plugins/barcode/inventree.py:92 +msgid "No matching data" +msgstr "" + #: stock/forms.py:93 msgid "Include stock items in sub locations" msgstr "" @@ -1846,121 +2128,121 @@ msgstr "" msgid "Set the destination as the default location for selected parts" msgstr "" -#: stock/models.py:205 +#: stock/models.py:210 #, python-brace-format msgid "" "A stock item with this serial number already exists for template part {part}" msgstr "" -#: stock/models.py:210 +#: stock/models.py:215 msgid "A stock item with this serial number already exists" msgstr "" -#: stock/models.py:229 +#: stock/models.py:234 #, python-brace-format msgid "Part type ('{pf}') must be {pe}" msgstr "" -#: stock/models.py:239 stock/models.py:248 +#: stock/models.py:244 stock/models.py:253 msgid "Quantity must be 1 for item with a serial number" msgstr "" -#: stock/models.py:240 +#: stock/models.py:245 msgid "Serial number cannot be set if quantity greater than 1" msgstr "" -#: stock/models.py:256 +#: stock/models.py:261 msgid "Stock item cannot be created for a template Part" msgstr "" -#: stock/models.py:265 +#: stock/models.py:270 msgid "Item cannot belong to itself" msgstr "" -#: stock/models.py:306 +#: stock/models.py:311 msgid "Base part" msgstr "" -#: stock/models.py:314 +#: stock/models.py:319 msgid "Select a matching supplier part for this stock item" msgstr "" -#: stock/models.py:318 +#: stock/models.py:323 msgid "Where is this stock item located?" msgstr "" -#: stock/models.py:322 +#: stock/models.py:327 msgid "Is this item installed in another item?" msgstr "" -#: stock/models.py:326 +#: stock/models.py:331 msgid "Item assigned to customer?" msgstr "" -#: stock/models.py:329 +#: stock/models.py:334 msgid "Serial number for this item" msgstr "" -#: stock/models.py:334 +#: stock/models.py:339 msgid "Batch code for this stock item" msgstr "" -#: stock/models.py:343 +#: stock/models.py:348 msgid "Build for this stock item" msgstr "" -#: stock/models.py:352 +#: stock/models.py:357 msgid "Purchase order for this stock item" msgstr "" -#: stock/models.py:363 +#: stock/models.py:374 msgid "Delete this Stock Item when stock is depleted" msgstr "" -#: stock/models.py:370 stock/templates/stock/item_notes.html:13 +#: stock/models.py:381 stock/templates/stock/item_notes.html:13 #: stock/templates/stock/item_notes.html:29 msgid "Stock Item Notes" msgstr "" -#: stock/models.py:464 +#: stock/models.py:516 msgid "Quantity must be integer" msgstr "" -#: stock/models.py:470 +#: stock/models.py:522 #, python-brace-format msgid "Quantity must not exceed available stock quantity ({n})" msgstr "" -#: stock/models.py:473 stock/models.py:476 +#: stock/models.py:525 stock/models.py:528 msgid "Serial numbers must be a list of integers" msgstr "" -#: stock/models.py:479 +#: stock/models.py:531 msgid "Quantity does not match serial numbers" msgstr "" -#: stock/models.py:489 +#: stock/models.py:541 msgid "Serial numbers already exist: " msgstr "" -#: stock/models.py:511 +#: stock/models.py:563 msgid "Add serial number" msgstr "" -#: stock/models.py:514 +#: stock/models.py:566 #, python-brace-format msgid "Serialized {n} items" msgstr "" -#: stock/models.py:814 +#: stock/models.py:866 msgid "Tracking entry title" msgstr "" -#: stock/models.py:816 +#: stock/models.py:868 msgid "Entry notes" msgstr "" -#: stock/models.py:818 +#: stock/models.py:870 msgid "Link to external page for further information" msgstr "" @@ -1968,51 +2250,69 @@ msgstr "" msgid "Stock Tracking Information" msgstr "" -#: stock/templates/stock/item_base.html:11 -msgid "Stock Item Details" +#: stock/templates/stock/item_base.html:8 +#: stock/templates/stock/item_base.html:56 +#: stock/templates/stock/stock_adjust.html:16 +msgid "Stock Item" msgstr "" -#: stock/templates/stock/item_base.html:56 +#: stock/templates/stock/item_base.html:20 +msgid "This stock item is allocated to Sales Order" +msgstr "" + +#: stock/templates/stock/item_base.html:26 +msgid "This stock item is allocated to Build" +msgstr "" + +#: stock/templates/stock/item_base.html:32 msgid "" "This stock item is serialized - it has a unique serial number and the " "quantity cannot be adjusted." msgstr "" -#: stock/templates/stock/item_base.html:60 +#: stock/templates/stock/item_base.html:36 msgid "This stock item cannot be deleted as it has child items" msgstr "" -#: stock/templates/stock/item_base.html:64 +#: stock/templates/stock/item_base.html:40 msgid "" "This stock item will be automatically deleted when all stock is depleted." msgstr "" -#: stock/templates/stock/item_base.html:69 +#: stock/templates/stock/item_base.html:45 msgid "This stock item was split from " msgstr "" -#: stock/templates/stock/item_base.html:89 +#: stock/templates/stock/item_base.html:105 +msgid "Stock Item Details" +msgstr "" + +#: stock/templates/stock/item_base.html:119 msgid "Belongs To" msgstr "" -#: stock/templates/stock/item_base.html:95 +#: stock/templates/stock/item_base.html:125 #: stock/templates/stock/stock_adjust.html:17 msgid "Location" msgstr "" -#: stock/templates/stock/item_base.html:102 +#: stock/templates/stock/item_base.html:132 +msgid "Unique Identifier" +msgstr "" + +#: stock/templates/stock/item_base.html:139 msgid "Serial Number" msgstr "" -#: stock/templates/stock/item_base.html:161 +#: stock/templates/stock/item_base.html:198 msgid "Last Updated" msgstr "" -#: stock/templates/stock/item_base.html:166 +#: stock/templates/stock/item_base.html:203 msgid "Last Stocktake" msgstr "" -#: stock/templates/stock/item_base.html:170 +#: stock/templates/stock/item_base.html:207 msgid "No stocktake performed" msgstr "" @@ -2055,12 +2355,7 @@ msgstr "" msgid "Stock Locations" msgstr "" -#: stock/templates/stock/stock_adjust.html:16 #: stock/templates/stock/stock_app_base.html:7 -msgid "Stock Item" -msgstr "" - -#: stock/templates/stock/stock_app_base.html:9 msgid "Stock Location" msgstr "" @@ -2223,6 +2518,10 @@ msgstr "" msgid "No results found" msgstr "" +#: templates/InvenTree/starred_parts.html:7 +msgid "Starred Parts" +msgstr "" + #: templates/about.html:13 msgid "InvenTree Version Information" msgstr "" @@ -2236,46 +2535,58 @@ msgid "InvenTree Version" msgstr "" #: templates/about.html:30 -msgid "Commit Hash" +msgid "Django Version" msgstr "" #: templates/about.html:34 -msgid "Commit Date" +msgid "Commit Hash" msgstr "" #: templates/about.html:38 +msgid "Commit Date" +msgstr "" + +#: templates/about.html:42 msgid "InvenTree Documentation" msgstr "" -#: templates/about.html:43 +#: templates/about.html:47 msgid "View Code on GitHub" msgstr "" -#: templates/about.html:47 +#: templates/about.html:51 msgid "Submit Bug Report" msgstr "" -#: templates/navbar.html:23 +#: templates/navbar.html:14 +msgid "Buy" +msgstr "" + +#: templates/navbar.html:22 +msgid "Sell" +msgstr "" + +#: templates/navbar.html:36 msgid "Admin" msgstr "" -#: templates/navbar.html:26 +#: templates/navbar.html:39 msgid "Settings" msgstr "" -#: templates/navbar.html:27 +#: templates/navbar.html:40 msgid "Logout" msgstr "" -#: templates/navbar.html:29 +#: templates/navbar.html:42 msgid "Login" msgstr "" -#: templates/navbar.html:32 +#: templates/navbar.html:45 msgid "About InvenTree" msgstr "" -#: templates/navbar.html:33 +#: templates/navbar.html:46 msgid "Statistics" msgstr "" @@ -2331,34 +2642,50 @@ msgstr "" msgid "Stock status" msgstr "" -#: templates/table_filters.html:53 +#: templates/table_filters.html:37 +msgid "Is allocated" +msgstr "" + +#: templates/table_filters.html:38 +msgid "Item has been alloacted" +msgstr "" + +#: templates/table_filters.html:58 msgid "Order status" msgstr "" -#: templates/table_filters.html:64 +#: templates/table_filters.html:69 msgid "Include subcategories" msgstr "" -#: templates/table_filters.html:65 +#: templates/table_filters.html:70 msgid "Include parts in subcategories" msgstr "" -#: templates/table_filters.html:69 +#: templates/table_filters.html:74 msgid "Active" msgstr "" -#: templates/table_filters.html:70 +#: templates/table_filters.html:75 msgid "Show active parts" msgstr "" -#: templates/table_filters.html:74 +#: templates/table_filters.html:79 msgid "Template" msgstr "" -#: templates/table_filters.html:78 +#: templates/table_filters.html:83 msgid "Stock available" msgstr "" -#: templates/table_filters.html:82 +#: templates/table_filters.html:87 msgid "Low stock" msgstr "" + +#: templates/table_filters.html:99 +msgid "Starred" +msgstr "" + +#: templates/table_filters.html:107 +msgid "Purchasable" +msgstr "" diff --git a/InvenTree/order/admin.py b/InvenTree/order/admin.py index 05598928a7..b7ac71976c 100644 --- a/InvenTree/order/admin.py +++ b/InvenTree/order/admin.py @@ -9,6 +9,8 @@ from import_export.resources import ModelResource from import_export.fields import Field from .models import PurchaseOrder, PurchaseOrderLineItem +from .models import SalesOrder, SalesOrderLineItem +from .models import SalesOrderAllocation class PurchaseOrderAdmin(ImportExportModelAdmin): @@ -22,6 +24,17 @@ class PurchaseOrderAdmin(ImportExportModelAdmin): ) +class SalesOrderAdmin(ImportExportModelAdmin): + + list_display = ( + 'reference', + 'customer', + 'status', + 'description', + 'creation_date', + ) + + class POLineItemResource(ModelResource): """ Class for managing import / export of POLineItem data """ @@ -40,6 +53,16 @@ class POLineItemResource(ModelResource): clean_model_instances = True +class SOLineItemResource(ModelResource): + """ Class for managing import / export of SOLineItem data """ + + class Meta: + model = SalesOrderLineItem + skip_unchanged = True + report_skipped = False + clean_model_instances = True + + class PurchaseOrderLineItemAdmin(ImportExportModelAdmin): resource_class = POLineItemResource @@ -52,5 +75,31 @@ class PurchaseOrderLineItemAdmin(ImportExportModelAdmin): ) +class SalesOrderLineItemAdmin(ImportExportModelAdmin): + + resource_class = SOLineItemResource + + list_display = ( + 'order', + 'part', + 'quantity', + 'reference' + ) + + +class SalesOrderAllocationAdmin(ImportExportModelAdmin): + + list_display = ( + 'line', + 'item', + 'quantity' + ) + + admin.site.register(PurchaseOrder, PurchaseOrderAdmin) admin.site.register(PurchaseOrderLineItem, PurchaseOrderLineItemAdmin) + +admin.site.register(SalesOrder, SalesOrderAdmin) +admin.site.register(SalesOrderLineItem, SalesOrderLineItemAdmin) + +admin.site.register(SalesOrderAllocation, SalesOrderAllocationAdmin) diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 1dd0657930..0f84d6bc32 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -19,9 +19,12 @@ from company.models import SupplierPart from .models import PurchaseOrder, PurchaseOrderLineItem from .serializers import POSerializer, POLineItemSerializer +from .models import SalesOrder, SalesOrderLineItem +from .serializers import SalesOrderSerializer, SOLineItemSerializer + class POList(generics.ListCreateAPIView): - """ API endpoint for accessing a list of Order objects + """ API endpoint for accessing a list of PurchaseOrder objects - GET: Return list of PO objects (with filters) - POST: Create a new PurchaseOrder object @@ -150,7 +153,7 @@ class PODetail(generics.RetrieveUpdateAPIView): class POLineItemList(generics.ListCreateAPIView): - """ API endpoint for accessing a list of PO Line Item objects + """ API endpoint for accessing a list of POLineItem objects - GET: Return a list of PO Line Item objects - POST: Create a new PurchaseOrderLineItem object @@ -159,6 +162,17 @@ class POLineItemList(generics.ListCreateAPIView): queryset = PurchaseOrderLineItem.objects.all() serializer_class = POLineItemSerializer + def get_serializer(self, *args, **kwargs): + + try: + kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', False)) + except AttributeError: + pass + + kwargs['context'] = self.get_serializer_context() + + return self.serializer_class(*args, **kwargs) + permission_classes = [ permissions.IsAuthenticated, ] @@ -184,10 +198,200 @@ class POLineItemDetail(generics.RetrieveUpdateAPIView): ] -po_api_urls = [ - url(r'^order/(?P\d+)/?$', PODetail.as_view(), name='api-po-detail'), - url(r'^order/?$', POList.as_view(), name='api-po-list'), +class SOList(generics.ListCreateAPIView): + """ + API endpoint for accessing a list of SalesOrder objects. - url(r'^line/(?P\d+)/?$', POLineItemDetail.as_view(), name='api-po-line-detail'), - url(r'^line/?$', POLineItemList.as_view(), name='api-po-line-list'), + - GET: Return list of SO objects (with filters) + - POST: Create a new SalesOrder + """ + + queryset = SalesOrder.objects.all() + serializer_class = SalesOrderSerializer + + def get_serializer(self, *args, **kwargs): + + try: + kwargs['customer_detail'] = str2bool(self.request.query_params.get('customer_detail', False)) + except AttributeError: + pass + + # Ensure the context is passed through to the serializer + kwargs['context'] = self.get_serializer_context() + + return self.serializer_class(*args, **kwargs) + + def get_queryset(self, *args, **kwargs): + + queryset = super().get_queryset(*args, **kwargs) + + queryset = queryset.prefetch_related( + 'customer', + 'lines' + ) + + queryset = SalesOrderSerializer.annotate_queryset(queryset) + + return queryset + + def filter_queryset(self, queryset): + """ + Perform custom filtering operations on the SalesOrder queryset. + """ + + queryset = super().filter_queryset(queryset) + + params = self.request.query_params + + status = params.get('status', None) + + if status is not None: + queryset = queryset.filter(status=status) + + # Filter by "Part" + # Only return SalesOrder which have LineItem referencing the part + part = params.get('part', None) + + if part is not None: + try: + part = Part.objects.get(pk=part) + queryset = queryset.filter(id__in=[so.id for so in part.sales_orders()]) + except (Part.DoesNotExist, ValueError): + pass + + return queryset + + permission_classes = [ + permissions.IsAuthenticated + ] + + filter_backends = [ + DjangoFilterBackend, + filters.SearchFilter, + filters.OrderingFilter, + ] + + filter_fields = [ + 'customer', + ] + + ordering_fields = [ + 'creation_date', + 'reference' + ] + + ordering = '-creation_date' + + +class SODetail(generics.RetrieveUpdateAPIView): + """ + API endpoint for detail view of a SalesOrder object. + """ + + queryset = SalesOrder.objects.all() + serializer_class = SalesOrderSerializer + + def get_serializer(self, *args, **kwargs): + + try: + kwargs['customer_detail'] = str2bool(self.request.query_params.get('customer_detail', False)) + except AttributeError: + pass + + kwargs['context'] = self.get_serializer_context() + + return self.serializer_class(*args, **kwargs) + + def get_queryset(self, *args, **kwargs): + + queryset = super().get_queryset(*args, **kwargs) + + queryset = queryset.prefetch_related('customer', 'lines') + + queryset = SalesOrderSerializer.annotate_queryset(queryset) + + return queryset + + permission_classes = [permissions.IsAuthenticated] + + +class SOLineItemList(generics.ListCreateAPIView): + """ + API endpoint for accessing a list of SalesOrderLineItem objects. + """ + + queryset = SalesOrderLineItem.objects.all() + serializer_class = SOLineItemSerializer + + def get_serializer(self, *args, **kwargs): + + try: + kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', False)) + except AttributeError: + pass + + try: + kwargs['order_detail'] = str2bool(self.request.query_params.get('order_detail', False)) + except AttributeError: + pass + + try: + kwargs['allocations'] = str2bool(self.request.query_params.get('allocations', False)) + except AttributeError: + pass + + kwargs['context'] = self.get_serializer_context() + + return self.serializer_class(*args, **kwargs) + + def get_queryset(self, *args, **kwargs): + + queryset = super().get_queryset(*args, **kwargs) + + queryset = queryset.prefetch_related( + 'part', + 'part__stock_items', + 'allocations', + 'allocations__item__location', + 'order', + 'order__stock_items', + ) + + return queryset + + permission_classes = [permissions.IsAuthenticated] + + filter_backends = [DjangoFilterBackend] + + filter_fields = [ + 'order', + 'part', + ] + + +class SOLineItemDetail(generics.RetrieveUpdateAPIView): + """ API endpoint for detail view of a SalesOrderLineItem object """ + + queryset = SalesOrderLineItem.objects.all() + serializer_class = SOLineItemSerializer + + permission_classes = [permissions.IsAuthenticated] + + +order_api_urls = [ + # API endpoints for purchase orders + url(r'^po/(?P\d+)/$', PODetail.as_view(), name='api-po-detail'), + url(r'^po/$', POList.as_view(), name='api-po-list'), + + # API endpoints for purchase order line items + url(r'^po-line/(?P\d+)/$', POLineItemDetail.as_view(), name='api-po-line-detail'), + url(r'^po-line/$', POLineItemList.as_view(), name='api-po-line-list'), + + # API endpoints for sales ordesr + url(r'^so/(?P\d+)/$', SODetail.as_view(), name='api-so-detail'), + url(r'^so/$', SOList.as_view(), name='api-so-list'), + + # API endpoints for sales order line items + url(r'^so-line/(?P\d+)/$', SOLineItemDetail.as_view(), name='api-so-line-detail'), + url(r'^so-line/$', SOLineItemList.as_view(), name='api-so-line-list'), ] diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index 52c761e03e..9991fe6670 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -15,6 +15,8 @@ from InvenTree.fields import RoundingDecimalFormField from stock.models import StockLocation from .models import PurchaseOrder, PurchaseOrderLineItem, PurchaseOrderAttachment +from .models import SalesOrder, SalesOrderLineItem, SalesOrderAttachment +from .models import SalesOrderAllocation class IssuePurchaseOrderForm(HelperForm): @@ -48,8 +50,30 @@ class CancelPurchaseOrderForm(HelperForm): fields = [ 'confirm', ] + + +class CancelSalesOrderForm(HelperForm): + + confirm = forms.BooleanField(required=False, help_text=_('Cancel order')) + + class Meta: + model = SalesOrder + fields = [ + 'confirm', + ] +class ShipSalesOrderForm(HelperForm): + + confirm = forms.BooleanField(required=False, help_text=_('Ship order')) + + class Meta: + model = SalesOrder + fields = [ + 'confirm', + ] + + class ReceivePurchaseOrderForm(HelperForm): location = TreeNodeChoiceField(queryset=StockLocation.objects.all(), required=True, help_text=_('Receive parts to this location')) @@ -75,6 +99,20 @@ class EditPurchaseOrderForm(HelperForm): ] +class EditSalesOrderForm(HelperForm): + """ Form for editing a SalesOrder object """ + + class Meta: + model = SalesOrder + fields = [ + 'reference', + 'customer', + 'customer_reference', + 'description', + 'link' + ] + + class EditPurchaseOrderAttachmentForm(HelperForm): """ Form for editing a PurchaseOrderAttachment object """ @@ -87,6 +125,18 @@ class EditPurchaseOrderAttachmentForm(HelperForm): ] +class EditSalesOrderAttachmentForm(HelperForm): + """ Form for editing a SalesOrderAttachment object """ + + class Meta: + model = SalesOrderAttachment + fields = [ + 'order', + 'attachment', + 'comment' + ] + + class EditPurchaseOrderLineItemForm(HelperForm): """ Form for editing a PurchaseOrderLineItem object """ @@ -101,3 +151,32 @@ class EditPurchaseOrderLineItemForm(HelperForm): 'reference', 'notes', ] + + +class EditSalesOrderLineItemForm(HelperForm): + """ Form for editing a SalesOrderLineItem object """ + + quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5) + + class Meta: + model = SalesOrderLineItem + fields = [ + 'order', + 'part', + 'quantity', + 'reference', + 'notes' + ] + + +class EditSalesOrderAllocationForm(HelperForm): + + quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5) + + class Meta: + model = SalesOrderAllocation + + fields = [ + 'line', + 'item', + 'quantity'] diff --git a/InvenTree/order/migrations/0020_auto_20200420_0940.py b/InvenTree/order/migrations/0020_auto_20200420_0940.py new file mode 100644 index 0000000000..59431353dd --- /dev/null +++ b/InvenTree/order/migrations/0020_auto_20200420_0940.py @@ -0,0 +1,76 @@ +# Generated by Django 3.0.5 on 2020-04-20 09:40 + +import InvenTree.fields +import InvenTree.models +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import markdownx.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('company', '0021_remove_supplierpart_manufacturer_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('order', '0019_purchaseorder_supplier_reference'), + ] + + operations = [ + migrations.CreateModel( + name='SalesOrder', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('reference', models.CharField(help_text='Order reference', max_length=64, unique=True)), + ('description', models.CharField(help_text='Order description', max_length=250)), + ('link', models.URLField(blank=True, help_text='Link to external page')), + ('creation_date', models.DateField(blank=True, null=True)), + ('status', models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Placed'), (30, 'Complete'), (40, 'Cancelled'), (50, 'Lost'), (60, 'Returned')], default=10, help_text='Order status')), + ('issue_date', models.DateField(blank=True, null=True)), + ('complete_date', models.DateField(blank=True, null=True)), + ('notes', markdownx.models.MarkdownxField(blank=True, help_text='Order notes')), + ('customer_reference', models.CharField(blank=True, help_text='Customer order reference code', max_length=64)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('customer', models.ForeignKey(help_text='Customer', limit_choices_to={True, 'is_supplier'}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sales_orders', to='company.Company')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AlterField( + model_name='purchaseorder', + name='supplier', + field=models.ForeignKey(help_text='Supplier', limit_choices_to={'is_supplier': True}, on_delete=django.db.models.deletion.CASCADE, related_name='purchase_orders', to='company.Company'), + ), + migrations.AlterField( + model_name='purchaseorder', + name='supplier_reference', + field=models.CharField(blank=True, help_text='Supplier order reference code', max_length=64), + ), + migrations.CreateModel( + name='SalesOrderLineItem', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, help_text='Item quantity', max_digits=15, validators=[django.core.validators.MinValueValidator(0)])), + ('reference', models.CharField(blank=True, help_text='Line item reference', max_length=100)), + ('notes', models.CharField(blank=True, help_text='Line item notes', max_length=500)), + ('order', models.ForeignKey(help_text='Sales Order', on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='order.SalesOrder')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='SalesOrderAttachment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('attachment', models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment)), + ('comment', models.CharField(help_text='File comment', max_length=100)), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='order.SalesOrder')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/InvenTree/order/migrations/0021_auto_20200420_1010.py b/InvenTree/order/migrations/0021_auto_20200420_1010.py new file mode 100644 index 0000000000..0f8351b660 --- /dev/null +++ b/InvenTree/order/migrations/0021_auto_20200420_1010.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.5 on 2020-04-20 10:10 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('company', '0021_remove_supplierpart_manufacturer_name'), + ('order', '0020_auto_20200420_0940'), + ] + + operations = [ + migrations.AlterField( + model_name='salesorder', + name='customer', + field=models.ForeignKey(help_text='Customer', limit_choices_to={'is_customer': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sales_orders', to='company.Company'), + ), + ] diff --git a/InvenTree/order/migrations/0022_salesorderlineitem_part.py b/InvenTree/order/migrations/0022_salesorderlineitem_part.py new file mode 100644 index 0000000000..1ef32fba1b --- /dev/null +++ b/InvenTree/order/migrations/0022_salesorderlineitem_part.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.5 on 2020-04-20 22:33 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0035_auto_20200406_0045'), + ('order', '0021_auto_20200420_1010'), + ] + + operations = [ + migrations.AddField( + model_name='salesorderlineitem', + name='part', + field=models.ForeignKey(help_text='Part', limit_choices_to={'salable': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sales_orders', to='part.Part'), + ), + ] diff --git a/InvenTree/order/migrations/0023_auto_20200420_2309.py b/InvenTree/order/migrations/0023_auto_20200420_2309.py new file mode 100644 index 0000000000..32d47f593a --- /dev/null +++ b/InvenTree/order/migrations/0023_auto_20200420_2309.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.5 on 2020-04-20 23:09 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0035_auto_20200406_0045'), + ('order', '0022_salesorderlineitem_part'), + ] + + operations = [ + migrations.AlterField( + model_name='salesorderlineitem', + name='part', + field=models.ForeignKey(help_text='Part', limit_choices_to={'salable': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sales_order_line_items', to='part.Part'), + ), + ] diff --git a/InvenTree/order/migrations/0024_salesorderallocation.py b/InvenTree/order/migrations/0024_salesorderallocation.py new file mode 100644 index 0000000000..ca8ed182d9 --- /dev/null +++ b/InvenTree/order/migrations/0024_salesorderallocation.py @@ -0,0 +1,26 @@ +# Generated by Django 3.0.5 on 2020-04-22 02:09 + +import InvenTree.fields +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0030_auto_20200422_0015'), + ('order', '0023_auto_20200420_2309'), + ] + + operations = [ + migrations.CreateModel( + name='SalesOrderAllocation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, max_digits=15, validators=[django.core.validators.MinValueValidator(0)])), + ('item', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='sales_order_allocation', to='stock.StockItem')), + ('line', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allocations', to='order.SalesOrderLineItem')), + ], + ), + ] diff --git a/InvenTree/order/migrations/0025_auto_20200422_0222.py b/InvenTree/order/migrations/0025_auto_20200422_0222.py new file mode 100644 index 0000000000..34d0114ac9 --- /dev/null +++ b/InvenTree/order/migrations/0025_auto_20200422_0222.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.5 on 2020-04-22 02:22 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0031_auto_20200422_0209'), + ('order', '0024_salesorderallocation'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='salesorderallocation', + unique_together={('line', 'item')}, + ), + ] diff --git a/InvenTree/order/migrations/0026_auto_20200422_0224.py b/InvenTree/order/migrations/0026_auto_20200422_0224.py new file mode 100644 index 0000000000..c92280898e --- /dev/null +++ b/InvenTree/order/migrations/0026_auto_20200422_0224.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.5 on 2020-04-22 02:24 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0031_auto_20200422_0209'), + ('order', '0025_auto_20200422_0222'), + ] + + operations = [ + migrations.AlterField( + model_name='salesorderallocation', + name='item', + field=models.OneToOneField(limit_choices_to={'part__salable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='sales_order_allocation', to='stock.StockItem'), + ), + ] diff --git a/InvenTree/order/migrations/0027_auto_20200422_0236.py b/InvenTree/order/migrations/0027_auto_20200422_0236.py new file mode 100644 index 0000000000..a4af5aedd3 --- /dev/null +++ b/InvenTree/order/migrations/0027_auto_20200422_0236.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.5 on 2020-04-22 02:36 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0031_auto_20200422_0209'), + ('order', '0026_auto_20200422_0224'), + ] + + operations = [ + migrations.AlterField( + model_name='salesorderallocation', + name='item', + field=models.ForeignKey(limit_choices_to={'part__salable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='sales_order_allocations', to='stock.StockItem'), + ), + ] diff --git a/InvenTree/order/migrations/0028_auto_20200423_0956.py b/InvenTree/order/migrations/0028_auto_20200423_0956.py new file mode 100644 index 0000000000..cf9cd1e0e2 --- /dev/null +++ b/InvenTree/order/migrations/0028_auto_20200423_0956.py @@ -0,0 +1,37 @@ +# Generated by Django 3.0.5 on 2020-04-23 09:56 + +import InvenTree.fields +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0031_auto_20200422_0209'), + ('order', '0027_auto_20200422_0236'), + ] + + operations = [ + migrations.AlterField( + model_name='purchaseorder', + name='status', + field=models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Placed'), (30, 'Complete'), (40, 'Cancelled'), (50, 'Lost'), (60, 'Returned')], default=10, help_text='Purchase order status'), + ), + migrations.AlterField( + model_name='salesorder', + name='status', + field=models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Shipped'), (40, 'Cancelled'), (50, 'Lost'), (60, 'Returned')], default=10, help_text='Purchase order status'), + ), + migrations.AlterField( + model_name='salesorderallocation', + name='item', + field=models.ForeignKey(help_text='Select stock item to allocate', limit_choices_to={'part__salable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='sales_order_allocations', to='stock.StockItem'), + ), + migrations.AlterField( + model_name='salesorderallocation', + name='quantity', + field=InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, help_text='Enter stock allocation quantity', max_digits=15, validators=[django.core.validators.MinValueValidator(0)]), + ), + ] diff --git a/InvenTree/order/migrations/0029_auto_20200423_1042.py b/InvenTree/order/migrations/0029_auto_20200423_1042.py new file mode 100644 index 0000000000..2f7e0072ca --- /dev/null +++ b/InvenTree/order/migrations/0029_auto_20200423_1042.py @@ -0,0 +1,30 @@ +# Generated by Django 3.0.5 on 2020-04-23 10:42 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('order', '0028_auto_20200423_0956'), + ] + + operations = [ + migrations.RenameField( + model_name='salesorder', + old_name='complete_date', + new_name='shipment_date', + ), + migrations.RemoveField( + model_name='salesorder', + name='issue_date', + ), + migrations.AddField( + model_name='salesorder', + name='shipped_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/InvenTree/order/migrations/0030_auto_20200426_0551.py b/InvenTree/order/migrations/0030_auto_20200426_0551.py new file mode 100644 index 0000000000..7236088be3 --- /dev/null +++ b/InvenTree/order/migrations/0030_auto_20200426_0551.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.5 on 2020-04-26 05:51 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0033_auto_20200426_0539'), + ('order', '0029_auto_20200423_1042'), + ] + + operations = [ + migrations.AlterField( + model_name='salesorderallocation', + name='item', + field=models.ForeignKey(help_text='Select stock item to allocate', limit_choices_to={'belongs_to': None, 'build_order': None, 'customer': None, 'part__salable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='sales_order_allocations', to='stock.StockItem'), + ), + ] diff --git a/InvenTree/order/migrations/0031_auto_20200426_0612.py b/InvenTree/order/migrations/0031_auto_20200426_0612.py new file mode 100644 index 0000000000..aa1cd055ec --- /dev/null +++ b/InvenTree/order/migrations/0031_auto_20200426_0612.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.5 on 2020-04-26 06:12 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0034_auto_20200426_0602'), + ('order', '0030_auto_20200426_0551'), + ] + + operations = [ + migrations.AlterField( + model_name='salesorderallocation', + name='item', + field=models.ForeignKey(help_text='Select stock item to allocate', limit_choices_to={'belongs_to': None, 'build_order': None, 'part__salable': True, 'sales_order': None}, on_delete=django.db.models.deletion.CASCADE, related_name='sales_order_allocations', to='stock.StockItem'), + ), + ] diff --git a/InvenTree/order/migrations/0032_auto_20200427_0044.py b/InvenTree/order/migrations/0032_auto_20200427_0044.py new file mode 100644 index 0000000000..4648ca911b --- /dev/null +++ b/InvenTree/order/migrations/0032_auto_20200427_0044.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.5 on 2020-04-27 00:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0035_auto_20200406_0045'), + ('order', '0031_auto_20200426_0612'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='salesorderlineitem', + unique_together={('order', 'part')}, + ), + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 3a7d65abac..6198eb16bc 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -5,7 +5,8 @@ Order model definitions # -*- coding: utf-8 -*- from django.db import models, transaction -from django.db.models import F +from django.db.models import F, Sum +from django.db.models.functions import Coalesce from django.core.validators import MinValueValidator from django.core.exceptions import ValidationError from django.contrib.auth.models import User @@ -16,13 +17,15 @@ from markdownx.models import MarkdownxField import os from datetime import datetime +from decimal import Decimal -from stock.models import StockItem +from part import models as PartModels +from stock import models as stock_models from company.models import Company, SupplierPart from InvenTree.fields import RoundingDecimalField from InvenTree.helpers import decimal2string -from InvenTree.status_codes import OrderStatus +from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus from InvenTree.models import InvenTreeAttachment @@ -73,65 +76,42 @@ class Order(models.Model): creation_date = models.DateField(blank=True, null=True) - status = models.PositiveIntegerField(default=OrderStatus.PENDING, choices=OrderStatus.items(), - help_text='Order status') - created_by = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, related_name='+' ) - issue_date = models.DateField(blank=True, null=True) - - complete_date = models.DateField(blank=True, null=True) - notes = MarkdownxField(blank=True, help_text=_('Order notes')) - def place_order(self): - """ Marks the order as PLACED. Order must be currently PENDING. """ - - if self.status == OrderStatus.PENDING: - self.status = OrderStatus.PLACED - self.issue_date = datetime.now().date() - self.save() - - def complete_order(self): - """ Marks the order as COMPLETE. Order must be currently PLACED. """ - - if self.status == OrderStatus.PLACED: - self.status = OrderStatus.COMPLETE - self.complete_date = datetime.now().date() - self.save() - - def cancel_order(self): - """ Marks the order as CANCELLED. """ - - if self.status in [OrderStatus.PLACED, OrderStatus.PENDING]: - self.status = OrderStatus.CANCELLED - self.save() - class PurchaseOrder(Order): """ A PurchaseOrder represents goods shipped inwards from an external supplier. Attributes: supplier: Reference to the company supplying the goods in the order + supplier_reference: Optional field for supplier order reference code received_by: User that received the goods """ ORDER_PREFIX = "PO" + def __str__(self): + return "PO {ref} - {company}".format(ref=self.reference, company=self.supplier.name) + + status = models.PositiveIntegerField(default=PurchaseOrderStatus.PENDING, choices=PurchaseOrderStatus.items(), + help_text='Purchase order status') + supplier = models.ForeignKey( Company, on_delete=models.CASCADE, limit_choices_to={ 'is_supplier': True, }, related_name='purchase_orders', - help_text=_('Company') + help_text=_('Supplier') ) - supplier_reference = models.CharField(max_length=64, blank=True, help_text=_("Supplier order reference")) + supplier_reference = models.CharField(max_length=64, blank=True, help_text=_("Supplier order reference code")) received_by = models.ForeignKey( User, @@ -140,6 +120,10 @@ class PurchaseOrder(Order): related_name='+' ) + issue_date = models.DateField(blank=True, null=True) + + complete_date = models.DateField(blank=True, null=True) + def get_absolute_url(self): return reverse('po-detail', kwargs={'pk': self.id}) @@ -188,6 +172,29 @@ class PurchaseOrder(Order): line.save() + def place_order(self): + """ Marks the PurchaseOrder as PLACED. Order must be currently PENDING. """ + + if self.status == PurchaseOrderStatus.PENDING: + self.status = PurchaseOrderStatus.PLACED + self.issue_date = datetime.now().date() + self.save() + + def complete_order(self): + """ Marks the PurchaseOrder as COMPLETE. Order must be currently PLACED. """ + + if self.status == PurchaseOrderStatus.PLACED: + self.status = PurchaseOrderStatus.COMPLETE + self.complete_date = datetime.now().date() + self.save() + + def cancel_order(self): + """ Marks the PurchaseOrder as CANCELLED. """ + + if self.status in [PurchaseOrderStatus.PLACED, PurchaseOrderStatus.PENDING]: + self.status = PurchaseOrderStatus.CANCELLED + self.save() + def pending_line_items(self): """ Return a list of pending line items for this order. Any line item where 'received' < 'quantity' will be returned. @@ -206,7 +213,7 @@ class PurchaseOrder(Order): """ Receive a line item (or partial line item) against this PO """ - if not self.status == OrderStatus.PLACED: + if not self.status == PurchaseOrderStatus.PLACED: raise ValidationError({"status": _("Lines can only be received against an order marked as 'Placed'")}) try: @@ -218,7 +225,7 @@ class PurchaseOrder(Order): # Create a new stock item if line.part: - stock = StockItem( + stock = stock_models.StockItem( part=line.part.part, supplier_part=line.part, location=location, @@ -244,6 +251,115 @@ class PurchaseOrder(Order): self.complete_order() # This will save the model +class SalesOrder(Order): + """ + A SalesOrder represents a list of goods shipped outwards to a customer. + + Attributes: + customer: Reference to the company receiving the goods in the order + customer_reference: Optional field for customer order reference code + """ + + def __str__(self): + return "SO {ref} - {company}".format(ref=self.reference, company=self.customer.name) + + def get_absolute_url(self): + return reverse('so-detail', kwargs={'pk': self.id}) + + customer = models.ForeignKey( + Company, + on_delete=models.SET_NULL, + null=True, + limit_choices_to={'is_customer': True}, + related_name='sales_orders', + help_text=_("Customer"), + ) + + status = models.PositiveIntegerField(default=SalesOrderStatus.PENDING, choices=SalesOrderStatus.items(), + help_text='Purchase order status') + + customer_reference = models.CharField(max_length=64, blank=True, help_text=_("Customer order reference code")) + + shipment_date = models.DateField(blank=True, null=True) + + shipped_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + blank=True, null=True, + related_name='+' + ) + + @property + def is_pending(self): + return self.status == SalesOrderStatus.PENDING + + def is_fully_allocated(self): + """ Return True if all line items are fully allocated """ + + for line in self.lines.all(): + if not line.is_fully_allocated(): + return False + + return True + + def is_over_allocated(self): + """ Return true if any lines in the order are over-allocated """ + + for line in self.lines.all(): + if line.is_over_allocated(): + return True + + return False + + @transaction.atomic + def ship_order(self, user): + """ Mark this order as 'shipped' """ + + # The order can only be 'shipped' if the current status is PENDING + if not self.status == SalesOrderStatus.PENDING: + raise ValidationError({'status': _("SalesOrder cannot be shipped as it is not currently pending")}) + + # Complete the allocation for each allocated StockItem + for line in self.lines.all(): + for allocation in line.allocations.all(): + allocation.complete_allocation(user) + + # Remove the allocation from the database once it has been 'fulfilled' + if allocation.item.sales_order == self: + allocation.delete() + else: + raise ValidationError("Could not complete order - allocation item not fulfilled") + + # Ensure the order status is marked as "Shipped" + self.status = SalesOrderStatus.SHIPPED + self.shipment_date = datetime.now().date() + self.shipped_by = user + self.save() + + return True + + @transaction.atomic + def cancel_order(self): + """ + Cancel this order (only if it is "pending") + + - Mark the order as 'cancelled' + - Delete any StockItems which have been allocated + """ + + if not self.status == SalesOrderStatus.PENDING: + return False + + self.status = SalesOrderStatus.CANCELLED + self.save() + + for line in self.lines.all(): + for allocation in line.allocations.all(): + allocation.delete() + + return True + + class PurchaseOrderAttachment(InvenTreeAttachment): """ Model for storing file attachments against a PurchaseOrder object @@ -255,6 +371,17 @@ class PurchaseOrderAttachment(InvenTreeAttachment): order = models.ForeignKey(PurchaseOrder, on_delete=models.CASCADE, related_name="attachments") +class SalesOrderAttachment(InvenTreeAttachment): + """ + Model for storing file attachments against a SalesOrder object + """ + + def getSubdir(self): + return os.path.join("so_files", str(self.order.id)) + + order = models.ForeignKey(SalesOrder, on_delete=models.CASCADE, related_name='attachments') + + class OrderLineItem(models.Model): """ Abstract model for an order line item @@ -300,6 +427,10 @@ class PurchaseOrderLineItem(OrderLineItem): help_text=_('Purchase Order') ) + def get_base_part(self): + """ Return the base-part for the line item """ + return self.part.part + # TODO - Function callback for when the SupplierPart is deleted? part = models.ForeignKey( @@ -315,3 +446,171 @@ class PurchaseOrderLineItem(OrderLineItem): """ Calculate the number of items remaining to be received """ r = self.quantity - self.received return max(r, 0) + + +class SalesOrderLineItem(OrderLineItem): + """ + Model for a single LineItem in a SalesOrder + + Attributes: + order: Link to the SalesOrder that this line item belongs to + part: Link to a Part object (may be null) + """ + + order = models.ForeignKey(SalesOrder, on_delete=models.CASCADE, related_name='lines', help_text=_('Sales Order')) + + part = models.ForeignKey('part.Part', on_delete=models.SET_NULL, related_name='sales_order_line_items', null=True, help_text=_('Part'), limit_choices_to={'salable': True}) + + class Meta: + unique_together = [ + ('order', 'part'), + ] + + def fulfilled_quantity(self): + """ + Return the total stock quantity fulfilled against this line item. + """ + + query = self.order.stock_items.filter(part=self.part).aggregate(fulfilled=Coalesce(Sum('quantity'), Decimal(0))) + + return query['fulfilled'] + + def allocated_quantity(self): + """ Return the total stock quantity allocated to this LineItem. + + This is a summation of the quantity of each attached StockItem + """ + + query = self.allocations.aggregate(allocated=Coalesce(Sum('quantity'), Decimal(0))) + + return query['allocated'] + + def is_fully_allocated(self): + """ Return True if this line item is fully allocated """ + + if self.order.status == SalesOrderStatus.SHIPPED: + return self.fulfilled_quantity() >= self.quantity + + return self.allocated_quantity() >= self.quantity + + def is_over_allocated(self): + """ Return True if this line item is over allocated """ + return self.allocated_quantity() > self.quantity + + +class SalesOrderAllocation(models.Model): + """ + This model is used to 'allocate' stock items to a SalesOrder. + Items that are "allocated" to a SalesOrder are not yet "attached" to the order, + but they will be once the order is fulfilled. + + Attributes: + line: SalesOrderLineItem reference + item: StockItem reference + quantity: Quantity to take from the StockItem + + """ + + class Meta: + unique_together = [ + # Cannot allocate any given StockItem to the same line more than once + ('line', 'item'), + ] + + def clean(self): + """ + Validate the SalesOrderAllocation object: + + - Cannot allocate stock to a line item without a part reference + - The referenced part must match the part associated with the line item + - Allocated quantity cannot exceed the quantity of the stock item + - Allocation quantity must be "1" if the StockItem is serialized + - Allocation quantity cannot be zero + """ + + super().clean() + + errors = {} + + try: + if not self.line.part == self.item.part: + errors['item'] = _('Cannot allocate stock item to a line with a different part') + except PartModels.Part.DoesNotExist: + errors['line'] = _('Cannot allocate stock to a line without a part') + + if self.quantity > self.item.quantity: + errors['quantity'] = _('Allocation quantity cannot exceed stock quantity') + + # TODO: The logic here needs improving. Do we need to subtract our own amount, or something? + if self.item.quantity - self.item.allocation_count() + self.quantity < self.quantity: + errors['quantity'] = _('StockItem is over-allocated') + + if self.quantity <= 0: + errors['quantity'] = _('Allocation quantity must be greater than zero') + + if self.item.serial and not self.quantity == 1: + errors['quantity'] = _('Quantity must be 1 for serialized stock item') + + if len(errors) > 0: + raise ValidationError(errors) + + line = models.ForeignKey(SalesOrderLineItem, on_delete=models.CASCADE, related_name='allocations') + + item = models.ForeignKey( + 'stock.StockItem', + on_delete=models.CASCADE, + related_name='sales_order_allocations', + limit_choices_to={ + 'part__salable': True, + 'belongs_to': None, + 'sales_order': None, + 'build_order': None, + }, + help_text=_('Select stock item to allocate') + ) + + quantity = RoundingDecimalField(max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], default=1, help_text=_('Enter stock allocation quantity')) + + def get_serial(self): + return self.item.serial + + def get_location(self): + return self.item.location.id if self.item.location else None + + def get_location_path(self): + if self.item.location: + return self.item.location.pathstring + else: + return "" + + def complete_allocation(self, user): + """ + Complete this allocation (called when the parent SalesOrder is marked as "shipped"): + + - Determine if the referenced StockItem needs to be "split" (if allocated quantity != stock quantity) + - Mark the StockItem as belonging to the Customer (this will remove it from stock) + """ + + order = self.line.order + + item = self.item + + # If the allocated quantity is less than the amount available, + # then split the stock item into two lots + if item.quantity > self.quantity: + + # Grab a copy of the new stock item (which will keep track of its "parent") + item = item.splitStock(self.quantity, None, user) + + # Update our own reference to the new item + self.item = item + self.save() + + # Assign the StockItem to the SalesOrder customer + item.sales_order = order + + # Clear the location + item.location = None + item.status = StockStatus.SHIPPED + + item.save() diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 935166e82a..e0ef57f802 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -10,13 +10,16 @@ from rest_framework import serializers from django.db.models import Count from InvenTree.serializers import InvenTreeModelSerializer -from company.serializers import CompanyBriefSerializer +from company.serializers import CompanyBriefSerializer, SupplierPartSerializer +from part.serializers import PartBriefSerializer from .models import PurchaseOrder, PurchaseOrderLineItem +from .models import SalesOrder, SalesOrderLineItem +from .models import SalesOrderAllocation class POSerializer(InvenTreeModelSerializer): - """ Serializes an Order object """ + """ Serializer for a PurchaseOrder object """ def __init__(self, *args, **kwargs): @@ -71,6 +74,22 @@ class POSerializer(InvenTreeModelSerializer): class POLineItemSerializer(InvenTreeModelSerializer): + def __init__(self, *args, **kwargs): + + part_detail = kwargs.pop('part_detail', False) + + super().__init__(*args, **kwargs) + + if part_detail is not True: + self.fields.pop('part_detail') + self.fields.pop('supplier_part_detail') + + quantity = serializers.FloatField() + received = serializers.FloatField() + + part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True) + supplier_part_detail = SupplierPartSerializer(source='part', many=False, read_only=True) + class Meta: model = PurchaseOrderLineItem @@ -81,5 +100,134 @@ class POLineItemSerializer(InvenTreeModelSerializer): 'notes', 'order', 'part', + 'part_detail', + 'supplier_part_detail', 'received', ] + + +class SalesOrderSerializer(InvenTreeModelSerializer): + """ + Serializers for the SalesOrder object + """ + + def __init__(self, *args, **kwargs): + + customer_detail = kwargs.pop('customer_detail', False) + + super().__init__(*args, **kwargs) + + if customer_detail is not True: + self.fields.pop('customer_detail') + + @staticmethod + def annotate_queryset(queryset): + """ + Add extra information to the queryset + """ + + return queryset.annotate( + line_items=Count('lines'), + ) + + customer_detail = CompanyBriefSerializer(source='customer', many=False, read_only=True) + + line_items = serializers.IntegerField(read_only=True) + + status_text = serializers.CharField(source='get_status_display', read_only=True) + + class Meta: + model = SalesOrder + + fields = [ + 'pk', + 'shipment_date', + 'creation_date', + 'description', + 'line_items', + 'link', + 'reference', + 'customer', + 'customer_detail', + 'customer_reference', + 'status', + 'status_text', + 'shipment_date', + 'notes', + ] + + read_only_fields = [ + 'reference', + 'status' + ] + + +class SalesOrderAllocationSerializer(InvenTreeModelSerializer): + """ + Serializer for the SalesOrderAllocation model. + This includes some fields from the related model objects. + """ + + location_path = serializers.CharField(source='get_location_path') + location_id = serializers.IntegerField(source='get_location') + serial = serializers.CharField(source='get_serial') + quantity = serializers.FloatField() + + class Meta: + model = SalesOrderAllocation + + fields = [ + 'pk', + 'line', + 'serial', + 'quantity', + 'location_id', + 'location_path', + 'item', + ] + + +class SOLineItemSerializer(InvenTreeModelSerializer): + """ Serializer for a SalesOrderLineItem object """ + + def __init__(self, *args, **kwargs): + + part_detail = kwargs.pop('part_detail', False) + order_detail = kwargs.pop('order_detail', False) + allocations = kwargs.pop('allocations', False) + + super().__init__(*args, **kwargs) + + if part_detail is not True: + self.fields.pop('part_detail') + + if order_detail is not True: + self.fields.pop('order_detail') + + if allocations is not True: + self.fields.pop('allocations') + + order_detail = SalesOrderSerializer(source='order', many=False, read_only=True) + part_detail = PartBriefSerializer(source='part', many=False, read_only=True) + allocations = SalesOrderAllocationSerializer(many=True, read_only=True) + + quantity = serializers.FloatField() + allocated = serializers.FloatField(source='allocated_quantity', read_only=True) + fulfilled = serializers.FloatField(source='fulfilled_quantity', read_only=True) + + class Meta: + model = SalesOrderLineItem + + fields = [ + 'pk', + 'allocated', + 'allocations', + 'quantity', + 'fulfilled', + 'reference', + 'notes', + 'order', + 'order_detail', + 'part', + 'part_detail', + ] diff --git a/InvenTree/order/templates/order/po_delete.html b/InvenTree/order/templates/order/delete_attachment.html similarity index 100% rename from InvenTree/order/templates/order/po_delete.html rename to InvenTree/order/templates/order/delete_attachment.html diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html index 03aa4c4ce2..e047c5607f 100644 --- a/InvenTree/order/templates/order/order_base.html +++ b/InvenTree/order/templates/order/order_base.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "two_column.html" %} {% load i18n %} {% load static %} @@ -6,129 +6,114 @@ {% load status_codes %} {% block page_title %} -InvenTree | {{ order }} +InvenTree | {% trans "Purchase Order" %} {% endblock %} -{% block content %} +{% block thumbnail %} + +{% endblock %} -
-
-
-
- -
-
-

{{ order }}

-

{{ order.description }}

-

-

-
- - - {% if order.status == OrderStatus.PENDING and order.lines.count > 0 %} - - {% elif order.status == OrderStatus.PLACED %} - - - {% endif %} - {% if order.status == OrderStatus.PENDING or order.status == OrderStatus.PLACED %} - - {% endif %} -
-
-

-
+{% block page_data %} +

{% trans "Purchase Order" %} {% purchase_order_status_label order.status large=True %}

+
+

{{ order }}

+

{{ order.description }}

+

+

+
+ + + {% if order.status == PurchaseOrderStatus.PENDING and order.lines.count > 0 %} + + {% elif order.status == PurchaseOrderStatus.PLACED %} + + + {% endif %} + {% if order.status == PurchaseOrderStatus.PENDING or order.status == PurchaseOrderStatus.PLACED %} + + {% endif %}
-
-

{% trans "Purchase Order Details" %}

- - - - - - - - - - - - - - - - - - {% if order.supplier_reference %} - - - - - - {% endif %} - {% if order.link %} - - - - - - {% endif %} - - - - - - {% if order.issue_date %} - - - - - - {% endif %} - {% if order.status == OrderStatus.COMPLETE %} - - - - - - {% endif %} -
{% trans "Order Reference" %}{{ order.reference }}
{% trans "Order Status" %}{% order_status order.status %}
{% trans "Supplier" %}{{ order.supplier.name }}
{% trans "Supplier Reference" %}{{ order.supplier_reference }}
External Link{{ order.link }}
{% trans "Created" %}{{ order.creation_date }}{{ order.created_by }}
{% trans "Issued" %}{{ order.issue_date }}
{% trans "Received" %}{{ order.complete_date }}{{ order.received_by }}
-
-
- -
-
-{% block details %} - - - +

{% endblock %} -
+{% block page_details %} +

{% trans "Purchase Order Details" %}

+ + + + + + + + + + + + + + + + + + {% if order.supplier_reference %} + + + + + + {% endif %} + {% if order.link %} + + + + + + {% endif %} + + + + + + {% if order.issue_date %} + + + + + + {% endif %} + {% if order.status == PurchaseOrderStatus.COMPLETE %} + + + + + + {% endif %} +
{% trans "Order Reference" %}{{ order.reference }}
{% trans "Order Status" %}{% purchase_order_status_label order.status %}
{% trans "Supplier" %}{{ order.supplier.name }}
{% trans "Supplier Reference" %}{{ order.supplier_reference }}
External Link{{ order.link }}
{% trans "Created" %}{{ order.creation_date }}{{ order.created_by }}
{% trans "Issued" %}{{ order.issue_date }}
{% trans "Received" %}{{ order.complete_date }}{{ order.received_by }}
{% endblock %} {% block js_ready %} {{ block.super }} -{% if order.status == OrderStatus.PENDING and order.lines.count > 0 %} +{% if order.status == PurchaseOrderStatus.PENDING and order.lines.count > 0 %} $("#place-order").click(function() { launchModalForm("{% url 'po-issue' order.id %}", { diff --git a/InvenTree/order/templates/order/order_cancel.html b/InvenTree/order/templates/order/order_cancel.html index 3c71028b06..91707ae737 100644 --- a/InvenTree/order/templates/order/order_cancel.html +++ b/InvenTree/order/templates/order/order_cancel.html @@ -1,7 +1,9 @@ {% extends "modal_form.html" %} +{% load i18n %} + {% block pre_form_content %} -Cancelling this order means that the order will no longer be editable. +{% trans "Cancelling this order means that the order will no longer be editable." %} {% endblock %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/order_notes.html b/InvenTree/order/templates/order/order_notes.html index de4f18ba6b..1d2c19c6cb 100644 --- a/InvenTree/order/templates/order/order_notes.html +++ b/InvenTree/order/templates/order/order_notes.html @@ -7,7 +7,7 @@ {% block details %} -{% include 'order/tabs.html' with tab='notes' %} +{% include 'order/po_tabs.html' with tab='notes' %} {% if editing %}

{% trans "Order Notes" %}

diff --git a/InvenTree/order/templates/order/po_attachments.html b/InvenTree/order/templates/order/po_attachments.html index 173b0e1fb6..e8e2b4bbff 100644 --- a/InvenTree/order/templates/order/po_attachments.html +++ b/InvenTree/order/templates/order/po_attachments.html @@ -6,7 +6,7 @@ {% block details %} -{% include 'order/tabs.html' with tab='attachments' %} +{% include 'order/po_tabs.html' with tab='attachments' %}

{% trans "Purchase Order Attachments" %} diff --git a/InvenTree/order/templates/order/tabs.html b/InvenTree/order/templates/order/po_tabs.html similarity index 100% rename from InvenTree/order/templates/order/tabs.html rename to InvenTree/order/templates/order/po_tabs.html diff --git a/InvenTree/order/templates/order/purchase_order_detail.html b/InvenTree/order/templates/order/purchase_order_detail.html index 8196ada70d..73c794126c 100644 --- a/InvenTree/order/templates/order/purchase_order_detail.html +++ b/InvenTree/order/templates/order/purchase_order_detail.html @@ -7,78 +7,19 @@ {% block details %} -{% include 'order/tabs.html' with tab='details' %} +{% include 'order/po_tabs.html' with tab='details' %}
- {% if order.status == OrderStatus.PENDING %} + {% if order.status == PurchaseOrderStatus.PENDING %} {% endif %}
-

{% trans "Order Items" %}

+

{% trans "Purchase Order Items" %}

- - - - - - - - - - {% if not order.status == OrderStatus.PENDING %} - - {% endif %} - - - - - - {% for line in order.lines.all %} - - - {% if line.part %} - - - - {% else %} - - {% endif %} - - - {% if not order.status == OrderStatus.PENDING %} - - {% endif %} - - - - {% endfor %} - +
{% trans "Line" %}{% trans "Part" %}{% trans "Description" %}{% trans "Order Code" %}{% trans "Reference" %}{% trans "Quantity" %}{% trans "Received" %}{% trans "Note" %}
- {{ forloop.counter }} - - {% include "hover_image.html" with image=line.part.part.image hover=True %} - {{ line.part.part.full_name }} - {{ line.part.part.description }}{{ line.part.SKU }}Warning: Part has been deleted.{{ line.reference }}{% decimal line.quantity %}{% decimal line.received %} - {{ line.notes }} - -
- {% if order.status == OrderStatus.PENDING %} - - - {% endif %} - {% if order.status == OrderStatus.PLACED and line.received < line.quantity %} - - {% endif %} -
-
{% endblock %} @@ -87,27 +28,6 @@ {{ block.super }} -$("#po-lines-table").on('click', ".line-receive", function() { - - var button = $(this); - - console.log('clicked! ' + button.attr('pk')); - - launchModalForm("{% url 'po-receive' order.id %}", { - reload: true, - data: { - line: button.attr('pk') - }, - secondary: [ - { - field: 'location', - label: 'New Location', - title: 'Create new stock location', - url: "{% url 'stock-location-create' %}", - }, - ] - }); -}); $("#receive-order").click(function() { launchModalForm("{% url 'po-receive' order.id %}", { @@ -115,8 +35,8 @@ $("#receive-order").click(function() { secondary: [ { field: 'location', - label: 'New Location', - title: 'Create new stock location', + label: '{% trans "New Location" %}', + title: '{% trans "Create new stock location" %}', url: "{% url 'stock-location-create' %}", }, ] @@ -133,7 +53,7 @@ $("#export-order").click(function() { location.href = "{% url 'po-export' order.id %}"; }); -{% if order.status == OrderStatus.PENDING %} +{% if order.status == PurchaseOrderStatus.PENDING %} $('#new-po-line').click(function() { launchModalForm("{% url 'po-line-item-create' %}", { @@ -144,8 +64,8 @@ $('#new-po-line').click(function() { secondary: [ { field: 'part', - label: 'New Supplier Part', - title: 'Create new supplier part', + label: '{% trans "New Supplier Part" %}', + title: '{% trans "Create new supplier part" %}', url: "{% url 'supplier-part-create' %}", data: { supplier: {{ order.supplier.id }}, @@ -157,7 +77,153 @@ $('#new-po-line').click(function() { }); {% endif %} -$("#po-lines-table").inventreeTable({ +function reloadTable() { + $("#po-table").bootstrapTable("refresh"); +} + +function setupCallbacks() { + // Setup callbacks for the line buttons + + var table = $("#po-table"); + + {% if order.status == PurchaseOrderStatus.PENDING %} + table.find(".button-line-edit").click(function() { + var pk = $(this).attr('pk'); + + launchModalForm(`/order/purchase-order/line/${pk}/edit/`, { + success: reloadTable, + }); + }); + + table.find(".button-line-delete").click(function() { + var pk = $(this).attr('pk'); + + launchModalForm(`/order/purchase-order/line/${pk}/delete/`, { + success: reloadTable, + }); + }); + {% endif %} + + table.find(".button-line-receive").click(function() { + var pk = $(this).attr('pk'); + + launchModalForm("{% url 'po-receive' order.id %}", { + success: reloadTable, + data: { + line: pk, + }, + secondary: [ + { + field: 'location', + label: '{% trans "New Location" %}', + title: '{% trans "Create new stock location" %}', + url: "{% url 'stock-location-create' %}", + }, + ] + }); + }); + +} + +$("#po-table").inventreeTable({ + onPostBody: setupCallbacks, + formatNoMatches: function() { return "{% trans 'No line items found' %}"; }, + queryParams: { + order: {{ order.id }}, + part_detail: true, + }, + url: "{% url 'api-po-line-list' %}", + columns: [ + { + field: 'pk', + title: 'ID', + visible: false, + }, + { + field: 'part', + sortable: true, + title: '{% trans "Part" %}', + formatter: function(value, row, index, field) { + if (row.part) { + return imageHoverIcon(row.part_detail.thumbnail) + renderLink(row.part_detail.full_name, `/part/${value}/`); + } else { + return '-'; + } + }, + }, + { + sortable: true, + field: 'part_detail.description', + title: '{% trans "Description" %}', + }, + { + sortable: true, + field: 'supplier_part_detail.SKU', + title: '{% trans "Order Code" %}', + formatter: function(value, row, index, field) { + return renderLink(value, `/supplier-part/${row.part}/`); + }, + }, + { + sortable: true, + field: 'reference', + title: '{% trans "Reference" %}', + }, + { + sortable: true, + field: 'quantity', + title: '{% trans "Quantity" %}' + }, + { + sortable: true, + field: 'received', + title: '{% trans "Received" %}', + formatter: function(value, row, index, field) { + return makeProgressBar(row.received, row.quantity, { + id: `order-line-progress-${row.pk}`, + }); + }, + sorter: function(valA, valB, rowA, rowB) { + + if (rowA.received == 0 && rowB.received == 0) { + return (rowA.quantity > rowB.quantity) ? 1 : -1; + } + + var progressA = parseFloat(rowA.received) / rowA.quantity; + var progressB = parseFloat(rowB.received) / rowB.quantity; + + return (progressA < progressB) ? 1 : -1; + } + }, + { + field: 'notes', + title: '{% trans "Notes" %}', + }, + { + field: 'buttons', + title: '', + formatter: function(value, row, index, field) { + var html = `
`; + + var pk = row.pk; + + {% if order.status == PurchaseOrderStatus.PENDING %} + html += makeIconButton('fa-edit', 'button-line-edit', pk, '{% trans "Edit line item" %}'); + html += makeIconButton('fa-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete line item" %}'); + {% endif %} + + {% if order.status == PurchaseOrderStatus.PLACED %} + if (row.received < row.quantity) { + html += makeIconButton('fa-clipboard-check', 'button-line-receive', pk, '{% trans "Receive line item" %}'); + } + {% endif %} + + html += `
`; + + return html; + }, + } + ] }); diff --git a/InvenTree/order/templates/order/purchase_orders.html b/InvenTree/order/templates/order/purchase_orders.html index 54d57d2d8c..1019092151 100644 --- a/InvenTree/order/templates/order/purchase_orders.html +++ b/InvenTree/order/templates/order/purchase_orders.html @@ -4,18 +4,18 @@ {% load i18n %} {% block page_title %} -InvenTree | Purchase Orders +InvenTree | {% trans "Purchase Orders" %} {% endblock %} {% block content %} -

Purchase Orders

+

{% trans "Purchase Orders" %}


- -
+ +
diff --git a/InvenTree/order/templates/order/sales_order_base.html b/InvenTree/order/templates/order/sales_order_base.html new file mode 100644 index 0000000000..6028157a22 --- /dev/null +++ b/InvenTree/order/templates/order/sales_order_base.html @@ -0,0 +1,133 @@ +{% extends "two_column.html" %} + +{% load i18n %} +{% load static %} +{% load inventree_extras %} +{% load status_codes %} + +{% block page_title %} +InvenTree | {% trans "Sales Order" %} +{% endblock %} + +{% block pre_content %} +{% if order.status == SalesOrderStatus.PENDING and not order.is_fully_allocated %} +
+ {% trans "This SalesOrder has not been fully allocated" %} +
+{% endif %} +{% endblock %} + +{% block thumbnail %} + +{% endblock %} + + +{% block page_data %} + +

{% trans "Sales Order" %} {% sales_order_status_label order.status large=True %}

+
+

{{ order }}

+

{{ order.description }}

+
+
+ + + {% if order.status == SalesOrderStatus.PENDING %} + + + {% endif %} +
+
+{% endblock %} + +{% block page_details %} +

{% trans "Sales Order Details" %}

+ + + + + + + + + + + + + + + + + + {% if order.customer_reference %} + + + + + + {% endif %} + {% if order.link %} + + + + + + {% endif %} + + + + + + {% if order.shipment_date %} + + + + + + {% endif %} + {% if order.status == PurchaseOrderStatus.COMPLETE %} + + + + + + {% endif %} +
{% trans "Order Reference" %}{{ order.reference }}
{% trans "Order Status" %}{% sales_order_status_label order.status %}
{% trans "Customer" %}{{ order.customer.name }}
{% trans "Customer Reference" %}{{ order.customer_reference }}
External Link{{ order.link }}
{% trans "Created" %}{{ order.creation_date }}{{ order.created_by }}
{% trans "Shipped" %}{{ order.shipment_date }}{{ order.shipped_by }}
{% trans "Received" %}{{ order.complete_date }}{{ order.received_by }}
+{% endblock %} + +{% block js_ready %} +{{ block.super }} + +$("#edit-order").click(function() { + launchModalForm("{% url 'so-edit' order.id %}", { + reload: true, + }); +}); + +$("#cancel-order").click(function() { + launchModalForm("{% url 'so-cancel' order.id %}", { + reload: true, + }); +}); + +$("#ship-order").click(function() { + launchModalForm("{% url 'so-ship' order.id %}", { + reload: true, + }); +}); + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/sales_order_cancel.html b/InvenTree/order/templates/order/sales_order_cancel.html new file mode 100644 index 0000000000..2f0fe3beb1 --- /dev/null +++ b/InvenTree/order/templates/order/sales_order_cancel.html @@ -0,0 +1,12 @@ +{% extends "modal_form.html" %} + +{% load i18n %} + +{% block pre_form_content %} + +
+

{% trans "Warning" %}

+ {% trans "Cancelling this order means that the order will no longer be editable." %} +
+ +{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/sales_order_detail.html b/InvenTree/order/templates/order/sales_order_detail.html new file mode 100644 index 0000000000..0ca63882b2 --- /dev/null +++ b/InvenTree/order/templates/order/sales_order_detail.html @@ -0,0 +1,361 @@ +{% extends "order/sales_order_base.html" %} + +{% load inventree_extras %} +{% load status_codes %} +{% load i18n %} +{% load static %} + +{% block details %} + +{% include "order/so_tabs.html" with tab='details' %} + +
+ +

{% trans "Sales Order Items" %}

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

{% trans "Order Notes" %}

+
+ +
+ {% csrf_token %} + + {{ form }} +
+ +
+ +{{ form.media }} + +{% else %} +
+
+

{% trans "Order Notes" %}

+
+
+ +
+
+
+
+
+ {{ order.notes | markdownify }} +
+
+ +{% endif %} + +{% endblock %} + +{% block js_ready %} + +{{ block.super }} + +{% if editing %} +{% else %} +$("#edit-notes").click(function() { + location.href = "{% url 'so-notes' order.id %}?edit=1"; +}); +{% endif %} + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/sales_order_ship.html b/InvenTree/order/templates/order/sales_order_ship.html new file mode 100644 index 0000000000..0060561e71 --- /dev/null +++ b/InvenTree/order/templates/order/sales_order_ship.html @@ -0,0 +1,30 @@ +{% extends "modal_form.html" %} + +{% load i18n %} + +{% block pre_form_content %} + +{% if not order.is_fully_allocated %} +
+

{% trans "Warning" %}

+ {% trans "This order has not been fully allocated. If the order is marked as shipped, it can no longer be adjusted." %} +
+ {% trans "Ensure that the order allocation is correct before shipping the order." %} +
+{% endif %} + +{% if order.is_over_allocated %} +
+ {% trans "Some line items in this order have been over-allocated" %} +
+ {% trans "Ensure that this is correct before shipping the order." %} +
+{% endif %} + +
+ {% trans "Sales Order" %} {{ order.reference }} - {{ order.customer.name }} +
+ {% trans "Shipping this order means that the order will no longer be editable." %} +
+ +{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/sales_orders.html b/InvenTree/order/templates/order/sales_orders.html new file mode 100644 index 0000000000..4e29156773 --- /dev/null +++ b/InvenTree/order/templates/order/sales_orders.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} + +{% load static %} +{% load i18n %} + +{% block page_title %} +InvenTree | {% trans "Sales Orders" %} +{% endblock %} + +{% block content %} + +

{% trans "Sales Orders" %}

+
+ +
+
+ +
+ +
+
+
+ + +
+ +{% endblock %} + +{% block js_ready %} +{{ block.super }} + +loadSalesOrderTable("#sales-order-table", { + url: "{% url 'api-so-list' %}", +}); + +$("#so-create").click(function() { + launchModalForm("{% url 'so-create' %}", + { + follow: true, + } + ); +}); + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/so_allocation_delete.html b/InvenTree/order/templates/order/so_allocation_delete.html new file mode 100644 index 0000000000..e4cbe0b602 --- /dev/null +++ b/InvenTree/order/templates/order/so_allocation_delete.html @@ -0,0 +1,14 @@ +{% extends "modal_delete_form.html" %} +{% load i18n %} +{% load inventree_extras %} + +{% block pre_form_content %} +
+ {% trans "This action will unallocate the following stock from the Sales Order" %}: +
+ + {% decimal allocation.get_allocated %} x {{ allocation.line.part.full_name }} + {% if allocation.item.location %} ({{ allocation.get_location }}){% endif %} + +
+{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/so_attachments.html b/InvenTree/order/templates/order/so_attachments.html new file mode 100644 index 0000000000..82248fd5eb --- /dev/null +++ b/InvenTree/order/templates/order/so_attachments.html @@ -0,0 +1,81 @@ +{% extends "order/sales_order_base.html" %} + +{% load inventree_extras %} +{% load i18n %} +{% load static %} + +{% block details %} + +{% include 'order/so_tabs.html' with tab='attachments' %} + +

{% trans "Sales Order Attachments" %} + +
+ +
+
+ +
+
+ + + + + + + + + + + {% for attachment in order.attachments.all %} + + + + + + {% endfor %} + +
{% trans "File" %}{% trans "Comment" %}
{{ attachment.basename }}{{ attachment.comment }} +
+ + +
+
+ +{% endblock %} + +{% block js_ready %} +{{ block.super }} + +$("#new-attachment").click(function() { + launchModalForm("{% url 'so-attachment-create' %}?order={{ order.id }}", + { + reload: true, + } + ); +}); + +$("#attachment-table").on('click', '.attachment-edit-button', function() { + var button = $(this); + + launchModalForm(button.attr('url'), { + reload: true, + }); +}); + +$("#attachment-table").on('click', '.attachment-delete-button', function() { + var button = $(this); + + launchModalForm(button.attr('url'), { + reload: true, + }); +}); + +$("#attachment-table").inventreeTable({ +}); + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/so_builds.html b/InvenTree/order/templates/order/so_builds.html new file mode 100644 index 0000000000..f877a4977f --- /dev/null +++ b/InvenTree/order/templates/order/so_builds.html @@ -0,0 +1,30 @@ +{% extends "order/sales_order_base.html" %} + +{% load inventree_extras %} +{% load i18n %} +{% load static %} + +{% block details %} + +{% include 'order/so_tabs.html' with tab='builds' %} + +

{% trans "Build Orders" %}

+
+ +
+ +{% endblock %} + +{% block js_ready %} + +{{ block.super }} + +loadBuildTable($("#builds-table"), { + url: "{% url 'api-build-list' %}", + params: { + sales_order: {{ order.id }}, + part_detail: true, + }, +}); + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/so_lineitem_delete.html b/InvenTree/order/templates/order/so_lineitem_delete.html new file mode 100644 index 0000000000..1d9f80d137 --- /dev/null +++ b/InvenTree/order/templates/order/so_lineitem_delete.html @@ -0,0 +1,6 @@ +{% extends "modal_delete_form.html" %} +{% load i18n %} + +{% block pre_form_content %} +{% trans "Are you sure you wish to delete this line item?" %} +{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/so_tabs.html b/InvenTree/order/templates/order/so_tabs.html new file mode 100644 index 0000000000..cb3740a073 --- /dev/null +++ b/InvenTree/order/templates/order/so_tabs.html @@ -0,0 +1,25 @@ +{% load i18n %} + + \ No newline at end of file diff --git a/InvenTree/order/test_sales_order.py b/InvenTree/order/test_sales_order.py new file mode 100644 index 0000000000..6cc48c3b6f --- /dev/null +++ b/InvenTree/order/test_sales_order.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- + +from django.test import TestCase + +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError + +from company.models import Company +from stock.models import StockItem +from order.models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation +from part.models import Part +from InvenTree import status_codes as status + + +class SalesOrderTest(TestCase): + """ + Run tests to ensure that the SalesOrder model is working correctly. + + """ + + def setUp(self): + + # Create a Company to ship the goods to + self.customer = Company.objects.create(name="ABC Co", description="My customer", is_customer=True) + + # Create a Part to ship + self.part = Part.objects.create(name='Spanner', salable=True, description='A spanner that I sell') + + # Create some stock! + StockItem.objects.create(part=self.part, quantity=100) + StockItem.objects.create(part=self.part, quantity=200) + + # Create a SalesOrder to ship against + self.order = SalesOrder.objects.create( + customer=self.customer, + reference='1234', + customer_reference='ABC 55555' + ) + + # Create a line item + self.line = SalesOrderLineItem.objects.create(quantity=50, order=self.order, part=self.part) + + def test_empty_order(self): + self.assertEqual(self.line.quantity, 50) + self.assertEqual(self.line.allocated_quantity(), 0) + self.assertEqual(self.line.fulfilled_quantity(), 0) + self.assertFalse(self.line.is_fully_allocated()) + self.assertFalse(self.line.is_over_allocated()) + + self.assertTrue(self.order.is_pending) + self.assertFalse(self.order.is_fully_allocated()) + + def test_add_duplicate_line_item(self): + # Adding a duplicate line item to a SalesOrder must throw an error + + with self.assertRaises(IntegrityError): + SalesOrderLineItem.objects.create(order=self.order, part=self.part) + + def allocate_stock(self, full=True): + # Allocate stock to the order + SalesOrderAllocation.objects.create( + line=self.line, + item=StockItem.objects.get(pk=1), + quantity=25) + + SalesOrderAllocation.objects.create( + line=self.line, + item=StockItem.objects.get(pk=2), + quantity=25 if full else 20 + ) + + def test_allocate_partial(self): + # Partially allocate stock + self.allocate_stock(False) + + self.assertFalse(self.order.is_fully_allocated()) + self.assertFalse(self.line.is_fully_allocated()) + self.assertEqual(self.line.allocated_quantity(), 45) + self.assertEqual(self.line.fulfilled_quantity(), 0) + + def test_allocate_full(self): + # Fully allocate stock + self.allocate_stock(True) + + self.assertTrue(self.order.is_fully_allocated()) + self.assertTrue(self.line.is_fully_allocated()) + self.assertEqual(self.line.allocated_quantity(), 50) + + def test_order_cancel(self): + # Allocate line items then cancel the order + + self.allocate_stock(True) + + self.assertEqual(SalesOrderAllocation.objects.count(), 2) + self.assertEqual(self.order.status, status.SalesOrderStatus.PENDING) + + self.order.cancel_order() + self.assertEqual(SalesOrderAllocation.objects.count(), 0) + self.assertEqual(self.order.status, status.SalesOrderStatus.CANCELLED) + + # Now try to ship it - should fail + with self.assertRaises(ValidationError): + self.order.ship_order(None) + + def test_ship_order(self): + # Allocate line items, then ship the order + + # Assert some stuff before we run the test + # Initially there are two stock items + self.assertEqual(StockItem.objects.count(), 2) + + # Take 25 units from each StockItem + self.allocate_stock(True) + + self.assertEqual(SalesOrderAllocation.objects.count(), 2) + + self.order.ship_order(None) + + # There should now be 4 stock items + self.assertEqual(StockItem.objects.count(), 4) + + self.assertEqual(StockItem.objects.get(pk=1).quantity, 75) + self.assertEqual(StockItem.objects.get(pk=2).quantity, 175) + self.assertEqual(StockItem.objects.get(pk=3).quantity, 25) + self.assertEqual(StockItem.objects.get(pk=3).quantity, 25) + + self.assertEqual(StockItem.objects.get(pk=1).sales_order, None) + self.assertEqual(StockItem.objects.get(pk=2).sales_order, None) + self.assertEqual(StockItem.objects.get(pk=3).sales_order, self.order) + self.assertEqual(StockItem.objects.get(pk=4).sales_order, self.order) + + # And no allocations + self.assertEqual(SalesOrderAllocation.objects.count(), 0) + + self.assertEqual(self.order.status, status.SalesOrderStatus.SHIPPED) + + self.assertTrue(self.order.is_fully_allocated()) + self.assertTrue(self.line.is_fully_allocated()) + self.assertEqual(self.line.fulfilled_quantity(), 50) + self.assertEqual(self.line.allocated_quantity(), 0) diff --git a/InvenTree/order/test_views.py b/InvenTree/order/test_views.py index bf3608e2b0..932cac9060 100644 --- a/InvenTree/order/test_views.py +++ b/InvenTree/order/test_views.py @@ -7,7 +7,7 @@ from django.test import TestCase from django.urls import reverse from django.contrib.auth import get_user_model -from InvenTree.status_codes import OrderStatus +from InvenTree.status_codes import PurchaseOrderStatus from .models import PurchaseOrder, PurchaseOrderLineItem @@ -53,7 +53,7 @@ class POTests(OrderViewTestCase): response = self.client.get(reverse('po-detail', args=(1,))) self.assertEqual(response.status_code, 200) keys = response.context.keys() - self.assertIn('OrderStatus', keys) + self.assertIn('PurchaseOrderStatus', keys) def test_po_create(self): """ Launch forms to create new PurchaseOrder""" @@ -91,7 +91,7 @@ class POTests(OrderViewTestCase): url = reverse('po-issue', args=(1,)) order = PurchaseOrder.objects.get(pk=1) - self.assertEqual(order.status, OrderStatus.PENDING) + self.assertEqual(order.status, PurchaseOrderStatus.PENDING) # Test without confirmation response = self.client.post(url, {'confirm': 0}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') @@ -109,7 +109,7 @@ class POTests(OrderViewTestCase): # Test that the order was actually placed order = PurchaseOrder.objects.get(pk=1) - self.assertEqual(order.status, OrderStatus.PLACED) + self.assertEqual(order.status, PurchaseOrderStatus.PLACED) def test_line_item_create(self): """ Test the form for adding a new LineItem to a PurchaseOrder """ @@ -117,7 +117,7 @@ class POTests(OrderViewTestCase): # Record the number of line items in the PurchaseOrder po = PurchaseOrder.objects.get(pk=1) n = po.lines.count() - self.assertEqual(po.status, OrderStatus.PENDING) + self.assertEqual(po.status, PurchaseOrderStatus.PENDING) url = reverse('po-line-item-create') @@ -181,7 +181,7 @@ class TestPOReceive(OrderViewTestCase): super().setUp() self.po = PurchaseOrder.objects.get(pk=1) - self.po.status = OrderStatus.PLACED + self.po.status = PurchaseOrderStatus.PLACED self.po.save() self.url = reverse('po-receive', args=(1,)) diff --git a/InvenTree/order/tests.py b/InvenTree/order/tests.py index 35cf8909be..ca24b9586d 100644 --- a/InvenTree/order/tests.py +++ b/InvenTree/order/tests.py @@ -6,7 +6,7 @@ from .models import PurchaseOrder, PurchaseOrderLineItem from stock.models import StockLocation from company.models import SupplierPart -from InvenTree.status_codes import OrderStatus +from InvenTree.status_codes import PurchaseOrderStatus class OrderTest(TestCase): @@ -31,11 +31,11 @@ class OrderTest(TestCase): self.assertEqual(order.get_absolute_url(), '/order/purchase-order/1/') - self.assertEqual(str(order), 'PO 1') + self.assertEqual(str(order), 'PO 1 - ACME') line = PurchaseOrderLineItem.objects.get(pk=1) - self.assertEqual(str(line), "100 x ACME0001 from ACME (for PO 1)") + self.assertEqual(str(line), "100 x ACME0001 from ACME (for PO 1 - ACME)") def test_on_order(self): """ There should be 3 separate items on order for the M2x4 LPHS part """ @@ -57,7 +57,7 @@ class OrderTest(TestCase): order = PurchaseOrder.objects.get(pk=1) - self.assertEqual(order.status, OrderStatus.PENDING) + self.assertEqual(order.status, PurchaseOrderStatus.PENDING) self.assertEqual(order.lines.count(), 3) sku = SupplierPart.objects.get(SKU='ACME-WIDGET') @@ -104,14 +104,14 @@ class OrderTest(TestCase): self.assertEqual(len(order.pending_line_items()), 3) # Should fail, as order is 'PENDING' not 'PLACED" - self.assertEqual(order.status, OrderStatus.PENDING) + self.assertEqual(order.status, PurchaseOrderStatus.PENDING) with self.assertRaises(django_exceptions.ValidationError): order.receive_line_item(line, loc, 50, user=None) order.place_order() - self.assertEqual(order.status, OrderStatus.PLACED) + self.assertEqual(order.status, PurchaseOrderStatus.PLACED) order.receive_line_item(line, loc, 50, user=None) @@ -134,9 +134,9 @@ class OrderTest(TestCase): order.receive_line_item(line, loc, 500, user=None) self.assertEqual(part.on_order, 800) - self.assertEqual(order.status, OrderStatus.PLACED) + self.assertEqual(order.status, PurchaseOrderStatus.PLACED) for line in order.pending_line_items(): order.receive_line_item(line, loc, line.quantity, user=None) - self.assertEqual(order.status, OrderStatus.COMPLETE) + self.assertEqual(order.status, PurchaseOrderStatus.COMPLETE) diff --git a/InvenTree/order/urls.py b/InvenTree/order/urls.py index 3346d7c44d..daa83eb5ab 100644 --- a/InvenTree/order/urls.py +++ b/InvenTree/order/urls.py @@ -9,21 +9,15 @@ from django.conf.urls import url, include from . import views -purchase_order_attachment_urls = [ - url(r'^new/', views.PurchaseOrderAttachmentCreate.as_view(), name='po-attachment-create'), - url(r'^(?P\d+)/edit/', views.PurchaseOrderAttachmentEdit.as_view(), name='po-attachment-edit'), - url(r'^(?P\d+)/delete/', views.PurchaseOrderAttachmentDelete.as_view(), name='po-attachment-delete'), -] - purchase_order_detail_urls = [ - url(r'^cancel/?', views.PurchaseOrderCancel.as_view(), name='po-cancel'), - url(r'^edit/?', views.PurchaseOrderEdit.as_view(), name='po-edit'), - url(r'^issue/?', views.PurchaseOrderIssue.as_view(), name='po-issue'), - url(r'^receive/?', views.PurchaseOrderReceive.as_view(), name='po-receive'), - url(r'^complete/?', views.PurchaseOrderComplete.as_view(), name='po-complete'), + url(r'^cancel/', views.PurchaseOrderCancel.as_view(), name='po-cancel'), + url(r'^edit/', views.PurchaseOrderEdit.as_view(), name='po-edit'), + url(r'^issue/', views.PurchaseOrderIssue.as_view(), name='po-issue'), + url(r'^receive/', views.PurchaseOrderReceive.as_view(), name='po-receive'), + url(r'^complete/', views.PurchaseOrderComplete.as_view(), name='po-complete'), - url(r'^export/?', views.PurchaseOrderExport.as_view(), name='po-export'), + url(r'^export/', views.PurchaseOrderExport.as_view(), name='po-export'), url(r'^notes/', views.PurchaseOrderNotes.as_view(), name='po-notes'), @@ -31,19 +25,6 @@ purchase_order_detail_urls = [ url(r'^.*$', views.PurchaseOrderDetail.as_view(), name='po-detail'), ] -po_line_item_detail_urls = [ - - url(r'^edit/', views.POLineItemEdit.as_view(), name='po-line-item-edit'), - url(r'^delete/', views.POLineItemDelete.as_view(), name='po-line-item-delete'), -] - -po_line_urls = [ - - url(r'^new/', views.POLineItemCreate.as_view(), name='po-line-item-create'), - - url(r'^(?P\d+)/', include(po_line_item_detail_urls)), -] - purchase_order_urls = [ url(r'^new/', views.PurchaseOrderCreate.as_view(), name='po-create'), @@ -53,14 +34,72 @@ purchase_order_urls = [ # Display detail view for a single purchase order url(r'^(?P\d+)/', include(purchase_order_detail_urls)), - url(r'^line/', include(po_line_urls)), + url(r'^line/', include([ + url(r'^new/', views.POLineItemCreate.as_view(), name='po-line-item-create'), + url(r'^(?P\d+)/', include([ + url(r'^edit/', views.POLineItemEdit.as_view(), name='po-line-item-edit'), + url(r'^delete/', views.POLineItemDelete.as_view(), name='po-line-item-delete'), + ])), + ])), - url(r'^attachments/', include(purchase_order_attachment_urls)), + url(r'^attachments/', include([ + url(r'^new/', views.PurchaseOrderAttachmentCreate.as_view(), name='po-attachment-create'), + url(r'^(?P\d+)/edit/', views.PurchaseOrderAttachmentEdit.as_view(), name='po-attachment-edit'), + url(r'^(?P\d+)/delete/', views.PurchaseOrderAttachmentDelete.as_view(), name='po-attachment-delete'), + ])), # Display complete list of purchase orders url(r'^.*$', views.PurchaseOrderIndex.as_view(), name='po-index'), ] +sales_order_detail_urls = [ + + url(r'^edit/', views.SalesOrderEdit.as_view(), name='so-edit'), + url(r'^cancel/', views.SalesOrderCancel.as_view(), name='so-cancel'), + url(r'^ship/', views.SalesOrderShip.as_view(), name='so-ship'), + + url(r'^builds/', views.SalesOrderDetail.as_view(template_name='order/so_builds.html'), name='so-builds'), + url(r'^attachments/', views.SalesOrderDetail.as_view(template_name='order/so_attachments.html'), name='so-attachments'), + url(r'^notes/', views.SalesOrderNotes.as_view(), name='so-notes'), + + url(r'^.*$', views.SalesOrderDetail.as_view(), name='so-detail'), +] + +sales_order_urls = [ + + url(r'^new/', views.SalesOrderCreate.as_view(), name='so-create'), + + url(r'^line/', include([ + url(r'^new/', views.SOLineItemCreate.as_view(), name='so-line-item-create'), + url(r'^(?P\d+)/', include([ + url(r'^edit/', views.SOLineItemEdit.as_view(), name='so-line-item-edit'), + url(r'^delete/', views.SOLineItemDelete.as_view(), name='so-line-item-delete'), + ])), + ])), + + # URLs for sales order allocations + url(r'^allocation/', include([ + url(r'^new/', views.SalesOrderAllocationCreate.as_view(), name='so-allocation-create'), + url(r'(?P\d+)/', include([ + url(r'^edit/', views.SalesOrderAllocationEdit.as_view(), name='so-allocation-edit'), + url(r'^delete/', views.SalesOrderAllocationDelete.as_view(), name='so-allocation-delete'), + ])), + ])), + + url(r'^attachments/', include([ + url(r'^new/', views.SalesOrderAttachmentCreate.as_view(), name='so-attachment-create'), + url(r'^(?P\d+)/edit/', views.SalesOrderAttachmentEdit.as_view(), name='so-attachment-edit'), + url(r'^(?P\d+)/delete/', views.SalesOrderAttachmentDelete.as_view(), name='so-attachment-delete'), + ])), + + # Display detail view for a single SalesOrder + url(r'^(?P\d+)/', include(sales_order_detail_urls)), + + # Display list of all sales orders + url(r'^.*$', views.SalesOrderIndex.as_view(), name='so-index'), +] + order_urls = [ url(r'^purchase-order/', include(purchase_order_urls)), + url(r'^sales-order/', include(sales_order_urls)), ] diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index 67d5b926d7..476a61b5e3 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -16,6 +16,8 @@ import logging from decimal import Decimal, InvalidOperation from .models import PurchaseOrder, PurchaseOrderLineItem, PurchaseOrderAttachment +from .models import SalesOrder, SalesOrderLineItem, SalesOrderAttachment +from .models import SalesOrderAllocation from .admin import POLineItemResource from build.models import Build from company.models import Company, SupplierPart @@ -27,7 +29,7 @@ from . import forms as order_forms from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView from InvenTree.helpers import DownloadFile, str2bool -from InvenTree.status_codes import OrderStatus +from InvenTree.status_codes import PurchaseOrderStatus logger = logging.getLogger(__name__) @@ -50,11 +52,16 @@ class PurchaseOrderIndex(ListView): def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) - ctx['OrderStatus'] = OrderStatus - return ctx +class SalesOrderIndex(ListView): + + model = SalesOrder + template_name = 'order/sales_orders.html' + context_object_name = 'orders' + + class PurchaseOrderDetail(DetailView): """ Detail view for a PurchaseOrder object """ @@ -65,11 +72,17 @@ class PurchaseOrderDetail(DetailView): def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) - ctx['OrderStatus'] = OrderStatus - return ctx +class SalesOrderDetail(DetailView): + """ Detail view for a SalesOrder object """ + + context_object_name = 'order' + queryset = SalesOrder.objects.all().prefetch_related('lines') + template_name = 'order/sales_order_detail.html' + + class PurchaseOrderAttachmentCreate(AjaxCreateView): """ View for creating a new PurchaseOrderAtt @@ -113,6 +126,34 @@ class PurchaseOrderAttachmentCreate(AjaxCreateView): return form +class SalesOrderAttachmentCreate(AjaxCreateView): + """ View for creating a new SalesOrderAttachment """ + + model = SalesOrderAttachment + form_class = order_forms.EditSalesOrderAttachmentForm + ajax_form_title = _('Add Sales Order Attachment') + + def get_data(self): + return { + 'success': _('Added attachment') + } + + def get_initial(self): + initials = super().get_initial().copy() + + initials['order'] = SalesOrder.objects.get(id=self.request.GET.get('order', None)) + + return initials + + def get_form(self): + """ Hide the 'order' field """ + + form = super().get_form() + form.fields['order'].widget = HiddenInput() + + return form + + class PurchaseOrderAttachmentEdit(AjaxUpdateView): """ View for editing a PurchaseOrderAttachment object """ @@ -134,12 +175,46 @@ class PurchaseOrderAttachmentEdit(AjaxUpdateView): return form +class SalesOrderAttachmentEdit(AjaxUpdateView): + """ View for editing a SalesOrderAttachment object """ + + model = SalesOrderAttachment + form_class = order_forms.EditSalesOrderAttachmentForm + ajax_form_title = _("Edit Attachment") + + def get_data(self): + return { + 'success': _('Attachment updated') + } + + def get_form(self): + form = super().get_form() + + form.fields['order'].widget = HiddenInput() + + return form + + class PurchaseOrderAttachmentDelete(AjaxDeleteView): """ View for deleting a PurchaseOrderAttachment """ model = PurchaseOrderAttachment ajax_form_title = _("Delete Attachment") - ajax_template_name = "order/po_delete.html" + ajax_template_name = "order/delete_attachment.html" + context_object_name = "attachment" + + def get_data(self): + return { + "danger": _("Deleted attachment") + } + + +class SalesOrderAttachmentDelete(AjaxDeleteView): + """ View for deleting a SalesOrderAttachment """ + + model = SalesOrderAttachment + ajax_form_title = _("Delete Attachment") + ajax_template_name = "order/delete_attachment.html" context_object_name = "attachment" def get_data(self): @@ -165,7 +240,28 @@ class PurchaseOrderNotes(UpdateView): ctx = super().get_context_data(**kwargs) - ctx['editing'] = str2bool(self.request.GET.get('edit', '')) + ctx['editing'] = str2bool(self.request.GET.get('edit', False)) + + return ctx + + +class SalesOrderNotes(UpdateView): + """ View for editing the 'notes' field of a SalesORder """ + + context_object_name = 'order' + template_name = 'order/sales_order_notes.html' + model = SalesOrder + + fields = ['notes'] + + def get_success_url(self): + return reverse('so-notes', kwargs={'pk': self.get_object().pk}) + + def get_context_data(self, **kwargs): + + ctx = super().get_context_data(**kwargs) + + ctx['editing'] = str2bool(self.request.GET.get('edit', False)) return ctx @@ -180,7 +276,7 @@ class PurchaseOrderCreate(AjaxCreateView): def get_initial(self): initials = super().get_initial().copy() - initials['status'] = OrderStatus.PENDING + initials['status'] = PurchaseOrderStatus.PENDING supplier_id = self.request.GET.get('supplier', None) @@ -200,6 +296,35 @@ class PurchaseOrderCreate(AjaxCreateView): self.object.save() +class SalesOrderCreate(AjaxCreateView): + """ View for creating a new SalesOrder object """ + + model = SalesOrder + ajax_form_title = _("Create Sales Order") + form_class = order_forms.EditSalesOrderForm + + def get_initial(self): + initials = super().get_initial().copy() + + initials['status'] = PurchaseOrderStatus.PENDING + + customer_id = self.request.GET.get('customer', None) + + if customer_id is not None: + try: + customer = Company.objects.get(id=customer_id) + initials['customer'] = customer + except (Company.DoesNotExist, ValueError): + pass + + return initials + + def post_save(self, **kwargs): + # Record the user who created this sales order + self.object.created_by = self.request.user + self.object.save() + + class PurchaseOrderEdit(AjaxUpdateView): """ View for editing a PurchaseOrder using a modal form """ @@ -214,12 +339,28 @@ class PurchaseOrderEdit(AjaxUpdateView): order = self.get_object() # Prevent user from editing supplier if there are already lines in the order - if order.lines.count() > 0 or not order.status == OrderStatus.PENDING: + if order.lines.count() > 0 or not order.status == PurchaseOrderStatus.PENDING: form.fields['supplier'].widget = HiddenInput() return form +class SalesOrderEdit(AjaxUpdateView): + """ View for editing a SalesOrder """ + + model = SalesOrder + ajax_form_title = _('Edit Sales Order') + form_class = order_forms.EditSalesOrderForm + + def get_form(self): + form = super().get_form() + + # Prevent user from editing customer + form.fields['customer'].widget = HiddenInput() + + return form + + class PurchaseOrderCancel(AjaxUpdateView): """ View for cancelling a purchase order """ @@ -253,6 +394,40 @@ class PurchaseOrderCancel(AjaxUpdateView): return self.renderJsonResponse(request, form, data) +class SalesOrderCancel(AjaxUpdateView): + """ View for cancelling a sales order """ + + model = SalesOrder + ajax_form_title = _("Cancel sales order") + ajax_template_name = "order/sales_order_cancel.html" + form_class = order_forms.CancelSalesOrderForm + + def post(self, request, *args, **kwargs): + + order = self.get_object() + form = self.get_form() + + confirm = str2bool(request.POST.get('confirm', False)) + + valid = False + + if not confirm: + form.errors['confirm'] = [_('Confirm order cancellation')] + else: + valid = True + + if valid: + if not order.cancel_order(): + form.non_field_errors = [_('Could not cancel order')] + valid = False + + data = { + 'form_valid': valid, + } + + return self.renderJsonResponse(request, form, data) + + class PurchaseOrderIssue(AjaxUpdateView): """ View for changing a purchase order from 'PENDING' to 'ISSUED' """ @@ -310,7 +485,7 @@ class PurchaseOrderComplete(AjaxUpdateView): if confirm: po = self.get_object() - po.status = OrderStatus.COMPLETE + po.status = PurchaseOrderStatus.COMPLETE po.save() data = { @@ -322,6 +497,48 @@ class PurchaseOrderComplete(AjaxUpdateView): return self.renderJsonResponse(request, form, data) +class SalesOrderShip(AjaxUpdateView): + """ View for 'shipping' a SalesOrder """ + form_class = order_forms.ShipSalesOrderForm + model = SalesOrder + context_object_name = 'order' + ajax_template_name = 'order/sales_order_ship.html' + ajax_form_title = _('Ship Order') + + def post(self, request, *args, **kwargs): + + self.request = request + + order = self.get_object() + self.object = order + + form = self.get_form() + + confirm = str2bool(request.POST.get('confirm', False)) + + valid = False + + if not confirm: + form.errors['confirm'] = [_('Confirm order shipment')] + else: + valid = True + + if valid: + if not order.ship_order(request.user): + form.non_field_errors = [_('Could not ship order')] + valid = False + + data = { + 'form_valid': valid, + } + + context = self.get_context_data() + + context['order'] = order + + return self.renderJsonResponse(request, form, data, context) + + class PurchaseOrderExport(AjaxView): """ File download for a purchase order @@ -879,7 +1096,7 @@ class POLineItemCreate(AjaxCreateView): # Limit the available to orders to ones that are PENDING query = form.fields['order'].queryset - query = query.filter(status=OrderStatus.PENDING) + query = query.filter(status=PurchaseOrderStatus.PENDING) form.fields['order'].queryset = query order_id = form['order'].value() @@ -924,12 +1141,80 @@ class POLineItemCreate(AjaxCreateView): order = PurchaseOrder.objects.get(id=order_id) initials['order'] = order - except PurchaseOrder.DoesNotExist: + except (PurchaseOrder.DoesNotExist, ValueError): pass return initials +class SOLineItemCreate(AjaxCreateView): + """ Ajax view for creating a new SalesOrderLineItem object """ + + model = SalesOrderLineItem + context_order_name = 'line' + form_class = order_forms.EditSalesOrderLineItemForm + ajax_form_title = _('Add Line Item') + + def get_form(self, *args, **kwargs): + + form = super().get_form(*args, **kwargs) + + # If the order is specified, hide the widget + order_id = form['order'].value() + + if SalesOrder.objects.filter(id=order_id).exists(): + form.fields['order'].widget = HiddenInput() + + return form + + def get_initial(self): + """ + Extract initial data for this line item: + + Options: + order: The SalesOrder object + part: The Part object + """ + + initials = super().get_initial().copy() + + order_id = self.request.GET.get('order', None) + part_id = self.request.GET.get('part', None) + + if order_id: + try: + order = SalesOrder.objects.get(id=order_id) + initials['order'] = order + except (SalesOrder.DoesNotExist, ValueError): + pass + + if part_id: + try: + part = Part.objects.get(id=part_id) + if part.salable: + initials['part'] = part + except (Part.DoesNotExist, ValueError): + pass + + return initials + + +class SOLineItemEdit(AjaxUpdateView): + """ View for editing a SalesOrderLineItem """ + + model = SalesOrderLineItem + form_class = order_forms.EditSalesOrderLineItemForm + ajax_form_title = _('Edit Line Item') + + def get_form(self): + form = super().get_form() + + form.fields.pop('order') + form.fields.pop('part') + + return form + + class POLineItemEdit(AjaxUpdateView): """ View for editing a PurchaseOrderLineItem object in a modal form. """ @@ -960,3 +1245,109 @@ class POLineItemDelete(AjaxDeleteView): return { 'danger': _('Deleted line item'), } + + +class SOLineItemDelete(AjaxDeleteView): + + model = SalesOrderLineItem + ajax_form_title = _("Delete Line Item") + ajax_template_name = "order/so_lineitem_delete.html" + + def get_data(self): + return { + 'danger': _('Deleted line item'), + } + + +class SalesOrderAllocationCreate(AjaxCreateView): + """ View for creating a new SalesOrderAllocation """ + + model = SalesOrderAllocation + form_class = order_forms.EditSalesOrderAllocationForm + ajax_form_title = _('Allocate Stock to Order') + + def get_initial(self): + initials = super().get_initial().copy() + + line_id = self.request.GET.get('line', None) + + if line_id is not None: + line = SalesOrderLineItem.objects.get(pk=line_id) + + initials['line'] = line + + # Search for matching stock items, pre-fill if there is only one + items = StockItem.objects.filter(part=line.part) + + quantity = line.quantity - line.allocated_quantity() + + if quantity < 0: + quantity = 0 + + if items.count() == 1: + item = items.first() + initials['item'] = item + + # Reduce the quantity IF there is not enough stock + qmax = item.quantity - item.allocation_count() + + if qmax < quantity: + quantity = qmax + + initials['quantity'] = quantity + + return initials + + def get_form(self): + + form = super().get_form() + + line_id = form['line'].value() + + # If a line item has been specified, reduce the queryset for the stockitem accordingly + try: + line = SalesOrderLineItem.objects.get(pk=line_id) + + queryset = form.fields['item'].queryset + + # Ensure the part reference matches + queryset = queryset.filter(part=line.part) + + # Exclude StockItem which are already allocated to this order + allocated = [allocation.item.pk for allocation in line.allocations.all()] + + queryset = queryset.exclude(pk__in=allocated) + + form.fields['item'].queryset = queryset + + # Hide the 'line' field + form.fields['line'].widget = HiddenInput() + + except (ValueError, SalesOrderLineItem.DoesNotExist): + pass + + return form + + +class SalesOrderAllocationEdit(AjaxUpdateView): + + model = SalesOrderAllocation + form_class = order_forms.EditSalesOrderAllocationForm + ajax_form_title = _('Edit Allocation Quantity') + + def get_form(self): + form = super().get_form() + + # Prevent the user from editing particular fields + form.fields.pop('item') + form.fields.pop('line') + + return form + + +class SalesOrderAllocationDelete(AjaxDeleteView): + + model = SalesOrderAllocation + ajax_form_title = _("Remove allocation") + context_object_name = 'allocation' + ajax_template_name = "order/so_allocation_delete.html" diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index ca5b8f11c2..a03f11cbfa 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -39,9 +39,10 @@ from InvenTree.models import InvenTreeTree, InvenTreeAttachment from InvenTree.fields import InvenTreeURLField from InvenTree.helpers import decimal2string, normalize -from InvenTree.status_codes import BuildStatus, StockStatus, OrderStatus +from InvenTree.status_codes import BuildStatus, StockStatus, PurchaseOrderStatus from company.models import SupplierPart +from stock import models as StockModels class PartCategory(InvenTreeTree): @@ -639,11 +640,12 @@ class Part(models.Model): def stock_entries(self): """ Return all 'in stock' items. To be in stock: - - customer is None + - build_order is None + - sales_order is None - belongs_to is None """ - return self.stock_items.filter(customer=None, belongs_to=None) + return self.stock_items.filter(StockModels.StockItem.IN_STOCK_FILTER).exclude(status__in=StockStatus.UNAVAILABLE_CODES) @property def total_stock(self): @@ -824,6 +826,11 @@ class Part(models.Model): max_price = None for item in self.bom_items.all().select_related('sub_part'): + + if item.sub_part.pk == self.pk: + print("Warning: Item contains itself in BOM") + continue + prices = item.sub_part.get_price_range(quantity * item.quantity) if prices is None: @@ -924,6 +931,17 @@ class Part(models.Model): return n + def sales_orders(self): + """ Return a list of sales orders which reference this part """ + + orders = [] + + for line in self.sales_order_line_items.all().prefetch_related('order'): + if line.order not in orders: + orders.append(line.order) + + return orders + def purchase_orders(self): """ Return a list of purchase orders which reference this part """ @@ -939,18 +957,18 @@ class Part(models.Model): def open_purchase_orders(self): """ Return a list of open purchase orders against this part """ - return [order for order in self.purchase_orders() if order.status in OrderStatus.OPEN] + return [order for order in self.purchase_orders() if order.status in PurchaseOrderStatus.OPEN] def closed_purchase_orders(self): """ Return a list of closed purchase orders against this part """ - return [order for order in self.purchase_orders() if order.status not in OrderStatus.OPEN] + return [order for order in self.purchase_orders() if order.status not in PurchaseOrderStatus.OPEN] @property def on_order(self): """ Return the total number of items on order for this part. """ - orders = self.supplier_parts.filter(purchase_order_line_items__order__status__in=OrderStatus.OPEN).aggregate( + orders = self.supplier_parts.filter(purchase_order_line_items__order__status__in=PurchaseOrderStatus.OPEN).aggregate( quantity=Sum('purchase_order_line_items__quantity'), received=Sum('purchase_order_line_items__received') ) diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index a7305dcb2e..8cb3584664 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -15,7 +15,7 @@ from decimal import Decimal from django.db.models import Q, Sum from django.db.models.functions import Coalesce -from InvenTree.status_codes import StockStatus, OrderStatus, BuildStatus +from InvenTree.status_codes import StockStatus, PurchaseOrderStatus, BuildStatus from InvenTree.serializers import InvenTreeModelSerializer @@ -52,19 +52,19 @@ class PartThumbSerializer(serializers.Serializer): class PartBriefSerializer(InvenTreeModelSerializer): """ Serializer for Part (brief detail) """ - url = serializers.CharField(source='get_absolute_url', read_only=True) thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True) class Meta: model = Part fields = [ 'pk', - 'url', 'full_name', 'description', 'thumbnail', 'active', 'assembly', + 'purchaseable', + 'salable', 'virtual', ] @@ -118,7 +118,7 @@ class PartSerializer(InvenTreeModelSerializer): stock_filter = Q(stock_items__status__in=StockStatus.AVAILABLE_CODES) # Filter to limit orders to "open" - order_filter = Q(supplier_parts__purchase_order_line_items__order__status__in=OrderStatus.OPEN) + order_filter = Q(supplier_parts__purchase_order_line_items__order__status__in=PurchaseOrderStatus.OPEN) # Filter to limit builds to "active" build_filter = Q(builds__status__in=BuildStatus.ACTIVE_CODES) @@ -233,9 +233,13 @@ class PartStarSerializer(InvenTreeModelSerializer): class BomItemSerializer(InvenTreeModelSerializer): """ Serializer for BomItem object """ + price_range = serializers.CharField(read_only=True) + + quantity = serializers.FloatField() + part_detail = PartBriefSerializer(source='part', many=False, read_only=True) sub_part_detail = PartBriefSerializer(source='sub_part', many=False, read_only=True) - price_range = serializers.CharField(read_only=True) + validated = serializers.BooleanField(read_only=True, source='is_line_valid') def __init__(self, *args, **kwargs): diff --git a/InvenTree/part/templates/part/allocation.html b/InvenTree/part/templates/part/allocation.html index cab6c44022..77fa720403 100644 --- a/InvenTree/part/templates/part/allocation.html +++ b/InvenTree/part/templates/part/allocation.html @@ -18,7 +18,7 @@ {{ allocation.build.title }} {{ allocation.build.quantity }} × {{ allocation.build.part.full_name }} {{ allocation.quantity }} - {% build_status allocation.build.status %} + {% build_status_label allocation.build.status %} {% endfor %} diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html index 9382259cce..672edfb587 100644 --- a/InvenTree/part/templates/part/category.html +++ b/InvenTree/part/templates/part/category.html @@ -14,16 +14,16 @@

{% trans "All parts" %}

{% endif %}

-

- {% if category %} - - {% endif %}
diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index 2c3e5a2884..6d7400d4da 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -164,9 +164,8 @@ {% trans "Part can be purchased from external suppliers" %} {% endif %} - {% if 0 %} - {% trans "Sellable" %} + {% trans "Salable" %} {% include "slide.html" with state=part.salable field='salable' %} {% if part.salable %} {% trans "Part can be sold to customers" %} @@ -174,7 +173,6 @@ {% trans "Part cannot be sold to customers" %} {% endif %} - {% endif %}
diff --git a/InvenTree/part/templates/part/orders.html b/InvenTree/part/templates/part/orders.html index d53c6ca04f..8fe5e71688 100644 --- a/InvenTree/part/templates/part/orders.html +++ b/InvenTree/part/templates/part/orders.html @@ -1,17 +1,18 @@ {% extends "part/part_base.html" %} {% load static %} +{% load i18n %} {% block details %} {% include 'part/tabs.html' with tab='orders' %} -

Part Orders

+

{% trans "Purchase Orders" %}


- -
+ +
@@ -27,7 +28,10 @@ {{ block.super }} loadPurchaseOrderTable($("#purchase-order-table"), { - url: "{% url 'api-po-list' %}?part={{ part.id }}", + url: "{% url 'api-po-list' %}", + params: { + part: {{ part.id }}, + }, }); $("#part-order2").click(function() { diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index c4f0483ec2..4da8ec7ffe 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -6,11 +6,6 @@ {% block content %} -{% if part.active == False %} -
- {% trans "This part is not active" %} -
-{% endif %} {% if part.is_template %}
{% trans "This part is a template part." %} @@ -28,43 +23,48 @@
{% include "part/part_thumb.html" %}
-

+

{{ part.full_name }} -

+ {% if not part.active %} +
+ {% trans 'Inactive' %} +
+ {% endif %} +

{{ part.description }}

-
- {% if part.is_template == False %} {% include "qr_button.html" %} {% if part.active %} - {% if not part.virtual %} - {% endif %} {% if part.purchaseable %} - {% endif %} {% endif %} {% endif %} - - {% if not part.active %} - {% endif %}
diff --git a/InvenTree/part/templates/part/sales_orders.html b/InvenTree/part/templates/part/sales_orders.html new file mode 100644 index 0000000000..9e661d44e4 --- /dev/null +++ b/InvenTree/part/templates/part/sales_orders.html @@ -0,0 +1,36 @@ +{% extends "part/part_base.html" %} +{% load static %} +{% load i18n %} + +{% block details %} + +{% include 'part/tabs.html' with tab='sales-orders' %} + +

{% trans "Sales Orders" %}

+
+ +
+
+ +
+ +
+
+
+ + +
+ +{% endblock %} + +{% block js_ready %} +{{ block.super }} + +loadSalesOrderTable($("#sales-order-table"), { + url: "{% url 'api-so-list' %}", + params: { + part: {{ part.id }}, + }, +}); + +{% endblock %} diff --git a/InvenTree/part/templates/part/stock.html b/InvenTree/part/templates/part/stock.html index a692b11b01..03174f45c3 100644 --- a/InvenTree/part/templates/part/stock.html +++ b/InvenTree/part/templates/part/stock.html @@ -5,7 +5,7 @@ {% include 'part/tabs.html' with tab='stock' %} -

Part Stock

+

{% trans "Part Stock" %}


{% if part.is_template %} @@ -40,6 +40,7 @@ part: {{ part.id }}, location_detail: true, part_detail: true, + in_stock: true, }, groupByField: 'location', buttons: [ diff --git a/InvenTree/part/templates/part/tabs.html b/InvenTree/part/templates/part/tabs.html index b0cf14be5d..b47ebfe329 100644 --- a/InvenTree/part/templates/part/tabs.html +++ b/InvenTree/part/templates/part/tabs.html @@ -6,7 +6,7 @@ {% trans "Details" %} - {% trans "Parameters" %} {{ part.parameters.count }} + {% trans "Parameters" %}{{ part.parameters.count }} {% if part.is_template %} @@ -25,7 +25,7 @@ {% trans "BOM" %}{{ part.bom_count }} - {% trans "Build" %}{{ part.builds|length }} + {% trans "Build Orders" %}{{ part.builds.count }} {% endif %} {% if part.component or part.used_in_count > 0 %} @@ -43,7 +43,12 @@ {% trans "Purchase Orders" %} {{ part.purchase_orders|length }} {% endif %} - {% if part.trackable and 0 %} + {% if part.salable %} + + {% trans "Sales Orders" %} {{ part.sales_orders|length }} + + {% endif %} + {% if 0 and part.trackable %} {% trans "Tracking" %} {% if parts.serials.all|length > 0 %} @@ -55,6 +60,6 @@ {% trans "Attachments" %} {% if part.attachment_count > 0 %}{{ part.attachment_count }}{% endif %} - {% trans "Notes" %}{% if part.notes %} {% endif %} + {% trans "Notes" %}{% if part.notes %} {% endif %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/track.html b/InvenTree/part/templates/part/track.html index 9196715437..e3c47e4777 100644 --- a/InvenTree/part/templates/part/track.html +++ b/InvenTree/part/templates/part/track.html @@ -1,28 +1,12 @@ {% extends "part/part_base.html" %} - +{% load static %} +{% load i18n %} {% block details %} {% include 'part/tabs.html' with tab='track' %} -Part tracking for {{ part.full_name }} +

{% trans "Part Tracking" %}

- - - - - -{% for track in part.tracked_parts.all %} - - - - -{% endfor %} -
SerialStatus
{{ track.serial }}{{ track.get_status_display }}
- - +
{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templatetags/status_codes.py b/InvenTree/part/templatetags/status_codes.py index 18f84032ce..2d4dc77113 100644 --- a/InvenTree/part/templatetags/status_codes.py +++ b/InvenTree/part/templatetags/status_codes.py @@ -4,35 +4,31 @@ Provide templates for the various model status codes. from django import template from django.utils.safestring import mark_safe -from InvenTree.status_codes import OrderStatus, StockStatus, BuildStatus +from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus +from InvenTree.status_codes import StockStatus, BuildStatus register = template.Library() @register.simple_tag -def order_status(key, *args, **kwargs): - return mark_safe(OrderStatus.render(key)) +def purchase_order_status_label(key, *args, **kwargs): + """ Render a PurchaseOrder status label """ + return mark_safe(PurchaseOrderStatus.render(key, large=kwargs.get('large', False))) @register.simple_tag -def stock_status(key, *args, **kwargs): - return mark_safe(StockStatus.render(key)) +def sales_order_status_label(key, *args, **kwargs): + """ Render a SalesOrder status label """ + return mark_safe(SalesOrderStatus.render(key, large=kwargs.get('large', False))) @register.simple_tag -def build_status(key, *args, **kwargs): - return mark_safe(BuildStatus.render(key)) +def stock_status_label(key, *args, **kwargs): + """ Render a StockItem status label """ + return mark_safe(StockStatus.render(key, large=kwargs.get('large', False))) -@register.simple_tag(takes_context=True) -def load_status_codes(context): - """ - Make the various StatusCodes available to the page context - """ - - context['order_status_codes'] = OrderStatus.list() - context['stock_status_codes'] = StockStatus.list() - context['build_status_codes'] = BuildStatus.list() - - # Need to return something as the result is rendered to the page - return '' +@register.simple_tag +def build_status_label(key, *args, **kwargs): + """ Render a Build status label """ + return mark_safe(BuildStatus.render(key, large=kwargs.get('large', False))) diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index 0d376f8e5b..10db202fb9 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -51,6 +51,7 @@ part_detail_urls = [ url(r'^used/?', views.PartDetail.as_view(template_name='part/used_in.html'), name='part-used-in'), url(r'^suppliers/?', views.PartDetail.as_view(template_name='part/supplier.html'), name='part-suppliers'), url(r'^orders/?', views.PartDetail.as_view(template_name='part/orders.html'), name='part-orders'), + url(r'^sales-orders/', views.PartDetail.as_view(template_name='part/sales_orders.html'), name='part-sales-orders'), url(r'^track/?', views.PartDetail.as_view(template_name='part/track.html'), name='part-track'), url(r'^attachments/?', views.PartDetail.as_view(template_name='part/attachments.html'), name='part-attachments'), url(r'^notes/?', views.PartNotes.as_view(), name='part-notes'), diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index b36d097185..458696b75c 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -38,7 +38,6 @@ from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDelete from InvenTree.views import QRCodeView from InvenTree.helpers import DownloadFile, str2bool -from InvenTree.status_codes import OrderStatus, BuildStatus class PartIndex(ListView): @@ -561,8 +560,6 @@ class PartNotes(UpdateView): ctx['starred'] = part.isStarredBy(self.request.user) ctx['disabled'] = not part.active - ctx['OrderStatus'] = OrderStatus - return ctx @@ -593,9 +590,6 @@ class PartDetail(DetailView): context['starred'] = part.isStarredBy(self.request.user) context['disabled'] = not part.active - context['OrderStatus'] = OrderStatus - context['BuildStatus'] = BuildStatus - return context diff --git a/InvenTree/stock/admin.py b/InvenTree/stock/admin.py index 8c91518de0..e233908ec7 100644 --- a/InvenTree/stock/admin.py +++ b/InvenTree/stock/admin.py @@ -12,8 +12,8 @@ from .models import StockLocation, StockItem from .models import StockItemTracking from build.models import Build -from company.models import Company, SupplierPart -from order.models import PurchaseOrder +from company.models import SupplierPart +from order.models import PurchaseOrder, SalesOrder from part.models import Part @@ -74,10 +74,12 @@ class StockItemResource(ModelResource): belongs_to = Field(attribute='belongs_to', widget=widgets.ForeignKeyWidget(StockItem)) - customer = Field(attribute='customer', widget=widgets.ForeignKeyWidget(Company)) - build = Field(attribute='build', widget=widgets.ForeignKeyWidget(Build)) + sales_order = Field(attribute='sales_order', widget=widgets.ForeignKeyWidget(SalesOrder)) + + build_order = Field(attribute='build_order', widget=widgets.ForeignKeyWidget(Build)) + purchase_order = Field(attribute='purchase_order', widget=widgets.ForeignKeyWidget(PurchaseOrder)) # Date management diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 77b35f45d0..c31c1b8993 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -314,7 +314,6 @@ class StockList(generics.ListCreateAPIView): - POST: Create a new StockItem Additional query parameters are available: - - aggregate: If 'true' then stock items are aggregated by Part and Location - location: Filter stock by location - category: Filter by parts belonging to a certain category - supplier: Filter by supplier @@ -363,9 +362,31 @@ class StockList(generics.ListCreateAPIView): # Start with all objects stock_list = super().filter_queryset(queryset) - # Filter out parts which are not actually "in stock" - stock_list = stock_list.filter(customer=None, belongs_to=None) + in_stock = self.request.query_params.get('in_stock', None) + if in_stock is not None: + in_stock = str2bool(in_stock) + + if in_stock: + # Filter out parts which are not actually "in stock" + stock_list = stock_list.filter(StockItem.IN_STOCK_FILTER) + else: + # Only show parts which are not in stock + stock_list = stock_list.exclude(StockItem.IN_STOCK_FILTER) + + # Filter by 'allocated' patrs? + allocated = self.request.query_params.get('allocated', None) + + if allocated is not None: + allocated = str2bool(allocated) + + if allocated: + # Filter StockItem with either build allocations or sales order allocations + stock_list = stock_list.filter(Q(sales_order_allocations__isnull=False) | Q(allocations__isnull=False)) + else: + # Filter StockItem without build allocations or sales order allocations + stock_list = stock_list.filter(Q(sales_order_allocations__isnull=True) & Q(allocations__isnull=True)) + # Do we wish to filter by "active parts" active = self.request.query_params.get('active', None) @@ -387,7 +408,7 @@ class StockList(generics.ListCreateAPIView): stock_list = stock_list.filter(part=part_id) except (ValueError, Part.DoesNotExist): - pass + raise ValidationError({"part": "Invalid Part ID specified"}) # Does the client wish to filter by the 'ancestor'? anc_id = self.request.query_params.get('ancestor', None) @@ -400,12 +421,12 @@ class StockList(generics.ListCreateAPIView): stock_list = stock_list.filter(id__in=[item.pk for item in ancestor.children.all()]) except (ValueError, Part.DoesNotExist): - pass + raise ValidationError({"ancestor": "Invalid ancestor ID specified"}) # Does the client wish to filter by stock location? loc_id = self.request.query_params.get('location', None) - cascade = str2bool(self.request.query_params.get('cascade', False)) + cascade = str2bool(self.request.query_params.get('cascade', True)) if loc_id is not None: @@ -433,7 +454,7 @@ class StockList(generics.ListCreateAPIView): stock_list = stock_list.filter(part__category__in=category.getUniqueChildren()) except (ValueError, PartCategory.DoesNotExist): - pass + raise ValidationError({"category": "Invalid category id specified"}) # Filter by StockItem status status = self.request.query_params.get('status', None) @@ -490,9 +511,11 @@ class StockList(generics.ListCreateAPIView): filter_fields = [ 'supplier_part', - 'customer', 'belongs_to', 'build', + 'build_order', + 'sales_order', + 'build_order', ] diff --git a/InvenTree/stock/migrations/0027_stockitem_sales_order.py b/InvenTree/stock/migrations/0027_stockitem_sales_order.py new file mode 100644 index 0000000000..048609ae5f --- /dev/null +++ b/InvenTree/stock/migrations/0027_stockitem_sales_order.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.5 on 2020-04-21 05:03 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0023_auto_20200420_2309'), + ('stock', '0026_stockitem_uid'), + ] + + operations = [ + migrations.AddField( + model_name='stockitem', + name='sales_order', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stock_items', to='order.SalesOrderLineItem'), + ), + ] diff --git a/InvenTree/stock/migrations/0028_auto_20200421_0724.py b/InvenTree/stock/migrations/0028_auto_20200421_0724.py new file mode 100644 index 0000000000..61ebe97039 --- /dev/null +++ b/InvenTree/stock/migrations/0028_auto_20200421_0724.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.5 on 2020-04-21 07:24 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0027_stockitem_sales_order'), + ] + + operations = [ + migrations.RenameField( + model_name='stockitem', + old_name='sales_order', + new_name='sales_order_line', + ), + ] diff --git a/InvenTree/stock/migrations/0029_auto_20200421_2359.py b/InvenTree/stock/migrations/0029_auto_20200421_2359.py new file mode 100644 index 0000000000..1b89a9d143 --- /dev/null +++ b/InvenTree/stock/migrations/0029_auto_20200421_2359.py @@ -0,0 +1,19 @@ +# Generated by Django 3.0.5 on 2020-04-21 23:59 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0028_auto_20200421_0724'), + ] + + operations = [ + migrations.AlterField( + model_name='stockitem', + name='status', + field=models.PositiveIntegerField(choices=[(10, 'OK'), (50, 'Attention needed'), (55, 'Damaged'), (60, 'Destroyed'), (70, 'Lost'), (110, 'Shipped'), (85, 'Returned')], default=10, validators=[django.core.validators.MinValueValidator(0)]), + ), + ] diff --git a/InvenTree/stock/migrations/0030_auto_20200422_0015.py b/InvenTree/stock/migrations/0030_auto_20200422_0015.py new file mode 100644 index 0000000000..c720ac48ef --- /dev/null +++ b/InvenTree/stock/migrations/0030_auto_20200422_0015.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.5 on 2020-04-22 00:15 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0023_auto_20200420_2309'), + ('stock', '0029_auto_20200421_2359'), + ] + + operations = [ + migrations.AlterField( + model_name='stockitem', + name='sales_order_line', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stock_items', to='order.SalesOrderLineItem'), + ), + ] diff --git a/InvenTree/stock/migrations/0031_auto_20200422_0209.py b/InvenTree/stock/migrations/0031_auto_20200422_0209.py new file mode 100644 index 0000000000..1da143aac5 --- /dev/null +++ b/InvenTree/stock/migrations/0031_auto_20200422_0209.py @@ -0,0 +1,24 @@ +# Generated by Django 3.0.5 on 2020-04-22 02:09 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0024_salesorderallocation'), + ('stock', '0030_auto_20200422_0015'), + ] + + operations = [ + migrations.RemoveField( + model_name='stockitem', + name='sales_order_line', + ), + migrations.AddField( + model_name='stockitem', + name='sales_order', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stock_items', to='order.SalesOrder'), + ), + ] diff --git a/InvenTree/stock/migrations/0032_stockitem_build_order.py b/InvenTree/stock/migrations/0032_stockitem_build_order.py new file mode 100644 index 0000000000..849178c39c --- /dev/null +++ b/InvenTree/stock/migrations/0032_stockitem_build_order.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.5 on 2020-04-25 14:02 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('build', '0015_auto_20200425_1350'), + ('stock', '0031_auto_20200422_0209'), + ] + + operations = [ + migrations.AddField( + model_name='stockitem', + name='build_order', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stock_items', to='build.Build'), + ), + ] diff --git a/InvenTree/stock/migrations/0033_auto_20200426_0539.py b/InvenTree/stock/migrations/0033_auto_20200426_0539.py new file mode 100644 index 0000000000..214a66feeb --- /dev/null +++ b/InvenTree/stock/migrations/0033_auto_20200426_0539.py @@ -0,0 +1,19 @@ +# Generated by Django 3.0.5 on 2020-04-26 05:39 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0032_stockitem_build_order'), + ] + + operations = [ + migrations.AlterField( + model_name='stockitem', + name='status', + field=models.PositiveIntegerField(choices=[(10, 'OK'), (50, 'Attention needed'), (55, 'Damaged'), (60, 'Destroyed'), (70, 'Lost'), (85, 'Returned'), (110, 'Shipped'), (120, 'Used for Build'), (130, 'Installed in Stock Item')], default=10, validators=[django.core.validators.MinValueValidator(0)]), + ), + ] diff --git a/InvenTree/stock/migrations/0034_auto_20200426_0602.py b/InvenTree/stock/migrations/0034_auto_20200426_0602.py new file mode 100644 index 0000000000..4bf3171aa2 --- /dev/null +++ b/InvenTree/stock/migrations/0034_auto_20200426_0602.py @@ -0,0 +1,96 @@ +# Generated by Django 3.0.5 on 2020-04-26 06:02 + +import InvenTree.fields +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import markdownx.models +import mptt.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0030_auto_20200426_0551'), + ('build', '0016_auto_20200426_0551'), + ('part', '0035_auto_20200406_0045'), + ('company', '0021_remove_supplierpart_manufacturer_name'), + ('stock', '0033_auto_20200426_0539'), + ] + + operations = [ + migrations.RemoveField( + model_name='stockitem', + name='customer', + ), + migrations.AlterField( + model_name='stockitem', + name='batch', + field=models.CharField(blank=True, help_text='Batch code for this stock item', max_length=100, null=True, verbose_name='Batch Code'), + ), + migrations.AlterField( + model_name='stockitem', + name='belongs_to', + field=models.ForeignKey(blank=True, help_text='Is this item installed in another item?', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='owned_parts', to='stock.StockItem', verbose_name='Installed In'), + ), + migrations.AlterField( + model_name='stockitem', + name='build', + field=models.ForeignKey(blank=True, help_text='Build for this stock item', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='build_outputs', to='build.Build', verbose_name='Source Build'), + ), + migrations.AlterField( + model_name='stockitem', + name='build_order', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stock_items', to='build.Build', verbose_name='Destination Build Order'), + ), + migrations.AlterField( + model_name='stockitem', + name='link', + field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', max_length=125, verbose_name='External Link'), + ), + migrations.AlterField( + model_name='stockitem', + name='location', + field=mptt.fields.TreeForeignKey(blank=True, help_text='Where is this stock item located?', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='stock_items', to='stock.StockLocation', verbose_name='Stock Location'), + ), + migrations.AlterField( + model_name='stockitem', + name='notes', + field=markdownx.models.MarkdownxField(blank=True, help_text='Stock Item Notes', null=True, verbose_name='Notes'), + ), + migrations.AlterField( + model_name='stockitem', + name='parent', + field=mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='children', to='stock.StockItem', verbose_name='Parent Stock Item'), + ), + migrations.AlterField( + model_name='stockitem', + name='part', + field=models.ForeignKey(help_text='Base part', limit_choices_to={'active': True, 'is_template': False, 'virtual': False}, on_delete=django.db.models.deletion.CASCADE, related_name='stock_items', to='part.Part', verbose_name='Base Part'), + ), + migrations.AlterField( + model_name='stockitem', + name='purchase_order', + field=models.ForeignKey(blank=True, help_text='Purchase order for this stock item', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stock_items', to='order.PurchaseOrder', verbose_name='Source Purchase Order'), + ), + migrations.AlterField( + model_name='stockitem', + name='quantity', + field=models.DecimalField(decimal_places=5, default=1, max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Stock Quantity'), + ), + migrations.AlterField( + model_name='stockitem', + name='sales_order', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stock_items', to='order.SalesOrder', verbose_name='Destination Sales Order'), + ), + migrations.AlterField( + model_name='stockitem', + name='serial', + field=models.PositiveIntegerField(blank=True, help_text='Serial number for this item', null=True, verbose_name='Serial Number'), + ), + migrations.AlterField( + model_name='stockitem', + name='supplier_part', + field=models.ForeignKey(blank=True, help_text='Select a matching supplier part for this stock item', null=True, on_delete=django.db.models.deletion.SET_NULL, to='company.SupplierPart', verbose_name='Supplier Part'), + ), + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 51b61ff3fd..2261d8ad23 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -11,6 +11,8 @@ from django.core.exceptions import ValidationError from django.urls import reverse from django.db import models, transaction +from django.db.models import Sum, Q +from django.db.models.functions import Coalesce from django.core.validators import MinValueValidator from django.contrib.auth.models import User from django.db.models.signals import pre_delete @@ -28,7 +30,8 @@ from InvenTree.status_codes import StockStatus from InvenTree.models import InvenTreeTree from InvenTree.fields import InvenTreeURLField -from part.models import Part +from part import models as PartModels +from order.models import PurchaseOrder, SalesOrder class StockLocation(InvenTreeTree): @@ -126,8 +129,13 @@ class StockItem(MPTTModel): build: Link to a Build (if this stock item was created from a build) purchase_order: Link to a PurchaseOrder (if this stock item was created from a PurchaseOrder) infinite: If True this StockItem can never be exhausted + sales_order: Link to a SalesOrder object (if the StockItem has been assigned to a SalesOrder) + build_order: Link to a BuildOrder object (if the StockItem has been assigned to a BuildOrder) """ + # A Query filter which will be re-used in multiple places to determine if a StockItem is actually "in stock" + IN_STOCK_FILTER = Q(sales_order=None, build_order=None, belongs_to=None) + def save(self, *args, **kwargs): if not self.pk: add_note = True @@ -210,7 +218,7 @@ class StockItem(MPTTModel): raise ValidationError({ 'serial': _('A stock item with this serial number already exists') }) - except Part.DoesNotExist: + except PartModels.Part.DoesNotExist: pass def clean(self): @@ -223,6 +231,18 @@ class StockItem(MPTTModel): - Quantity must be 1 if the StockItem has a serial number """ + if self.status == StockStatus.SHIPPED and self.sales_order is None: + raise ValidationError({ + 'sales_order': "SalesOrder must be specified as status is marked as SHIPPED", + 'status': "Status cannot be marked as SHIPPED if the Customer is not set", + }) + + if self.status == StockStatus.ASSIGNED_TO_OTHER_ITEM and self.belongs_to is None: + raise ValidationError({ + 'belongs_to': "Belongs_to field must be specified as statis is marked as ASSIGNED_TO_OTHER_ITEM", + 'status': 'Status cannot be marked as ASSIGNED_TO_OTHER_ITEM if the belongs_to field is not set', + }) + # The 'supplier_part' field must point to the same part! try: if self.supplier_part is not None: @@ -256,7 +276,7 @@ class StockItem(MPTTModel): if self.part.is_template: raise ValidationError({'part': _('Stock item cannot be created for a template Part')}) - except Part.DoesNotExist: + except PartModels.Part.DoesNotExist: # This gets thrown if self.supplier_part is null # TODO - Find a test than can be perfomed... pass @@ -298,61 +318,104 @@ class StockItem(MPTTModel): uid = models.CharField(blank=True, max_length=128, help_text=("Unique identifier field")) - parent = TreeForeignKey('self', - on_delete=models.DO_NOTHING, - blank=True, null=True, - related_name='children') + parent = TreeForeignKey( + 'self', + verbose_name=_('Parent Stock Item'), + on_delete=models.DO_NOTHING, + blank=True, null=True, + related_name='children' + ) - part = models.ForeignKey('part.Part', on_delete=models.CASCADE, - related_name='stock_items', help_text=_('Base part'), - limit_choices_to={ - 'is_template': False, - 'active': True, - 'virtual': False - }) + part = models.ForeignKey( + 'part.Part', on_delete=models.CASCADE, + verbose_name=_('Base Part'), + related_name='stock_items', help_text=_('Base part'), + limit_choices_to={ + 'is_template': False, + 'active': True, + 'virtual': False + }) - supplier_part = models.ForeignKey('company.SupplierPart', blank=True, null=True, on_delete=models.SET_NULL, - help_text=_('Select a matching supplier part for this stock item')) + supplier_part = models.ForeignKey( + 'company.SupplierPart', blank=True, null=True, on_delete=models.SET_NULL, + verbose_name=_('Supplier Part'), + help_text=_('Select a matching supplier part for this stock item') + ) - location = TreeForeignKey(StockLocation, on_delete=models.DO_NOTHING, - related_name='stock_items', blank=True, null=True, - help_text=_('Where is this stock item located?')) + location = TreeForeignKey( + StockLocation, on_delete=models.DO_NOTHING, + verbose_name=_('Stock Location'), + related_name='stock_items', + blank=True, null=True, + help_text=_('Where is this stock item located?') + ) - belongs_to = models.ForeignKey('self', on_delete=models.DO_NOTHING, - related_name='owned_parts', blank=True, null=True, - help_text=_('Is this item installed in another item?')) + belongs_to = models.ForeignKey( + 'self', + verbose_name=_('Installed In'), + on_delete=models.DO_NOTHING, + related_name='owned_parts', blank=True, null=True, + help_text=_('Is this item installed in another item?') + ) - customer = models.ForeignKey('company.Company', on_delete=models.SET_NULL, - related_name='stockitems', blank=True, null=True, - help_text=_('Item assigned to customer?')) - - serial = models.PositiveIntegerField(blank=True, null=True, - help_text=_('Serial number for this item')) + serial = models.PositiveIntegerField( + verbose_name=_('Serial Number'), + blank=True, null=True, + help_text=_('Serial number for this item') + ) - link = InvenTreeURLField(max_length=125, blank=True, help_text=_("Link to external URL")) + link = InvenTreeURLField( + verbose_name=_('External Link'), + max_length=125, blank=True, + help_text=_("Link to external URL") + ) - batch = models.CharField(max_length=100, blank=True, null=True, - help_text=_('Batch code for this stock item')) + batch = models.CharField( + verbose_name=_('Batch Code'), + max_length=100, blank=True, null=True, + help_text=_('Batch code for this stock item') + ) - quantity = models.DecimalField(max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], default=1) + quantity = models.DecimalField( + verbose_name=_("Stock Quantity"), + max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], + default=1 + ) updated = models.DateField(auto_now=True, null=True) build = models.ForeignKey( 'build.Build', on_delete=models.SET_NULL, + verbose_name=_('Source Build'), blank=True, null=True, help_text=_('Build for this stock item'), related_name='build_outputs', ) purchase_order = models.ForeignKey( - 'order.PurchaseOrder', + PurchaseOrder, on_delete=models.SET_NULL, + verbose_name=_('Source Purchase Order'), related_name='stock_items', blank=True, null=True, help_text=_('Purchase order for this stock item') ) + sales_order = models.ForeignKey( + SalesOrder, + on_delete=models.SET_NULL, + verbose_name=_("Destination Sales Order"), + related_name='stock_items', + null=True, blank=True) + + build_order = models.ForeignKey( + 'build.Build', + on_delete=models.SET_NULL, + verbose_name=_("Destination Build Order"), + related_name='stock_items', + null=True, blank=True + ) + # last time the stock was checked / counted stocktake_date = models.DateField(blank=True, null=True) @@ -368,19 +431,73 @@ class StockItem(MPTTModel): choices=StockStatus.items(), validators=[MinValueValidator(0)]) - notes = MarkdownxField(blank=True, null=True, help_text=_('Stock Item Notes')) + notes = MarkdownxField( + blank=True, null=True, + verbose_name=_("Notes"), + help_text=_('Stock Item Notes') + ) # If stock item is incoming, an (optional) ETA field # expected_arrival = models.DateField(null=True, blank=True) infinite = models.BooleanField(default=False) + def is_allocated(self): + """ + Return True if this StockItem is allocated to a SalesOrder or a Build + """ + + # TODO - For now this only checks if the StockItem is allocated to a SalesOrder + # TODO - In future, once the "build" is working better, check this too + + if self.allocations.count() > 0: + return True + + if self.sales_order_allocations.count() > 0: + return True + + return False + + def build_allocation_count(self): + """ + Return the total quantity allocated to builds + """ + + query = self.allocations.aggregate(q=Coalesce(Sum('quantity'), Decimal(0))) + + return query['q'] + + def sales_order_allocation_count(self): + """ + Return the total quantity allocated to SalesOrders + """ + + query = self.sales_order_allocations.aggregate(q=Coalesce(Sum('quantity'), Decimal(0))) + + return query['q'] + + def allocation_count(self): + """ + Return the total quantity allocated to builds or orders + """ + + return self.build_allocation_count() + self.sales_order_allocation_count() + + def unallocated_quantity(self): + """ + Return the quantity of this StockItem which is *not* allocated + """ + + return max(self.quantity - self.allocation_count(), 0) + def can_delete(self): """ Can this stock item be deleted? It can NOT be deleted under the following circumstances: - Has child StockItems - Has a serial number and is tracked - Is installed inside another StockItem + - It has been assigned to a SalesOrder + - It has been assigned to a BuildOrder """ if self.child_count > 0: @@ -389,6 +506,12 @@ class StockItem(MPTTModel): if self.part.trackable and self.serial is not None: return False + if self.sales_order is not None: + return False + + if self.build_order is not None: + return False + return True @property @@ -406,7 +529,20 @@ class StockItem(MPTTModel): @property def in_stock(self): - if self.belongs_to or self.customer: + # Not 'in stock' if it has been installed inside another StockItem + if self.belongs_to is not None: + return False + + # Not 'in stock' if it has been sent to a customer + if self.sales_order is not None: + return False + + # Not 'in stock' if it has been allocated to a BuildOrder + if self.build_order is not None: + return False + + # Not 'in stock' if the status code makes it unavailable + if self.status in StockStatus.UNAVAILABLE_CODES: return False return True @@ -583,6 +719,9 @@ class StockItem(MPTTModel): # Remove the specified quantity from THIS stock item self.take_stock(quantity, user, 'Split {n} items into new stock item'.format(n=quantity)) + # Return a copy of the "new" stock item + return new_stock + @transaction.atomic def move(self, location, notes, user, **kwargs): """ Move part to a new location. @@ -605,6 +744,9 @@ class StockItem(MPTTModel): except InvalidOperation: return False + if not self.in_stock: + raise ValidationError(_("StockItem cannot be moved as it is not in stock")) + if quantity <= 0: return False diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 616755b11b..4e586b789e 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -34,6 +34,7 @@ class StockItemSerializerBrief(InvenTreeModelSerializer): location_name = serializers.CharField(source='location', read_only=True) part_name = serializers.CharField(source='part.full_name', read_only=True) + quantity = serializers.FloatField() class Meta: model = StockItem @@ -67,6 +68,8 @@ class StockItemSerializer(InvenTreeModelSerializer): 'supplier_part', 'supplier_part__supplier', 'supplier_part__manufacturer', + 'allocations', + 'sales_order_allocations', 'location', 'part', 'tracking_info', @@ -90,6 +93,9 @@ class StockItemSerializer(InvenTreeModelSerializer): tracking_items = serializers.IntegerField(source='tracking_info_count', read_only=True) + quantity = serializers.FloatField() + allocated = serializers.FloatField(source='allocation_count', read_only=True) + def __init__(self, *args, **kwargs): part_detail = kwargs.pop('part_detail', False) @@ -110,7 +116,10 @@ class StockItemSerializer(InvenTreeModelSerializer): class Meta: model = StockItem fields = [ + 'allocated', 'batch', + 'build_order', + 'belongs_to', 'in_stock', 'link', 'location', @@ -120,6 +129,7 @@ class StockItemSerializer(InvenTreeModelSerializer): 'part_detail', 'pk', 'quantity', + 'sales_order', 'serial', 'supplier_part', 'supplier_part_detail', diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 9785b78850..bfbc737181 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -1,205 +1,248 @@ -{% extends "stock/stock_app_base.html" %} +{% extends "two_column.html" %} {% load static %} {% load inventree_extras %} {% load status_codes %} {% load i18n %} -{% block content %} - -
-
-

{% trans "Stock Item Details" %}

- {% if item.serialized %} -

{{ item.part.full_name}} # {{ item.serial }}

- {% else %} -

{% decimal item.quantity %} × {{ item.part.full_name }}

- {% endif %} -

-

- {% include "qr_button.html" %} - {% if item.in_stock %} - {% if not item.serialized %} - - - - {% if item.part.trackable %} - - {% endif %} - {% endif %} - - - {% endif %} - - {% if item.can_delete %} - - {% endif %} -
-

- {% if item.serialized %} -
- {% trans "This stock item is serialized - it has a unique serial number and the quantity cannot be adjusted." %} -
- {% elif item.child_count > 0 %} -
- {% trans "This stock item cannot be deleted as it has child items" %} -
- {% elif item.delete_on_deplete %} -
- {% trans "This stock item will be automatically deleted when all stock is depleted." %} -
- {% endif %} - {% if item.parent %} -
- {% trans "This stock item was split from " %}{{ item.parent }} -
- {% endif %} -
- -
-
- - - - - - - - {% if item.belongs_to %} - - - - - - {% elif item.location %} - - - - - - {% endif %} - {% if item.uid %} - - - - - - {% endif %} - {% if item.serialized %} - - - - - - {% else %} - - - - - - {% endif %} - {% if item.batch %} - - - - - - {% endif %} - {% if item.build %} - - - - - - {% endif %} - {% if item.purchase_order %} - - - - - - {% endif %} - {% if item.customer %} - - - - - - {% endif %} - {% if item.link %} - - - - - {% endif %} - {% if item.supplier_part %} - - - - - - - - - - - {% endif %} - - - - - - - - - {% if item.stocktake_date %} - - {% else %} - - {% endif %} - - - - - - -
Part - {% include "hover_image.html" with image=item.part.image hover=True %} - {{ item.part.full_name }} -
{% trans "Belongs To" %}{{ item.belongs_to }}
{% trans "Location" %}{{ item.location.name }}
{% trans "Unique Identifier" %}{{ item.uid }}
{% trans "Serial Number" %}{{ item.serial }}
{% trans "Quantity" %}{% decimal item.quantity %} {% if item.part.units %}{{ item.part.units }}{% endif %}
{% trans "Batch" %}{{ item.batch }}
{% trans "Build" %}{{ item.build }}
{% trans "Purchase Order" %}{{ item.purchase_order }}
{% trans "Customer" %}{{ item.customer.name }}
- {% trans "External Link" %}{{ item.link }}
{% trans "Supplier" %}{{ item.supplier_part.supplier.name }}
{% trans "Supplier Part" %}{{ item.supplier_part.SKU }}
{% trans "Last Updated" %}{{ item.updated }}
{% trans "Last Stocktake" %}{{ item.stocktake_date }} {{ item.stocktake_user }}{% trans "No stocktake performed" %}
{% trans "Status" %}{% stock_status item.status %}
-
-
- - -
-
-{% block details %} - +{% block page_title %} +InvenTree | {% trans "Stock Item" %} - {{ item }} {% endblock %} + +{% block sidenav %} +
+{% endblock %} + +{% block pre_content %} +{% include 'stock/loc_link.html' with location=item.location %} + +{% for allocation in item.sales_order_allocations.all %} +
+ {% trans "This stock item is allocated to Sales Order" %} #{{ allocation.line.order.reference }} ({% trans "Quantity" %}: {% decimal allocation.quantity %}) +
+{% endfor %} + +{% for allocation in item.allocations.all %} +
+ {% trans "This stock item is allocated to Build" %} #{{ allocation.build.id }} ({% trans "Quantity" %}: {% decimal allocation.quantity %}) +
+{% endfor %} + +{% if item.serialized %} +
+ {% trans "This stock item is serialized - it has a unique serial number and the quantity cannot be adjusted." %} +
+{% elif item.child_count > 0 %} +
+ {% trans "This stock item cannot be deleted as it has child items" %} +
+{% elif item.delete_on_deplete and item.can_delete %} +
+ {% trans "This stock item will be automatically deleted when all stock is depleted." %} +
+{% endif %} + +{% endblock %} + +{% block thumbnail %} + +{% endblock %} + +{% block page_data %} +

+ {% trans "Stock Item" %} + {% stock_status_label item.status large=True %} +

+
+

+{% if item.serialized %} +{{ item.part.full_name}} # {{ item.serial }} +{% else %} +{% decimal item.quantity %} × {{ item.part.full_name }} +{% endif %} +

+ +
+ {% include "qr_button.html" %} + {% if item.in_stock %} + {% if not item.serialized %} + + + + {% if item.part.trackable %} + + {% endif %} + {% endif %} + + + {% endif %} + + {% if item.can_delete %} + + {% endif %}
{% endblock %} +{% block page_details %} +

{% trans "Stock Item Details" %}

+ + + + + + + + {% if item.belongs_to %} + + + + + + {% elif item.sales_order %} + + + + + + {% elif item.build_order %} + + + + + + {% elif item.location %} + + + + + + {% endif %} + {% if item.uid %} + + + + + + {% endif %} + {% if item.serialized %} + + + + + + {% else %} + + + + + + {% endif %} + {% if item.batch %} + + + + + + {% endif %} + {% if item.build %} + + + + + + {% endif %} + {% if item.purchase_order %} + + + + + + {% endif %} + {% if item.parent %} + + + + + + {% endif %} + {% if item.link %} + + + + + {% endif %} + {% if item.supplier_part %} + + + + + + + + + + + {% endif %} + + + + + + + + + {% if item.stocktake_date %} + + {% else %} + + {% endif %} + + + + + + +
Part + {% include "hover_image.html" with image=item.part.image hover=True %} + {{ item.part.full_name }} +
{% trans "Belongs To" %}{{ item.belongs_to }}
{% trans "Sales Order" %}{{ item.sales_order.reference }} - {{ item.sales_order.customer.name }}
{% trans "Build Order" %}{{ item.build_order }}
{% trans "Location" %}{{ item.location.name }}
{% trans "Unique Identifier" %}{{ item.uid }}
{% trans "Serial Number" %}{{ item.serial }}
{% trans "Quantity" %}{% decimal item.quantity %} {% if item.part.units %}{{ item.part.units }}{% endif %}
{% trans "Batch" %}{{ item.batch }}
{% trans "Build" %}{{ item.build }}
{% trans "Purchase Order" %}{{ item.purchase_order }}
{% trans "Parent Item" %}{% trans "Stock Item" %} #{{ item.parent.id }}
+ {% trans "External Link" %}{{ item.link }}
{% trans "Supplier" %}{{ item.supplier_part.supplier.name }}
{% trans "Supplier Part" %}{{ item.supplier_part.SKU }}
{% trans "Last Updated" %}{{ item.updated }}
{% trans "Last Stocktake" %}{{ item.stocktake_date }} {{ item.stocktake_user }}{% trans "No stocktake performed" %}
{% trans "Status" %}{% stock_status_label item.status %}
+{% endblock %} + {% block js_ready %} {{ block.super }} +loadTree("{% url 'api-stock-tree' %}", + "#stock-tree", + { + name: 'stock', + } + ); + + $("#toggle-stock-tree").click(function() { + toggleSideNav("#sidenav"); + return false; + }) + + initSideNav(); + $("#stock-serialize").click(function() { launchModalForm( "{% url 'stock-item-serialize' item.id %}", diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 7117e54c60..521e73bf7e 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -13,20 +13,20 @@

All stock items

{% endif %}

-

- {% if location %} {% include "qr_button.html" %} - {% endif %}
@@ -233,6 +233,7 @@ {% endif %} part_detail: true, location_detail: true, + in_stock: true, }, url: "{% url 'api-stock-list' %}", }); diff --git a/InvenTree/stock/templates/stock/stock_app_base.html b/InvenTree/stock/templates/stock/stock_app_base.html index 8f5f051003..7f31ac21b0 100644 --- a/InvenTree/stock/templates/stock/stock_app_base.html +++ b/InvenTree/stock/templates/stock/stock_app_base.html @@ -3,9 +3,7 @@ {% load i18n %} {% block page_title %} -{% if item %} -InvenTree | {% trans "Stock Item" %} - {{ item }} -{% elif location %} +{% if location %} InvenTree | {% trans "Stock Location" %} - {{ location }} {% else %} InvenTree | Stock diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 24c61fd89b..b6ec8bda85 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -258,9 +258,7 @@ class StockExport(AjaxView): stock_items = stock_items.filter(supplier_part=supplier_part) # Filter out stock items that are not 'in stock' - # TODO - This might need some more thought in the future... - stock_items = stock_items.filter(customer=None) - stock_items = stock_items.filter(belongs_to=None) + stock_items = stock_items.filter(StockItem.IN_STOCK_FILTER) # Pre-fetch related fields to reduce DB queries stock_items = stock_items.prefetch_related('part', 'supplier_part__supplier', 'location', 'purchase_order', 'build') @@ -314,7 +312,7 @@ class StockAdjust(AjaxView, FormMixin): """ # Start with all 'in stock' items - items = StockItem.objects.filter(customer=None, belongs_to=None) + items = StockItem.objects.filter(StockItem.IN_STOCK_FILTER) # Client provides a list of individual stock items if 'stock[]' in self.request.GET: @@ -731,7 +729,7 @@ class StockItemSerialize(AjaxUpdateView): if k in ['quantity', 'destination', 'serial_numbers']: form.errors[k] = messages[k] else: - form.non_field_errors = messages[k] + form.non_field_errors = [messages[k]] valid = False @@ -840,9 +838,12 @@ class StockItemCreate(AjaxCreateView): if part_id: try: part = Part.objects.get(pk=part_id) - initials['part'] = part - initials['location'] = part.get_default_location() - initials['supplier_part'] = part.default_supplier + # Check that the supplied part is 'valid' + if not part.is_template and part.active and not part.virtual: + initials['part'] = part + initials['location'] = part.get_default_location() + initials['supplier_part'] = part.default_supplier + except (ValueError, Part.DoesNotExist): pass diff --git a/InvenTree/templates/base.html b/InvenTree/templates/base.html index 8559e6d5f1..bf5fa4a1c3 100644 --- a/InvenTree/templates/base.html +++ b/InvenTree/templates/base.html @@ -107,6 +107,7 @@ InvenTree + diff --git a/InvenTree/templates/modal_form.html b/InvenTree/templates/modal_form.html index 98f1c49693..0a5ae59916 100644 --- a/InvenTree/templates/modal_form.html +++ b/InvenTree/templates/modal_form.html @@ -13,7 +13,11 @@ {% if form.non_field_errors %} {% endif %} {% endblock %} diff --git a/InvenTree/templates/navbar.html b/InvenTree/templates/navbar.html index c41ef3718f..bb5dcebad7 100644 --- a/InvenTree/templates/navbar.html +++ b/InvenTree/templates/navbar.html @@ -22,6 +22,7 @@ {% trans "Sell" %} diff --git a/InvenTree/templates/qr_button.html b/InvenTree/templates/qr_button.html index 7aafd834bc..cc10e0cd26 100644 --- a/InvenTree/templates/qr_button.html +++ b/InvenTree/templates/qr_button.html @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/InvenTree/templates/status_codes.html b/InvenTree/templates/status_codes.html index 36d5aa8b3e..029252a842 100644 --- a/InvenTree/templates/status_codes.html +++ b/InvenTree/templates/status_codes.html @@ -4,8 +4,8 @@ var {{ label }}Codes = { {% for opt in options %}'{{ opt.key }}': { key: '{{ opt.key }}', - value: '{{ opt.value }}',{% if opt.label %} - label: '{{ opt.label }}',{% endif %} + value: '{{ opt.value }}',{% if opt.color %} + label: 'label-{{ opt.color }}',{% endif %} },{% endfor %} }; @@ -18,18 +18,14 @@ function {{ label }}StatusDisplay(key) { key = String(key); - var label = {{ label }}Codes[key].label; - var value = {{ label }}Codes[key].value; if (value == null || value.length == 0) { value = key; } - // Label not found, return the original string - if (label == null || label.length == 0) { - return value; - } + // Select the label color + var label = {{ label }}Codes[key].label ?? ''; - return `${value}`; + return `${value}`; } diff --git a/InvenTree/templates/table_filters.html b/InvenTree/templates/table_filters.html index f976e977a6..b337b25ac8 100644 --- a/InvenTree/templates/table_filters.html +++ b/InvenTree/templates/table_filters.html @@ -1,13 +1,12 @@ {% load i18n %} {% load status_codes %} -{% load_status_codes %} -