diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 302c7d4a6913..6223cfeb5580 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,14 +10,14 @@ exclude: | )$ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v5.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: mixed-line-ending - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.0 + rev: v0.7.1 hooks: - id: ruff-format args: [--preview] @@ -28,7 +28,7 @@ repos: --preview ] - repo: https://github.com/astral-sh/uv-pre-commit - rev: 0.4.24 + rev: 0.4.28 hooks: - id: pip-compile name: pip-compile requirements-dev.in @@ -51,7 +51,7 @@ repos: args: [contrib/container/requirements.in, -o, contrib/container/requirements.txt, --python-version=3.11, --no-strip-extras, --generate-hashes] files: contrib/container/requirements\.(in|txt)$ - repo: https://github.com/Riverside-Healthcare/djLint - rev: v1.35.2 + rev: v1.35.3 hooks: - id: djlint-django - repo: https://github.com/codespell-project/codespell @@ -78,7 +78,7 @@ repos: - "prettier@^2.4.1" - "@trivago/prettier-plugin-sort-imports" - repo: https://github.com/pre-commit/mirrors-eslint - rev: "v9.12.0" + rev: "v9.13.0" hooks: - id: eslint additional_dependencies: @@ -90,7 +90,7 @@ repos: - "@typescript-eslint/parser" files: ^src/frontend/.*\.(js|jsx|ts|tsx)$ - repo: https://github.com/gitleaks/gitleaks - rev: v8.21.0 + rev: v8.21.2 hooks: - id: gitleaks #- repo: https://github.com/jumanjihouse/pre-commit-hooks diff --git a/src/backend/InvenTree/order/admin.py b/src/backend/InvenTree/order/admin.py index a26d7499a3b8..5fedce770f8b 100644 --- a/src/backend/InvenTree/order/admin.py +++ b/src/backend/InvenTree/order/admin.py @@ -332,6 +332,18 @@ class Meta: clean_model_instances = True +class ReturnOrderPartLineItemResource(PriceResourceMixin, InvenTreeResource): + """Class for managing import / export of ReturnOrderPartLineItem data.""" + + class Meta: + """Metaclass options.""" + + model = models.ReturnOrderPartLineItem + skip_unchanged = True + report_skipped = False + clean_model_instances = True + + class ReturnOrderLineItemAdmin(ImportExportModelAdmin): """Admin class for ReturnOrderLine model.""" @@ -340,6 +352,14 @@ class ReturnOrderLineItemAdmin(ImportExportModelAdmin): list_display = ['order', 'item', 'reference'] +class ReturnOrderPartLineItemAdmin(ImportExportModelAdmin): + """Admin class for ReturnOrderPartLine model.""" + + resource_class = ReturnOrderPartLineItemResource + + list_display = ['order', 'part', 'reference'] + + class ReturnOrderExtraLineClass(PriceResourceMixin, InvenTreeResource): """Class for managing import/export of ReturnOrderExtraLine data.""" @@ -370,4 +390,5 @@ class ReturnOrdeerExtraLineAdmin(GeneralExtraLineAdmin, ImportExportModelAdmin): # Return Order models admin.site.register(models.ReturnOrder, ReturnOrderAdmin) admin.site.register(models.ReturnOrderLineItem, ReturnOrderLineItemAdmin) +admin.site.register(models.ReturnOrderPartLineItem, ReturnOrderPartLineItemAdmin) admin.site.register(models.ReturnOrderExtraLine, ReturnOrdeerExtraLineAdmin) diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index 5e3c05d09b92..1be4fe79d08a 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -1356,6 +1356,13 @@ class ReturnOrderReceive(ReturnOrderContextMixin, CreateAPI): serializer_class = serializers.ReturnOrderReceiveSerializer +class ReturnOrderReceiveParts(ReturnOrderContextMixin, CreateAPI): + """API endpoint to receive parts against a ReturnOrder.""" + + queryset = models.ReturnOrder.objects.none() + serializer_class = serializers.ReturnOrderReceivePartsSerializer + + class ReturnOrderLineItemFilter(LineItemFilter): """Custom filters for the ReturnOrderLineItemList endpoint.""" @@ -1377,6 +1384,27 @@ def filter_received(self, queryset, name, value): return queryset.filter(received_date=None) +class ReturnOrderPartLineItemFilter(LineItemFilter): + """Custom filters for the ReturnOrderPartLineItemList endpoint.""" + + class Meta: + """Metaclass options.""" + + price_field = 'price' + model = models.ReturnOrderPartLineItem + fields = ['order', 'part'] + + outcome = rest_filters.NumberFilter(label='outcome') + + received = rest_filters.BooleanFilter(label='received', method='filter_received') + + def filter_received(self, queryset, name, value): + """Filter by 'received' field.""" + if str2bool(value): + return queryset.exclude(received_date=None) + return queryset.filter(received_date=None) + + class ReturnOrderLineItemMixin: """Mixin class for ReturnOrderLineItem endpoints.""" @@ -1407,6 +1435,35 @@ def get_queryset(self, *args, **kwargs): return queryset +class ReturnOrderPartLineItemMixin: + """Mixin class for ReturnOrderPartLineItem endpoints.""" + + queryset = models.ReturnOrderPartLineItem.objects.all() + serializer_class = serializers.ReturnOrderPartLineItemSerializer + + def get_serializer(self, *args, **kwargs): + """Return serializer for this endpoint with extra data as requested.""" + try: + params = self.request.query_params + + kwargs['order_detail'] = str2bool(params.get('order_detail', False)) + kwargs['part_detail'] = str2bool(params.get('part_detail', False)) + except AttributeError: + pass + + kwargs['context'] = self.get_serializer_context() + + return self.serializer_class(*args, **kwargs) + + def get_queryset(self, *args, **kwargs): + """Return annotated queryset for this endpoint.""" + queryset = super().get_queryset(*args, **kwargs) + + queryset = queryset.prefetch_related('order', 'part') + + return queryset + + class ReturnOrderLineItemList( ReturnOrderLineItemMixin, DataExportViewMixin, ListCreateAPI ): @@ -1426,10 +1483,30 @@ class ReturnOrderLineItemList( ] +class ReturnOrderPartLineItemList( + ReturnOrderPartLineItemMixin, DataExportViewMixin, ListCreateAPI +): + """API endpoint for accessing a list of ReturnOrderPartLineItemList objects.""" + + filterset_class = ReturnOrderPartLineItemFilter + + filter_backends = SEARCH_ORDER_FILTER + + ordering_fields = ['reference', 'target_date', 'received_date'] + + search_fields = ['part__name', 'part__description', 'reference'] + + class ReturnOrderLineItemDetail(ReturnOrderLineItemMixin, RetrieveUpdateDestroyAPI): """API endpoint for detail view of a ReturnOrderLineItem object.""" +class ReturnOrderPartLineItemDetail( + ReturnOrderPartLineItemMixin, RetrieveUpdateDestroyAPI +): + """API endpoint for detail view of a ReturnOrderLineItem object.""" + + class ReturnOrderExtraLineList(GeneralExtraLineList, ListCreateAPI): """API endpoint for accessing a list of ReturnOrderExtraLine objects.""" @@ -1863,6 +1940,11 @@ def item_link(self, item): ReturnOrderReceive.as_view(), name='api-return-order-receive', ), + path( + 'receive-parts/', + ReturnOrderReceiveParts.as_view(), + name='api-return-order-receive-parts', + ), path( 'metadata/', MetadataView.as_view(), @@ -1917,6 +1999,40 @@ def item_link(self, item): ), ]), ), + # API endpoints for return order part lines + path( + 'ro-part-line/', + include([ + path( + '/', + include([ + path( + 'metadata/', + MetadataView.as_view(), + {'model': models.ReturnOrderPartLineItem}, + name='api-return-order-part-line-metadata', + ), + path( + '', + ReturnOrderPartLineItemDetail.as_view(), + name='api-return-order-part-line-detail', + ), + ]), + ), + # Return order part line item status code information + path( + 'status/', + StatusView.as_view(), + {StatusView.MODEL_REF: ReturnOrderLineStatus}, + name='api-return-order-part-line-status-codes', + ), + path( + '', + ReturnOrderPartLineItemList.as_view(), + name='api-return-order-part-line-list', + ), + ]), + ), # API endpoints for return order extra line path( 'ro-extra-line/', diff --git a/src/backend/InvenTree/order/migrations/0102_returnorderpartlineitem.py b/src/backend/InvenTree/order/migrations/0102_returnorderpartlineitem.py new file mode 100644 index 000000000000..574cbda879e0 --- /dev/null +++ b/src/backend/InvenTree/order/migrations/0102_returnorderpartlineitem.py @@ -0,0 +1,45 @@ +# Generated by Django 4.2.16 on 2024-10-31 01:20 + +import InvenTree.fields +import InvenTree.models +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import djmoney.models.fields +import djmoney.models.validators +import generic.states.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0130_alter_parttesttemplate_part'), + ('order', '0101_purchaseorder_status_custom_key_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ReturnOrderPartLineItem', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('metadata', models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata')), + ('reference', models.CharField(blank=True, help_text='Line item reference', max_length=100, verbose_name='Reference')), + ('notes', models.CharField(blank=True, help_text='Line item notes', max_length=500, verbose_name='Notes')), + ('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external page', verbose_name='Link')), + ('target_date', models.DateField(blank=True, help_text='Target date for this line item (leave blank to use the target date from the order)', null=True, verbose_name='Target Date')), + ('quantity', InvenTree.fields.RoundingDecimalField(decimal_places=5, default=0, help_text='Enter stock return quantity', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Quantity')), + ('received_date', models.DateField(blank=True, help_text='The date this this return item was received', null=True, verbose_name='Received Date')), + ('outcome', generic.states.fields.InvenTreeCustomStatusModelField(choices=[(10, 'Pending'), (20, 'Return'), (30, 'Repair'), (40, 'Replace'), (50, 'Refund'), (60, 'Reject')], default=10, help_text='Outcome for this line item', verbose_name='Outcome')), + ('price_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True)), + ('price', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Cost associated with return or repair for this line item', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Price')), + ('outcome_custom_key', generic.states.fields.ExtraInvenTreeCustomStatusModelField(blank=True, default=None, help_text='Additional status information for this item', null=True, verbose_name='Custom status key')), + ('order', models.ForeignKey(help_text='Return Order', on_delete=django.db.models.deletion.CASCADE, related_name='part_lines', to='order.returnorder', verbose_name='Order')), + ('part', models.ForeignKey(help_text='Select part to return from customer', limit_choices_to={'salable': True, 'virtual': False}, on_delete=django.db.models.deletion.CASCADE, related_name='return_order_part_line_items', to='part.part', verbose_name='Part')), + ], + options={ + 'verbose_name': 'Return Order Part Line Item', + 'unique_together': {('order', 'part')}, + }, + bases=(InvenTree.models.PluginValidationMixin, models.Model), + ), + ] diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index 3876822c0734..3587f8cfc9cf 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -474,7 +474,7 @@ def filterByDate(queryset, min_date, max_date): def __str__(self): """Render a string representation of this PurchaseOrder.""" - return f"{self.reference} - {self.supplier.name if self.supplier else _('deleted')}" + return f'{self.reference} - {self.supplier.name if self.supplier else _("deleted")}' reference = models.CharField( unique=True, @@ -990,7 +990,7 @@ def filterByDate(queryset, min_date, max_date): def __str__(self): """Render a string representation of this SalesOrder.""" - return f"{self.reference} - {self.customer.name if self.customer else _('deleted')}" + return f'{self.reference} - {self.customer.name if self.customer else _("deleted")}' reference = models.CharField( unique=True, @@ -2160,7 +2160,7 @@ def barcode_model_type_code(cls): def __str__(self): """Render a string representation of this ReturnOrder.""" - return f"{self.reference} - {self.customer.name if self.customer else _('no customer')}" + return f'{self.reference} - {self.customer.name if self.customer else _("no customer")}' reference = models.CharField( unique=True, @@ -2386,6 +2386,65 @@ def receive_line_item(self, line, location, user, note='', **kwargs): content=InvenTreeNotificationBodies.ReturnOrderItemsReceived, ) + @transaction.atomic + def receive_part_line_item(self, line, location, user, note='', **kwargs): + """Receive a part line item against this ReturnOrder. + + Rules: + - Finds or creates a StockItem at the specified location + - Adds a tracking entry to the StockItem + """ + # Prevent an item from being "received" multiple times + if line.received_date is not None: + logger.warning('receive_line_item called with item already returned') + return + + part = line.part + + stock_items = stock.models.StockItem.objects.filter( + part=part, location=location + ) + if len(stock_items) > 0: + stock_item = stock_items[0] + stock_item.quantity += line.quantity + else: + stock_item = stock.models.StockItem( + part=part, quantity=line.quantity, location=location + ) + + deltas = { + 'returnorder': self.pk, + 'location': location.pk, + 'quantity': f'{line.quantity}', + } + + # Update the StockItem + stock_item.save(add_note=False) + + # Add a tracking entry to the StockItem + stock_item.add_tracking_entry( + StockHistoryCode.RETURNED_AGAINST_RETURN_ORDER, + user, + notes=note, + deltas=deltas, + location=location, + returnorder=self, + ) + + # Update the LineItem + line.received_date = InvenTree.helpers.current_date() + line.save() + + trigger_event('returnorder.received', id=self.pk) + + # Notify responsible users + notify_responsible( + self, + ReturnOrder, + exclude=user, + content=InvenTreeNotificationBodies.ReturnOrderItemsReceived, + ) + class ReturnOrderLineItem(OrderLineItem): """Model for a single LineItem in a ReturnOrder.""" @@ -2453,6 +2512,79 @@ def received(self): ) +class ReturnOrderPartLineItem(OrderLineItem): + """Model for a single PartLineItem in a ReturnOrder.""" + + class Meta: + """Metaclass options for this model.""" + + verbose_name = _('Return Order Part Line Item') + unique_together = [('order', 'part')] + + @staticmethod + def get_api_url(): + """Return the API URL associated with this model.""" + return reverse('api-return-order-part-line-list') + + def clean(self): + """Perform extra validation steps for the ReturnOrderPartLineItem model.""" + super().clean() + if self.quantity <= 0: + raise ValidationError({'quantity': _('Quantity must be greater than 0')}) + + order = models.ForeignKey( + ReturnOrder, + on_delete=models.CASCADE, + related_name='part_lines', + verbose_name=_('Order'), + help_text=_('Return Order'), + ) + + part = models.ForeignKey( + 'part.Part', + on_delete=models.CASCADE, + related_name='return_order_part_line_items', + verbose_name=_('Part'), + help_text=_('Select part to return from customer'), + limit_choices_to={'salable': True, 'virtual': False}, + ) + + quantity = RoundingDecimalField( + max_digits=15, + decimal_places=5, + validators=[MinValueValidator(0)], + default=0, + verbose_name=_('Quantity'), + help_text=_('Enter stock return quantity'), + ) + + received_date = models.DateField( + null=True, + blank=True, + verbose_name=_('Received Date'), + help_text=_('The date this this return item was received'), + ) + + @property + def received(self): + """Return True if this item has been received.""" + return self.received_date is not None + + outcome = InvenTreeCustomStatusModelField( + default=ReturnOrderLineStatus.PENDING.value, + choices=ReturnOrderLineStatus.items(), + verbose_name=_('Outcome'), + help_text=_('Outcome for this line item'), + ) + + price = InvenTreeModelMoneyField( + null=True, + blank=True, + verbose_name=_('Price'), + help_text=_('Cost associated with return or repair for this line item'), + ) + + class ReturnOrderExtraLine(OrderExtraLine): """Model for a single ExtraLine in a ReturnOrder.""" diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index ed979ac323ec..ae350d7ecc0e 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -1896,6 +1896,33 @@ def validate_line_item(self, item): return item +class ReturnOrderPartLineItemReceiveSerializer(serializers.Serializer): + """Serializer for receiving a single part line item against a ReturnOrder.""" + + class Meta: + """Metaclass options.""" + + fields = ['item'] + + item = serializers.PrimaryKeyRelatedField( + queryset=order.models.ReturnOrderPartLineItem.objects.all(), + many=False, + allow_null=False, + required=True, + label=_('Return order part line item'), + ) + + def validate_line_item(self, item): + """Validation for a single line item.""" + if item.order != self.context['order']: + raise ValidationError(_('Line item does not match return order')) + + if item.received: + raise ValidationError(_('Line item has already been received')) + + return item + + class ReturnOrderReceiveSerializer(serializers.Serializer): """Serializer for receiving items against a ReturnOrder.""" @@ -1940,6 +1967,7 @@ def save(self): data = self.validated_data items = data['items'] + items = data['items'] location = data['location'] with transaction.atomic(): @@ -1948,6 +1976,59 @@ def save(self): order.receive_line_item(line_item, location, request.user) +class ReturnOrderReceivePartsSerializer(serializers.Serializer): + """Serializer for receiving parts against a ReturnOrder.""" + + class Meta: + """Metaclass options.""" + + fields = ['items', 'location'] + + items = ReturnOrderPartLineItemReceiveSerializer(many=True) + + location = serializers.PrimaryKeyRelatedField( + queryset=stock.models.StockLocation.objects.all(), + many=False, + allow_null=False, + required=True, + label=_('Location'), + help_text=_('Select destination location for received items'), + ) + + def validate(self, data): + """Perform data validation for this serializer.""" + order = self.context['order'] + if order.status != ReturnOrderStatus.IN_PROGRESS: + raise ValidationError( + _('Items can only be received against orders which are in progress') + ) + + data = super().validate(data) + + items = data.get('items', []) + + if len(items) == 0: + raise ValidationError(_('Part line items must be provided')) + + return data + + @transaction.atomic + def save(self): + """Saving this serializer marks the returned items as received.""" + order = self.context['order'] + request = self.context['request'] + + data = self.validated_data + items = data['items'] + items = data['items'] + location = data['location'] + + with transaction.atomic(): + for item in items: + line_item = item['item'] + order.receive_part_line_item(line_item, location, request.user) + + @register_importer() class ReturnOrderLineItemSerializer( DataImportExportSerializerMixin, @@ -2006,6 +2087,57 @@ def __init__(self, *args, **kwargs): price_currency = InvenTreeCurrencySerializer(help_text=_('Line price currency')) +@register_importer() +class ReturnOrderPartLineItemSerializer( + DataImportExportSerializerMixin, + AbstractLineItemSerializer, + InvenTreeModelSerializer, +): + """Serializer for a ReturnOrderPartLineItem object.""" + + class Meta: + """Metaclass options.""" + + model = order.models.ReturnOrderPartLineItem + + fields = [ + 'pk', + 'order', + 'order_detail', + 'part', + 'quantity', + 'received_date', + 'outcome', + 'part_detail', + 'price', + 'price_currency', + 'link', + 'reference', + 'notes', + 'target_date', + 'link', + ] + + def __init__(self, *args, **kwargs): + """Initialization routine for the serializer.""" + order_detail = kwargs.pop('order_detail', False) + part_detail = kwargs.pop('part_detail', False) + + super().__init__(*args, **kwargs) + + if not order_detail: + self.fields.pop('order_detail', None) + + if not part_detail: + self.fields.pop('part_detail', None) + + order_detail = ReturnOrderSerializer(source='order', many=False, read_only=True) + part_detail = PartBriefSerializer(source='part', many=False, read_only=True) + quantity = InvenTreeDecimalField() + price = InvenTreeMoneySerializer(allow_null=True) + price_currency = InvenTreeCurrencySerializer(help_text=_('Line price currency')) + + @register_importer() class ReturnOrderExtraLineSerializer( AbstractExtraLineSerializer, InvenTreeModelSerializer diff --git a/src/backend/InvenTree/order/templates/order/return_order_detail.html b/src/backend/InvenTree/order/templates/order/return_order_detail.html index 279ddc66cc06..1f7677059264 100644 --- a/src/backend/InvenTree/order/templates/order/return_order_detail.html +++ b/src/backend/InvenTree/order/templates/order/return_order_detail.html @@ -40,6 +40,35 @@

{% trans "Line Items" %}

+ +
+
+

{% trans "Part Line Items" %}

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

{% trans "Extra Lines" %}

@@ -118,6 +147,20 @@

{% trans "Order Notes" %}

); }); + $('#receive-part-line-items').click(function() { + let items = getTableData('#return-order-part-lines-table'); + + receiveReturnOrderPartItems( + {{ order.pk }}, + items, + { + onSuccess: function() { + reloadBootstrapTable('#return-order-part-lines-table'); + } + } + ); + }); + $('#new-return-order-line').click(function() { createReturnOrderLineItem({ order: {{ order.pk }}, @@ -128,8 +171,17 @@

{% trans "Order Notes" %}

}); }); - $('#new-return-order-extra-line').click(function() { + $('#new-return-order-part-line').click(function() { + createReturnOrderPartLineItem({ + order: {{ order.pk }}, + customer: {{ order.customer.pk }}, + onSuccess: function() { + reloadBootstrapTable('#return-order-part-lines-table'); + } + }); + }); + $('#new-return-order-extra-line').click(function() { createExtraLineItem({ order: {{ order.pk }}, table: '#return-order-extra-lines-table', @@ -156,6 +208,18 @@

{% trans "Order Notes" %}

{% endif %} }); + loadReturnOrderPartLineItemTable({ + table: '#return-order-part-lines-table', + order: {{ order.pk }}, + {% if order.status == ReturnOrderStatus.IN_PROGRESS %} + allow_receive: true, + {% endif %} + {% if order.is_open or allow_extra_editing %} + allow_edit: {% js_bool roles.return_order.change %}, + allow_delete: {% js_bool roles.return_order.delete %}, + {% endif %} + }); + loadExtraLineTable({ order: {{ order.pk }}, url: '{% url "api-return-order-extra-line-list" %}', diff --git a/src/backend/InvenTree/templates/js/translated/return_order.js b/src/backend/InvenTree/templates/js/translated/return_order.js index 57ac61018536..6dea3885637d 100644 --- a/src/backend/InvenTree/templates/js/translated/return_order.js +++ b/src/backend/InvenTree/templates/js/translated/return_order.js @@ -36,11 +36,13 @@ completeReturnOrder, createReturnOrder, createReturnOrderLineItem, + createReturnOrderPartLineItem, editReturnOrder, editReturnOrderLineItem, issueReturnOrder, loadReturnOrderTable, loadReturnOrderLineItemTable, + loadReturnOrderPartLineItemTable, */ @@ -479,6 +481,66 @@ function editReturnOrderLineItem(pk, options={}) { }); } +/* + * Construct a set of fields for a ReturnOrderLineItem form + */ +function returnOrderPartLineItemFields(options={}) { + + let fields = { + order: { + filters: { + customer_detail: true, + } + }, + part: {}, + quantity: {}, + reference: {}, + outcome: { + icon: 'fa-route', + }, + price: { + icon: 'fa-dollar-sign', + }, + price_currency: { + icon: 'fa-coins', + }, + target_date: { + icon: 'fa-calendar-alt', + }, + notes: { + icon: 'fa-sticky-note', + }, + link: { + icon: 'fa-link', + }, + }; + + return fields; +} + + +/* + * Create a new ReturnOrderLineItem + */ +function createReturnOrderPartLineItem(options={}) { + + let fields = returnOrderPartLineItemFields(); + + if (options.order) { + fields.order.value = options.order; + fields.order.hidden = true; + } + + constructForm('{% url "api-return-order-part-line-list" %}', { + fields: fields, + method: 'POST', + title: '{% trans "Add Part Line Item" %}', + onSuccess: function(response) { + handleFormSuccess(response, options); + } + }); +} + /* * Receive one or more items against a ReturnOrder @@ -617,6 +679,143 @@ function receiveReturnOrderItems(order_id, line_items, options={}) { }); } +/* + * Receive one or more part items against a ReturnOrder + */ +function receiveReturnOrderPartItems(order_id, line_items, options={}) { + + if (line_items.length == 0) { + showAlertDialog( + '{% trans "Select Part Line Items" %}', + '{% trans "At least one part line item must be selected" %}' + ); + return; + } + + function renderLineItem(line_item) { + let pk = line_item.pk; + + // Render thumbnail + description + let thumb = thumbnailImage(line_item.part_detail.thumbnail); + + let buttons = ''; + + if (line_items.length > 1) { + buttons += makeRemoveButton('button-row-remove', pk, '{% trans "Remove row" %}'); + } + + buttons = wrapButtons(buttons); + + let html = ` + + + ${thumb} ${line_item.part_detail.full_name} + + + ${line_item.quantity} + + ${buttons} + `; + + return html; + } + + let table_entries = ''; + + line_items.forEach(function(item) { + if (!item.received_date) { + table_entries += renderLineItem(item); + } + }); + + let html = ''; + + html += ` + + + + + + + + ${table_entries} +
{% trans "Part" %}{% trans "Quantity" %}
`; + + constructForm(`{% url "api-return-order-list" %}${order_id}/receive-parts/`, { + method: 'POST', + preFormContent: html, + fields: { + location: { + filters: { + structural: false, + }, + tree_picker: { + url: '{% url "api-location-tree" %}', + }, + } + }, + confirm: true, + confirmMessage: '{% trans "Confirm receipt of items" %}', + title: '{% trans "Receive Return Order Items" %}', + afterRender: function(fields, opts) { + // Add callback to remove rows + $(opts.modal).find('.button-row-remove').click(function() { + let pk = $(this).attr('pk'); + $(opts.modal).find(`#receive_row_${pk}`).remove(); + }); + }, + onSubmit: function(fields, opts) { + // Extract data elements from the form + let data = { + items: [], + location: getFormFieldValue('location', {}, opts), + }; + + let item_pk_values = []; + + line_items.forEach(function(item) { + let pk = item.pk; + let row = $(opts.modal).find(`#receive_row_${pk}`); + + if (row.exists()) { + data.items.push({ + item: pk, + }); + item_pk_values.push(pk); + } + }); + + opts.nested = { + 'items': item_pk_values, + }; + + inventreePut( + opts.url, + data, + { + method: 'POST', + success: function(response) { + $(opts.modal).modal('hide'); + + handleFormSuccess(response, options); + }, + error: function(xhr) { + switch (xhr.status) { + case 400: + handleFormErrors(xhr.responseJSON, fields, opts); + break; + default: + $(opts.modal).modal('hide'); + showApiError(xhr, opts.url); + break; + } + } + } + ); + } + }); +} + /* * Load a table displaying line items for a particular ReturnOrder @@ -809,3 +1008,199 @@ function loadReturnOrderLineItemTable(options={}) { ] }); } + +/* + * Load a table displaying line items for a particular ReturnOrder + */ +function loadReturnOrderPartLineItemTable(options={}) { + + var table = options.table; + + options.params = options.params || {}; + + options.params.order = options.order; + options.params.item_detail = true; + options.params.order_detail = false; + options.params.part_detail = true; + + let filters = loadTableFilters('returnorderpartlineitem', options.params); + + setupFilterList('returnorderpartlineitem', $(table), '#filter-list-returnorderpartlines', {download: true}); + + function setupCallbacks() { + if (options.allow_edit) { + + // Callback for "receive" button + if (options.allow_receive) { + $(table).find('.button-line-receive').click(function() { + let pk = $(this).attr('pk'); + + let line = $(table).bootstrapTable('getRowByUniqueId', pk); + + receiveReturnOrderPartItems( + options.order, + [line], + { + onSuccess: function(response) { + reloadBootstrapTable(table); + } + } + ); + }); + } + + // Callback for "edit" button + $(table).find('.button-line-edit').click(function() { + let pk = $(this).attr('pk'); + + constructForm(`{% url "api-return-order-part-line-list" %}${pk}/`, { + fields: returnOrderPartLineItemFields(), + title: '{% trans "Edit Line Item" %}', + refreshTable: table, + }); + }); + } + + if (options.allow_delete) { + // Callback for "delete" button + $(table).find('.button-line-delete').click(function() { + let pk = $(this).attr('pk'); + + constructForm(`{% url "api-return-order-part-line-list" %}${pk}/`, { + method: 'DELETE', + title: '{% trans "Delete Line Item" %}', + refreshTable: table, + }); + }); + } + } + + $(table).inventreeTable({ + url: '{% url "api-return-order-part-line-list" %}', + name: 'returnorderpartlineitems', + formatNoMatches: function() { + return '{% trans "No matching part line items" %}'; + }, + onPostBody: setupCallbacks, + queryParams: filters, + original: options.params, + showColumns: true, + showFooter: true, + uniqueId: 'pk', + columns: [ + { + checkbox: true, + switchable: false, + }, + { + field: 'part', + sortable: true, + switchable: false, + title: '{% trans "Part" %}', + formatter: function(value, row) { + let part = row.part_detail; + let html = thumbnailImage(part.thumbnail) + ' '; + html += renderLink(part.full_name, `/part/${part.pk}/`); + return html; + } + }, + { + sortable: true, + switchable: false, + field: 'quantity', + title: '{% trans "Quantity" %}', + footerFormatter: function(data) { + return data.map(function(row) { + return +row['quantity']; + }).reduce(function(sum, i) { + return sum + i; + }, 0); + }, + }, + { + field: 'reference', + title: '{% trans "Reference" %}', + }, + { + field: 'outcome', + title: '{% trans "Outcome" %}', + sortable: true, + formatter: function(value, row) { + return returnOrderLineItemStatusDisplay(value); + } + }, + { + field: 'price', + title: '{% trans "Price" %}', + formatter: function(value, row) { + return formatCurrency(row.price, { + currency: row.price_currency, + }); + } + }, + { + sortable: true, + field: 'target_date', + title: '{% trans "Target Date" %}', + formatter: function(value, row) { + let html = renderDate(value); + + if (row.overdue) { + html += makeIconBadge('fa-calendar-times icon-red', '{% trans "This line item is overdue" %}'); + } + + return html; + } + }, + { + field: 'received_date', + title: '{% trans "Received" %}', + sortable: true, + formatter: function(value) { + if (!value) { + yesNoLabel(value); + } else { + return renderDate(value); + } + } + }, + { + field: 'notes', + title: '{% trans "Notes" %}', + }, + { + field: 'link', + title: '{% trans "Link" %}', + formatter: function(value, row) { + if (value) { + return renderLink(value, value); + } + } + }, + { + field: 'buttons', + title: '', + switchable: false, + formatter: function(value, row) { + let buttons = ''; + let pk = row.pk; + + if (options.allow_edit) { + + if (options.allow_receive && !row.received_date) { + buttons += makeIconButton('fa-sign-in-alt icon-green', 'button-line-receive', pk, '{% trans "Mark item as received" %}'); + } + + buttons += makeEditButton('button-line-edit', pk, '{% trans "Edit line item" %}'); + } + + if (options.allow_delete) { + buttons += makeDeleteButton('button-line-delete', pk, '{% trans "Delete line item" %}'); + } + + return wrapButtons(buttons); + } + } + ] + }); +} diff --git a/src/backend/InvenTree/templates/js/translated/table_filters.js b/src/backend/InvenTree/templates/js/translated/table_filters.js index f45c14e0b01c..356b4306a65d 100644 --- a/src/backend/InvenTree/templates/js/translated/table_filters.js +++ b/src/backend/InvenTree/templates/js/translated/table_filters.js @@ -125,6 +125,20 @@ function getReturnOrderLineItemFilters() { }; } +// Return a dictionary of filters for the return order part line item table +function getReturnOrderPartLineItemFilters() { + return { + received: { + type: 'bool', + title: '{% trans "Received" %}', + }, + outcome: { + title: '{% trans "Outcome" %}', + options: returnOrderLineItemCodes, + } + }; +} + // Return a dictionary of filters for the variants table function getVariantsTableFilters() { @@ -908,6 +922,8 @@ function getAvailableTableFilters(tableKey) { return getReturnOrderFilters(); case 'returnorderlineitem': return getReturnOrderLineItemFilters(); + case 'returnorderpartlineitem': + return getReturnOrderPartLineItemFilters(); case 'salesorder': return getSalesOrderFilters(); case 'salesorderallocation':