diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index 2595f8b5c3..b5cecf764d 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -407,14 +407,16 @@ def DownloadFile(data, filename, content_type='application/text', inline=False): def extract_serial_numbers(serials, expected_quantity, next_number: int): - """ Attempt to extract serial numbers from an input string. - - Serial numbers must be integer values - - Serial numbers must be positive - - Serial numbers can be split by whitespace / newline / commma chars - - Serial numbers can be supplied as an inclusive range using hyphen char e.g. 10-20 - - Serial numbers can be defined as ~ for getting the next available serial number - - Serial numbers can be supplied as + for getting all expecteded numbers starting from - - Serial numbers can be supplied as + for getting numbers starting from + """ + Attempt to extract serial numbers from an input string: + + Requirements: + - Serial numbers can be either strings, or integers + - Serial numbers can be split by whitespace / newline / commma chars + - Serial numbers can be supplied as an inclusive range using hyphen char e.g. 10-20 + - Serial numbers can be defined as ~ for getting the next available serial number + - Serial numbers can be supplied as + for getting all expecteded numbers starting from + - Serial numbers can be supplied as + for getting numbers starting from Args: serials: input string with patterns @@ -428,17 +430,18 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int): if '~' in serials: serials = serials.replace('~', str(next_number)) + # Split input string by whitespace or comma (,) characters groups = re.split("[\s,]+", serials) numbers = [] errors = [] - # helpers - def number_add(n): - if n in numbers: - errors.append(_('Duplicate serial: {n}').format(n=n)) + # Helper function to check for duplicated numbers + def add_sn(sn): + if sn in numbers: + errors.append(_('Duplicate serial: {sn}').format(sn=sn)) else: - numbers.append(n) + numbers.append(sn) try: expected_quantity = int(expected_quantity) @@ -466,7 +469,7 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int): if a < b: for n in range(a, b + 1): - number_add(n) + add_sn(n) else: errors.append(_("Invalid group: {g}").format(g=group)) @@ -495,21 +498,20 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int): end = start + expected_quantity for n in range(start, end): - number_add(n) + add_sn(n) # no case else: errors.append(_("Invalid group: {g}").format(g=group)) - # Group should be a number + # At this point, we assume that the "group" is just a single serial value elif group: - # try conversion - try: - number = int(group) - except: - # seem like it is not a number - raise ValidationError(_(f"Invalid group {group}")) - number_add(number) + try: + # First attempt to add as an integer value + add_sn(int(group)) + except (ValueError): + # As a backup, add as a string value + add_sn(group) # No valid input group detected else: diff --git a/InvenTree/InvenTree/static/script/inventree/notification.js b/InvenTree/InvenTree/static/script/inventree/notification.js index f6bdf3bc57..c4816d4b5c 100644 --- a/InvenTree/InvenTree/static/script/inventree/notification.js +++ b/InvenTree/InvenTree/static/script/inventree/notification.js @@ -37,7 +37,6 @@ function showAlertOrCache(message, cache, options={}) { if (cache) { addCachedAlert(message, options); } else { - showMessage(message, options); } } @@ -82,6 +81,8 @@ function showMessage(message, options={}) { var timeout = options.timeout || 5000; + var target = options.target || $('#alerts'); + var details = ''; if (options.details) { @@ -111,7 +112,7 @@ function showMessage(message, options={}) { `; - $('#alerts').append(html); + target.append(html); // Remove the alert automatically after a specified period of time $(`#alert-${id}`).delay(timeout).slideUp(200, function() { diff --git a/InvenTree/InvenTree/status.py b/InvenTree/InvenTree/status.py index cc9df701c6..181f431574 100644 --- a/InvenTree/InvenTree/status.py +++ b/InvenTree/InvenTree/status.py @@ -14,6 +14,8 @@ from django_q.monitor import Stat from django.conf import settings +import InvenTree.ready + logger = logging.getLogger("inventree") @@ -56,6 +58,12 @@ def is_email_configured(): configured = True + if InvenTree.ready.isInTestMode(): + return False + + if InvenTree.ready.isImportingData(): + return False + if not settings.EMAIL_HOST: configured = False @@ -89,6 +97,14 @@ def check_system_health(**kwargs): result = True + if InvenTree.ready.isInTestMode(): + # Do not perform further checks if we are running unit tests + return False + + if InvenTree.ready.isImportingData(): + # Do not perform further checks if we are importing data + return False + if not is_worker_running(**kwargs): # pragma: no cover result = False logger.warning(_("Background worker check failed")) diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index b4314f6738..678f69d228 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -12,11 +12,14 @@ import common.models INVENTREE_SW_VERSION = "0.7.0 dev" # InvenTree API version -INVENTREE_API_VERSION = 26 +INVENTREE_API_VERSION = 27 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v27 -> 2022-02-28 + - Adds target_date field to individual line items for purchase orders and sales orders + v26 -> 2022-02-17 - Adds API endpoint for uploading a BOM file and extracting data diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index b5622d7ce8..66c61bd0d7 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -274,7 +274,7 @@ class POLineItemFilter(rest_filters.FilterSet): model = models.PurchaseOrderLineItem fields = [ 'order', - 'part' + 'part', ] pending = rest_filters.BooleanFilter(label='pending', method='filter_pending') @@ -391,6 +391,7 @@ class POLineItemList(generics.ListCreateAPIView): 'reference', 'SKU', 'total_price', + 'target_date', ] search_fields = [ @@ -401,11 +402,6 @@ class POLineItemList(generics.ListCreateAPIView): 'reference', ] - filter_fields = [ - 'order', - 'part' - ] - class POLineItemDetail(generics.RetrieveUpdateDestroyAPIView): """ @@ -703,6 +699,7 @@ class SOLineItemList(generics.ListCreateAPIView): 'part__name', 'quantity', 'reference', + 'target_date', ] search_fields = [ diff --git a/InvenTree/order/migrations/0062_auto_20220228_0321.py b/InvenTree/order/migrations/0062_auto_20220228_0321.py new file mode 100644 index 0000000000..7f67a827a2 --- /dev/null +++ b/InvenTree/order/migrations/0062_auto_20220228_0321.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.10 on 2022-02-28 03:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0061_merge_0054_auto_20211201_2139_0060_auto_20211129_1339'), + ] + + operations = [ + migrations.AddField( + model_name='purchaseorderlineitem', + name='target_date', + field=models.DateField(blank=True, help_text='Target shipping date for this line item', null=True, verbose_name='Target Date'), + ), + migrations.AddField( + model_name='salesorderlineitem', + name='target_date', + field=models.DateField(blank=True, help_text='Target shipping date for this line item', null=True, verbose_name='Target Date'), + ), + ] diff --git a/InvenTree/order/migrations/0063_alter_purchaseorderlineitem_unique_together.py b/InvenTree/order/migrations/0063_alter_purchaseorderlineitem_unique_together.py new file mode 100644 index 0000000000..1c73b6b437 --- /dev/null +++ b/InvenTree/order/migrations/0063_alter_purchaseorderlineitem_unique_together.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.10 on 2022-02-28 04:27 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0062_auto_20220228_0321'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='purchaseorderlineitem', + unique_together=set(), + ), + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 7f6192dfcd..f08880a882 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -398,12 +398,22 @@ class PurchaseOrder(Order): return self.lines.count() > 0 and self.pending_line_items().count() == 0 @transaction.atomic - def receive_line_item(self, line, location, quantity, user, status=StockStatus.OK, purchase_price=None, **kwargs): - """ Receive a line item (or partial line item) against this PO + def receive_line_item(self, line, location, quantity, user, status=StockStatus.OK, **kwargs): + """ + Receive a line item (or partial line item) against this PO """ + # Extract optional batch code for the new stock item + batch_code = kwargs.get('batch_code', '') + + # Extract optional list of serial numbers + serials = kwargs.get('serials', None) + + # Extract optional notes field notes = kwargs.get('notes', '') - barcode = kwargs.get('barcode', '') + + # Extract optional barcode field + barcode = kwargs.get('barcode', None) # Prevent null values for barcode if barcode is None: @@ -427,33 +437,45 @@ class PurchaseOrder(Order): # Create a new stock item if line.part and quantity > 0: - stock = stock_models.StockItem( - part=line.part.part, - supplier_part=line.part, - location=location, - quantity=quantity, - purchase_order=self, - status=status, - purchase_price=line.purchase_price, - uid=barcode - ) - stock.save(add_note=False) + # Determine if we should individually serialize the items, or not + if type(serials) is list and len(serials) > 0: + serialize = True + else: + serialize = False + serials = [None] - tracking_info = { - 'status': status, - 'purchaseorder': self.pk, - } + for sn in serials: - stock.add_tracking_entry( - StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER, - user, - notes=notes, - deltas=tracking_info, - location=location, - purchaseorder=self, - quantity=quantity - ) + stock = stock_models.StockItem( + part=line.part.part, + supplier_part=line.part, + location=location, + quantity=1 if serialize else quantity, + purchase_order=self, + status=status, + batch=batch_code, + serial=sn, + purchase_price=line.purchase_price, + uid=barcode + ) + + stock.save(add_note=False) + + tracking_info = { + 'status': status, + 'purchaseorder': self.pk, + } + + stock.add_tracking_entry( + StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER, + user, + notes=notes, + deltas=tracking_info, + location=location, + purchaseorder=self, + quantity=quantity + ) # Update the number of parts received against the particular line item line.received += quantity @@ -794,9 +816,18 @@ class OrderLineItem(models.Model): Attributes: quantity: Number of items + reference: Reference text (e.g. customer reference) for this line item note: Annotation for the item + target_date: An (optional) date for expected shipment of this line item. + """ """ + Query filter for determining if an individual line item is "overdue": + - Amount received is less than the required quantity + - Target date is not None + - Target date is in the past + """ + OVERDUE_FILTER = Q(received__lt=F('quantity')) & ~Q(target_date=None) & Q(target_date__lt=datetime.now().date()) class Meta: abstract = True @@ -813,6 +844,12 @@ class OrderLineItem(models.Model): notes = models.CharField(max_length=500, blank=True, verbose_name=_('Notes'), help_text=_('Line item notes')) + target_date = models.DateField( + blank=True, null=True, + verbose_name=_('Target Date'), + help_text=_('Target shipping date for this line item'), + ) + class PurchaseOrderLineItem(OrderLineItem): """ Model for a purchase order line item. @@ -824,7 +861,6 @@ class PurchaseOrderLineItem(OrderLineItem): class Meta: unique_together = ( - ('order', 'part', 'quantity', 'purchase_price') ) @staticmethod diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 5e78b3e3a3..2f4c1ea5df 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -5,12 +5,14 @@ JSON serializers for the Order API # -*- coding: utf-8 -*- from __future__ import unicode_literals +from decimal import Decimal + from django.utils.translation import ugettext_lazy as _ from django.core.exceptions import ValidationError as DjangoValidationError from django.db import models, transaction from django.db.models import Case, When, Value -from django.db.models import BooleanField, ExpressionWrapper, F +from django.db.models import BooleanField, ExpressionWrapper, F, Q from rest_framework import serializers from rest_framework.serializers import ValidationError @@ -26,7 +28,7 @@ from InvenTree.serializers import InvenTreeModelSerializer from InvenTree.serializers import InvenTreeDecimalField from InvenTree.serializers import InvenTreeMoneySerializer from InvenTree.serializers import ReferenceIndexingSerializerMixin -from InvenTree.status_codes import StockStatus +from InvenTree.status_codes import StockStatus, PurchaseOrderStatus, SalesOrderStatus import order.models @@ -126,6 +128,7 @@ class POLineItemSerializer(InvenTreeModelSerializer): Add some extra annotations to this queryset: - Total price = purchase_price * quantity + - "Overdue" status (boolean field) """ queryset = queryset.annotate( @@ -135,6 +138,15 @@ class POLineItemSerializer(InvenTreeModelSerializer): ) ) + queryset = queryset.annotate( + overdue=Case( + When( + Q(order__status__in=PurchaseOrderStatus.OPEN) & order.models.OrderLineItem.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()) + ), + default=Value(False, output_field=BooleanField()), + ) + ) + return queryset def __init__(self, *args, **kwargs): @@ -155,6 +167,8 @@ class POLineItemSerializer(InvenTreeModelSerializer): quantity = serializers.FloatField(default=1) received = serializers.FloatField(default=0) + overdue = serializers.BooleanField(required=False, read_only=True) + total_price = serializers.FloatField(read_only=True) part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True) @@ -185,6 +199,7 @@ class POLineItemSerializer(InvenTreeModelSerializer): 'notes', 'order', 'order_detail', + 'overdue', 'part', 'part_detail', 'supplier_part_detail', @@ -194,6 +209,7 @@ class POLineItemSerializer(InvenTreeModelSerializer): 'purchase_price_string', 'destination', 'destination_detail', + 'target_date', 'total_price', ] @@ -203,6 +219,17 @@ class POLineItemReceiveSerializer(serializers.Serializer): A serializer for receiving a single purchase order line item against a purchase order """ + class Meta: + fields = [ + 'barcode', + 'line_item', + 'location', + 'quantity', + 'status', + 'batch_code' + 'serial_numbers', + ] + line_item = serializers.PrimaryKeyRelatedField( queryset=order.models.PurchaseOrderLineItem.objects.all(), many=False, @@ -241,6 +268,22 @@ class POLineItemReceiveSerializer(serializers.Serializer): return quantity + batch_code = serializers.CharField( + label=_('Batch Code'), + help_text=_('Enter batch code for incoming stock items'), + required=False, + default='', + allow_blank=True, + ) + + serial_numbers = serializers.CharField( + label=_('Serial Numbers'), + help_text=_('Enter serial numbers for incoming stock items'), + required=False, + default='', + allow_blank=True, + ) + status = serializers.ChoiceField( choices=list(StockStatus.items()), default=StockStatus.OK, @@ -270,14 +313,35 @@ class POLineItemReceiveSerializer(serializers.Serializer): return barcode - class Meta: - fields = [ - 'barcode', - 'line_item', - 'location', - 'quantity', - 'status', - ] + def validate(self, data): + + data = super().validate(data) + + line_item = data['line_item'] + quantity = data['quantity'] + serial_numbers = data.get('serial_numbers', '').strip() + + base_part = line_item.part.part + + # Does the quantity need to be "integer" (for trackable parts?) + if base_part.trackable: + + if Decimal(quantity) != int(quantity): + raise ValidationError({ + 'quantity': _('An integer quantity must be provided for trackable parts'), + }) + + # If serial numbers are provided + if serial_numbers: + try: + # Pass the serial numbers through to the parent serializer once validated + data['serials'] = extract_serial_numbers(serial_numbers, quantity, base_part.getLatestSerialNumberInt()) + except DjangoValidationError as e: + raise ValidationError({ + 'serial_numbers': e.messages, + }) + + return data class POReceiveSerializer(serializers.Serializer): @@ -366,6 +430,8 @@ class POReceiveSerializer(serializers.Serializer): request.user, status=item['status'], barcode=item.get('barcode', ''), + batch_code=item.get('batch_code', ''), + serials=item.get('serials', None), ) except (ValidationError, DjangoValidationError) as exc: # Catch model errors and re-throw as DRF errors @@ -549,6 +615,23 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer): class SOLineItemSerializer(InvenTreeModelSerializer): """ Serializer for a SalesOrderLineItem object """ + @staticmethod + def annotate_queryset(queryset): + """ + Add some extra annotations to this queryset: + + - "Overdue" status (boolean field) + """ + + queryset = queryset.annotate( + overdue=Case( + When( + Q(order__status__in=SalesOrderStatus.OPEN) & order.models.OrderLineItem.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()), + ), + default=Value(False, output_field=BooleanField()), + ) + ) + def __init__(self, *args, **kwargs): part_detail = kwargs.pop('part_detail', False) @@ -570,6 +653,8 @@ class SOLineItemSerializer(InvenTreeModelSerializer): part_detail = PartBriefSerializer(source='part', many=False, read_only=True) allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True) + overdue = serializers.BooleanField(required=False, read_only=True) + quantity = InvenTreeDecimalField() allocated = serializers.FloatField(source='allocated_quantity', read_only=True) @@ -599,12 +684,14 @@ class SOLineItemSerializer(InvenTreeModelSerializer): 'notes', 'order', 'order_detail', + 'overdue', 'part', 'part_detail', 'sale_price', 'sale_price_currency', 'sale_price_string', 'shipped', + 'target_date', ] diff --git a/InvenTree/order/templates/order/purchase_order_detail.html b/InvenTree/order/templates/order/purchase_order_detail.html index d0215777bb..b9972d73fc 100644 --- a/InvenTree/order/templates/order/purchase_order_detail.html +++ b/InvenTree/order/templates/order/purchase_order_detail.html @@ -174,6 +174,7 @@ $('#new-po-line').click(function() { value: '{{ order.supplier.currency }}', {% endif %} }, + target_date: {}, destination: {}, notes: {}, }, @@ -210,7 +211,7 @@ $('#new-po-line').click(function() { loadPurchaseOrderLineItemTable('#po-line-table', { order: {{ order.pk }}, supplier: {{ order.supplier.pk }}, - {% if order.status == PurchaseOrderStatus.PENDING %} + {% if roles.purchase_order.change %} allow_edit: true, {% else %} allow_edit: false, diff --git a/InvenTree/order/templates/order/sales_order_detail.html b/InvenTree/order/templates/order/sales_order_detail.html index 48b2542752..3676268f5c 100644 --- a/InvenTree/order/templates/order/sales_order_detail.html +++ b/InvenTree/order/templates/order/sales_order_detail.html @@ -238,6 +238,7 @@ reference: {}, sale_price: {}, sale_price_currency: {}, + target_date: {}, notes: {}, }, method: 'POST', diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index 2ea2086431..b6fee8a218 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -529,6 +529,108 @@ class PurchaseOrderReceiveTest(OrderTest): self.assertTrue(StockItem.objects.filter(uid='MY-UNIQUE-BARCODE-123').exists()) self.assertTrue(StockItem.objects.filter(uid='MY-UNIQUE-BARCODE-456').exists()) + def test_batch_code(self): + """ + Test that we can supply a 'batch code' when receiving items + """ + + line_1 = models.PurchaseOrderLineItem.objects.get(pk=1) + line_2 = models.PurchaseOrderLineItem.objects.get(pk=2) + + self.assertEqual(StockItem.objects.filter(supplier_part=line_1.part).count(), 0) + self.assertEqual(StockItem.objects.filter(supplier_part=line_2.part).count(), 0) + + data = { + 'items': [ + { + 'line_item': 1, + 'quantity': 10, + 'batch_code': 'abc-123', + }, + { + 'line_item': 2, + 'quantity': 10, + 'batch_code': 'xyz-789', + } + ], + 'location': 1, + } + + n = StockItem.objects.count() + + self.post( + self.url, + data, + expected_code=201, + ) + + # Check that two new stock items have been created! + self.assertEqual(n + 2, StockItem.objects.count()) + + item_1 = StockItem.objects.filter(supplier_part=line_1.part).first() + item_2 = StockItem.objects.filter(supplier_part=line_2.part).first() + + self.assertEqual(item_1.batch, 'abc-123') + self.assertEqual(item_2.batch, 'xyz-789') + + def test_serial_numbers(self): + """ + Test that we can supply a 'serial number' when receiving items + """ + + line_1 = models.PurchaseOrderLineItem.objects.get(pk=1) + line_2 = models.PurchaseOrderLineItem.objects.get(pk=2) + + self.assertEqual(StockItem.objects.filter(supplier_part=line_1.part).count(), 0) + self.assertEqual(StockItem.objects.filter(supplier_part=line_2.part).count(), 0) + + data = { + 'items': [ + { + 'line_item': 1, + 'quantity': 10, + 'batch_code': 'abc-123', + 'serial_numbers': '100+', + }, + { + 'line_item': 2, + 'quantity': 10, + 'batch_code': 'xyz-789', + } + ], + 'location': 1, + } + + n = StockItem.objects.count() + + self.post( + self.url, + data, + expected_code=201, + ) + + # Check that the expected number of stock items has been created + self.assertEqual(n + 11, StockItem.objects.count()) + + # 10 serialized stock items created for the first line item + self.assertEqual(StockItem.objects.filter(supplier_part=line_1.part).count(), 10) + + # Check that the correct serial numbers have been allocated + for i in range(100, 110): + item = StockItem.objects.get(serial_int=i) + self.assertEqual(item.serial, str(i)) + self.assertEqual(item.quantity, 1) + self.assertEqual(item.batch, 'abc-123') + + # A single stock item (quantity 10) created for the second line item + items = StockItem.objects.filter(supplier_part=line_2.part) + self.assertEqual(items.count(), 1) + + item = items.first() + + self.assertEqual(item.quantity, 10) + self.assertEqual(item.batch, 'xyz-789') + class SalesOrderTest(OrderTest): """ diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html index 4de0005672..e02c77509d 100644 --- a/InvenTree/part/templates/part/category.html +++ b/InvenTree/part/templates/part/category.html @@ -313,6 +313,10 @@ fields: fields, groups: partGroups(), title: '{% trans "Create Part" %}', + reloadFormAfterSuccess: true, + persist: true, + persistMessage: '{% trans "Create another part after this one" %}', + successMessage: '{% trans "Part created successfully" %}', onSuccess: function(data) { // Follow the new part location.href = `/part/${data.pk}/`; diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 9ee3dcace3..88c9c5badb 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -542,6 +542,11 @@ function constructFormBody(fields, options) { insertConfirmButton(options); } + // Insert "persist" button (if required) + if (options.persist) { + insertPersistButton(options); + } + // Display the modal $(modal).modal('show'); @@ -616,6 +621,22 @@ function insertConfirmButton(options) { } +/* Add a checkbox to select if the modal will stay open after success */ +function insertPersistButton(options) { + + var message = options.persistMessage || '{% trans "Keep this form open" %}'; + + var html = ` +
+ + +
+ `; + + $(options.modal).find('#modal-footer-buttons').append(html); +} + + /* * Extract all specified form values as a single object */ @@ -934,19 +955,40 @@ function getFormFieldValue(name, field={}, options={}) { */ function handleFormSuccess(response, options) { - // Close the modal - if (!options.preventClose) { - // Note: The modal will be deleted automatically after closing - $(options.modal).modal('hide'); - } - // Display any required messages // Should we show alerts immediately or cache them? var cache = (options.follow && response.url) || options.redirect || options.reload; + // Should the form "persist"? + var persist = false; + + if (options.persist && options.modal) { + // Determine if this form should "persist", or be dismissed? + var chk = $(options.modal).find('#modal-persist'); + + persist = chk.exists() && chk.prop('checked'); + } + + if (persist) { + cache = false; + } + + var msg_target = null; + + if (persist) { + // If the modal is persistant, the target for any messages should be the modal! + msg_target = $(options.modal).find('#pre-form-content'); + } + // Display any messages if (response && (response.success || options.successMessage)) { - showAlertOrCache(response.success || options.successMessage, cache, {style: 'success'}); + showAlertOrCache( + response.success || options.successMessage, + cache, + { + style: 'success', + target: msg_target, + }); } if (response && response.info) { @@ -961,20 +1003,41 @@ function handleFormSuccess(response, options) { showAlertOrCache(response.danger, cache, {style: 'danger'}); } - if (options.onSuccess) { - // Callback function - options.onSuccess(response, options); - } + if (persist) { + // Instead of closing the form and going somewhere else, + // reload (empty) the form so the user can input more data + + // Reset the status of the "submit" button + if (options.modal) { + $(options.modal).find('#modal-form-submit').prop('disabled', false); + } - if (options.follow && response.url) { - // Follow the returned URL - window.location.href = response.url; - } else if (options.reload) { - // Reload the current page - location.reload(); - } else if (options.redirect) { - // Redirect to a specified URL - window.location.href = options.redirect; + // Remove any error flags from the form + clearFormErrors(options); + + } else { + + // Close the modal + if (!options.preventClose) { + // Note: The modal will be deleted automatically after closing + $(options.modal).modal('hide'); + } + + if (options.onSuccess) { + // Callback function + options.onSuccess(response, options); + } + + if (options.follow && response.url) { + // Follow the returned URL + window.location.href = response.url; + } else if (options.reload) { + // Reload the current page + location.reload(); + } else if (options.redirect) { + // Redirect to a specified URL + window.location.href = options.redirect; + } } } @@ -988,6 +1051,8 @@ function clearFormErrors(options={}) { if (options && options.modal) { // Remove the individual error messages $(options.modal).find('.form-error-message').remove(); + + $(options.modal).find('.modal-content').removeClass('modal-error'); // Remove the "has error" class $(options.modal).find('.form-field-error').removeClass('form-field-error'); @@ -1884,7 +1949,7 @@ function getFieldName(name, options={}) { * - Field description (help text) * - Field errors */ -function constructField(name, parameters, options) { +function constructField(name, parameters, options={}) { var html = ''; @@ -1976,7 +2041,7 @@ function constructField(name, parameters, options) { html += `
`; // Does this input deserve "extra" decorators? - var extra = parameters.prefix != null; + var extra = (parameters.icon != null) || (parameters.prefix != null) || (parameters.prefixRaw != null); // Some fields can have 'clear' inputs associated with them if (!parameters.required && !parameters.read_only) { @@ -1998,9 +2063,13 @@ function constructField(name, parameters, options) { if (extra) { html += `
`; - + if (parameters.prefix) { html += `${parameters.prefix}`; + } else if (parameters.prefixRaw) { + html += parameters.prefixRaw; + } else if (parameters.icon) { + html += ``; } } @@ -2147,6 +2216,10 @@ function constructInputOptions(name, classes, type, parameters, options={}) { opts.push(`type='${type}'`); + if (parameters.title || parameters.help_text) { + opts.push(`title='${parameters.title || parameters.help_text}'`); + } + // Read only? if (parameters.read_only) { opts.push(`readonly=''`); @@ -2192,11 +2265,6 @@ function constructInputOptions(name, classes, type, parameters, options={}) { opts.push(`required=''`); } - // Custom mouseover title? - if (parameters.title != null) { - opts.push(`title='${parameters.title}'`); - } - // Placeholder? if (parameters.placeholder != null) { opts.push(`placeholder='${parameters.placeholder}'`); diff --git a/InvenTree/templates/js/translated/helpers.js b/InvenTree/templates/js/translated/helpers.js index 2d35513e78..1925cb47ac 100644 --- a/InvenTree/templates/js/translated/helpers.js +++ b/InvenTree/templates/js/translated/helpers.js @@ -116,6 +116,10 @@ function makeIconButton(icon, cls, pk, title, options={}) { extraProps += `disabled='true' `; } + if (options.collapseTarget) { + extraProps += `data-bs-toggle='collapse' href='#${options.collapseTarget}'`; + } + html += ``; diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 1f5306b7f8..1fddce6efc 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -476,6 +476,19 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { quantity = 0; } + // Prepend toggles to the quantity input + var toggle_batch = ` + + + + `; + + var toggle_serials = ` + + + + `; + // Quantity to Receive var quantity_input = constructField( `items_quantity_${pk}`, @@ -491,6 +504,36 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { } ); + // Add in options for "batch code" and "serial numbers" + var batch_input = constructField( + `items_batch_code_${pk}`, + { + type: 'string', + required: false, + label: '{% trans "Batch Code" %}', + help_text: '{% trans "Enter batch code for incoming stock items" %}', + prefixRaw: toggle_batch, + } + ); + + var sn_input = constructField( + `items_serial_numbers_${pk}`, + { + type: 'string', + required: false, + label: '{% trans "Serial Numbers" %}', + help_text: '{% trans "Enter serial numbers for incoming stock items" %}', + prefixRaw: toggle_serials, + } + ); + + // Hidden inputs below the "quantity" field + var quantity_input_group = `${quantity_input}
${batch_input}
`; + + if (line_item.part_detail.trackable) { + quantity_input_group += `
${sn_input}
`; + } + // Construct list of StockItem status codes var choices = []; @@ -528,16 +571,38 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { ); // Button to remove the row - var delete_button = `
`; + var buttons = `
`; - delete_button += makeIconButton( + buttons += makeIconButton( + 'fa-layer-group', + 'button-row-add-batch', + pk, + '{% trans "Add batch code" %}', + { + collapseTarget: `div-batch-${pk}` + } + ); + + if (line_item.part_detail.trackable) { + buttons += makeIconButton( + 'fa-hashtag', + 'button-row-add-serials', + pk, + '{% trans "Add serial numbers" %}', + { + collapseTarget: `div-serials-${pk}`, + } + ); + } + + buttons += makeIconButton( 'fa-times icon-red', 'button-row-remove', pk, '{% trans "Remove row" %}', ); - delete_button += '
'; + buttons += '
'; var html = ` @@ -554,7 +619,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { ${line_item.received} - ${quantity_input} + ${quantity_input_group} ${status_input} @@ -563,7 +628,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { ${destination_input} - ${delete_button} + ${buttons} `; @@ -587,7 +652,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { {% trans "Order Code" %} {% trans "Ordered" %} {% trans "Received" %} - {% trans "Receive" %} + {% trans "Quantity to Receive" %} {% trans "Status" %} {% trans "Destination" %} @@ -678,13 +743,23 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { var location = getFormFieldValue(`items_location_${pk}`, {}, opts); if (quantity != null) { - data.items.push({ + + var line = { line_item: pk, quantity: quantity, status: status, location: location, - }); + }; + if (getFormFieldElement(`items_batch_code_${pk}`).exists()) { + line.batch_code = getFormFieldValue(`items_batch_code_${pk}`); + } + + if (getFormFieldElement(`items_serial_numbers_${pk}`).exists()) { + line.serial_numbers = getFormFieldValue(`items_serial_numbers_${pk}`); + } + + data.items.push(line); item_pk_values.push(pk); } @@ -936,6 +1011,7 @@ function loadPurchaseOrderLineItemTable(table, options={}) { reference: {}, purchase_price: {}, purchase_price_currency: {}, + target_date: {}, destination: {}, notes: {}, }, @@ -977,7 +1053,11 @@ function loadPurchaseOrderLineItemTable(table, options={}) { ], { success: function() { + // Reload the line item table $(table).bootstrapTable('refresh'); + + // Reload the "received stock" table + $('#stock-table').bootstrapTable('refresh'); } } ); @@ -1117,6 +1197,28 @@ function loadPurchaseOrderLineItemTable(table, options={}) { return formatter.format(total); } }, + { + sortable: true, + field: 'target_date', + switchable: true, + title: '{% trans "Target Date" %}', + formatter: function(value, row) { + if (row.target_date) { + var html = row.target_date; + + if (row.overdue) { + html += ``; + } + + return html; + + } else if (row.order_detail && row.order_detail.target_date) { + return `${row.order_detail.target_date}`; + } else { + return '-'; + } + } + }, { sortable: false, field: 'received', @@ -1163,15 +1265,15 @@ function loadPurchaseOrderLineItemTable(table, options={}) { var pk = row.pk; + if (options.allow_receive && row.received < row.quantity) { + html += makeIconButton('fa-sign-in-alt icon-green', 'button-line-receive', pk, '{% trans "Receive line item" %}'); + } + if (options.allow_edit) { html += makeIconButton('fa-edit icon-blue', 'button-line-edit', pk, '{% trans "Edit line item" %}'); html += makeIconButton('fa-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete line item" %}'); } - if (options.allow_receive && row.received < row.quantity) { - html += makeIconButton('fa-sign-in-alt', 'button-line-receive', pk, '{% trans "Receive line item" %}'); - } - html += `
`; return html; @@ -2223,6 +2325,28 @@ function loadSalesOrderLineItemTable(table, options={}) { return formatter.format(total); } }, + { + field: 'target_date', + title: '{% trans "Target Date" %}', + sortable: true, + switchable: true, + formatter: function(value, row) { + if (row.target_date) { + var html = row.target_date; + + if (row.overdue) { + html += ``; + } + + return html; + + } else if (row.order_detail && row.order_detail.target_date) { + return `${row.order_detail.target_date}`; + } else { + return '-'; + } + } + } ]; if (pending) { @@ -2366,6 +2490,7 @@ function loadSalesOrderLineItemTable(table, options={}) { reference: {}, sale_price: {}, sale_price_currency: {}, + target_date: {}, notes: {}, }, title: '{% trans "Edit Line Item" %}', diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 093aec388b..56b1ca6b75 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -905,6 +905,28 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) { field: 'quantity', title: '{% trans "Quantity" %}', }, + { + field: 'target_date', + title: '{% trans "Target Date" %}', + switchable: true, + sortable: true, + formatter: function(value, row) { + if (row.target_date) { + var html = row.target_date; + + if (row.overdue) { + html += ``; + } + + return html; + + } else if (row.order_detail && row.order_detail.target_date) { + return `${row.order_detail.target_date}`; + } else { + return '-'; + } + } + }, { field: 'received', title: '{% trans "Received" %}',