diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index 9d322f339d..e7b8aeb71e 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -466,6 +466,24 @@ background: #eee; } +/* pricing table widths */ +.table-price-two tr td:first-child { + width: 40%; +} + +.table-price-three tr td:first-child { + width: 40%; +} + +.table-price-two tr td:last-child { + width: 60%; +} + +.table-price-three tr td:last-child { + width: 30%; +} +/* !pricing table widths */ + .btn-glyph { padding-left: 6px; padding-right: 6px; diff --git a/InvenTree/InvenTree/static/script/inventree/inventree.js b/InvenTree/InvenTree/static/script/inventree/inventree.js index 238fc0a6a6..d4269c1ffb 100644 --- a/InvenTree/InvenTree/static/script/inventree/inventree.js +++ b/InvenTree/InvenTree/static/script/inventree/inventree.js @@ -100,7 +100,7 @@ function makeIconButton(icon, cls, pk, title, options={}) { if (options.disabled) { extraProps += "disabled='true' "; } - + html += ``; diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index bc2ca4214b..4280177629 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -7,6 +7,8 @@ These models are 'generic' and do not fit a particular business logic object. from __future__ import unicode_literals import os +import decimal +import math from django.db import models, transaction from django.db.utils import IntegrityError, OperationalError @@ -730,6 +732,72 @@ class PriceBreak(models.Model): return converted.amount +def get_price(instance, quantity, moq=True, multiples=True, currency=None): + """ Calculate the price based on quantity price breaks. + + - Don't forget to add in flat-fee cost (base_cost field) + - If MOQ (minimum order quantity) is required, bump quantity + - If order multiples are to be observed, then we need to calculate based on that, too + """ + + price_breaks = instance.price_breaks.all() + + # No price break information available? + if len(price_breaks) == 0: + return None + + # Check if quantity is fraction and disable multiples + multiples = (quantity % 1 == 0) + + # Order multiples + if multiples: + quantity = int(math.ceil(quantity / instance.multiple) * instance.multiple) + + pb_found = False + pb_quantity = -1 + pb_cost = 0.0 + + if currency is None: + # Default currency selection + currency = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY') + + pb_min = None + for pb in instance.price_breaks.all(): + # Store smallest price break + if not pb_min: + pb_min = pb + + # Ignore this pricebreak (quantity is too high) + if pb.quantity > quantity: + continue + + pb_found = True + + # If this price-break quantity is the largest so far, use it! + if pb.quantity > pb_quantity: + pb_quantity = pb.quantity + + # Convert everything to the selected currency + pb_cost = pb.convert_to(currency) + + # Use smallest price break + if not pb_found and pb_min: + # Update price break information + pb_quantity = pb_min.quantity + pb_cost = pb_min.convert_to(currency) + # Trigger cost calculation using smallest price break + pb_found = True + + # Convert quantity to decimal.Decimal format + quantity = decimal.Decimal(f'{quantity}') + + if pb_found: + cost = pb_cost * quantity + return InvenTree.helpers.normalize(cost + instance.base_cost) + else: + return None + + class ColorTheme(models.Model): """ Color Theme Setting """ diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index 89a3f6c9bf..32f1d07a33 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -6,8 +6,6 @@ Company database model definitions from __future__ import unicode_literals import os -import decimal -import math from django.utils.translation import ugettext_lazy as _ from django.core.validators import MinValueValidator @@ -26,7 +24,6 @@ from markdownx.models import MarkdownxField from stdimage.models import StdImageField from InvenTree.helpers import getMediaUrl, getBlankImage, getBlankThumbnail -from InvenTree.helpers import normalize from InvenTree.fields import InvenTreeURLField from InvenTree.status_codes import PurchaseOrderStatus @@ -558,70 +555,7 @@ class SupplierPart(models.Model): price=price ) - def get_price(self, quantity, moq=True, multiples=True, currency=None): - """ Calculate the supplier price based on quantity price breaks. - - - Don't forget to add in flat-fee cost (base_cost field) - - If MOQ (minimum order quantity) is required, bump quantity - - If order multiples are to be observed, then we need to calculate based on that, too - """ - - price_breaks = self.price_breaks.all() - - # No price break information available? - if len(price_breaks) == 0: - return None - - # Check if quantity is fraction and disable multiples - multiples = (quantity % 1 == 0) - - # Order multiples - if multiples: - quantity = int(math.ceil(quantity / self.multiple) * self.multiple) - - pb_found = False - pb_quantity = -1 - pb_cost = 0.0 - - if currency is None: - # Default currency selection - currency = common.models.InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY') - - pb_min = None - for pb in self.price_breaks.all(): - # Store smallest price break - if not pb_min: - pb_min = pb - - # Ignore this pricebreak (quantity is too high) - if pb.quantity > quantity: - continue - - pb_found = True - - # If this price-break quantity is the largest so far, use it! - if pb.quantity > pb_quantity: - pb_quantity = pb.quantity - - # Convert everything to the selected currency - pb_cost = pb.convert_to(currency) - - # Use smallest price break - if not pb_found and pb_min: - # Update price break information - pb_quantity = pb_min.quantity - pb_cost = pb_min.convert_to(currency) - # Trigger cost calculation using smallest price break - pb_found = True - - # Convert quantity to decimal.Decimal format - quantity = decimal.Decimal(f'{quantity}') - - if pb_found: - cost = pb_cost * quantity - return normalize(cost + self.base_cost) - else: - return None + get_price = common.models.get_price def open_orders(self): """ Return a database query for PO line items for this SupplierPart, diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index 4c9caf3b53..8536c71ef5 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -211,6 +211,7 @@ class EditSalesOrderLineItemForm(HelperForm): 'part', 'quantity', 'reference', + 'sale_price', 'notes' ] diff --git a/InvenTree/order/migrations/0045_auto_20210504_1946.py b/InvenTree/order/migrations/0045_auto_20210504_1946.py new file mode 100644 index 0000000000..a8d9469dc7 --- /dev/null +++ b/InvenTree/order/migrations/0045_auto_20210504_1946.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2 on 2021-05-04 19:46 + +from django.db import migrations +import djmoney.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0044_auto_20210404_2016'), + ] + + operations = [ + migrations.AddField( + model_name='salesorderlineitem', + name='sale_price', + field=djmoney.models.fields.MoneyField(blank=True, decimal_places=4, default_currency='USD', help_text='Unit sale price', max_digits=19, null=True, verbose_name='Sale Price'), + ), + migrations.AddField( + model_name='salesorderlineitem', + name='sale_price_currency', + field=djmoney.models.fields.CurrencyField(choices=[('AUD', 'Australian Dollar'), ('GBP', 'British Pound'), ('CAD', 'Canadian Dollar'), ('EUR', 'Euro'), ('JPY', 'Japanese Yen'), ('NZD', 'New Zealand Dollar'), ('USD', 'US Dollar')], default='USD', editable=False, max_length=3), + ), + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 292a5ed492..67890806c5 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -672,12 +672,22 @@ class SalesOrderLineItem(OrderLineItem): Attributes: order: Link to the SalesOrder that this line item belongs to part: Link to a Part object (may be null) + sale_price: The unit sale price for this OrderLineItem """ order = models.ForeignKey(SalesOrder, on_delete=models.CASCADE, related_name='lines', verbose_name=_('Order'), help_text=_('Sales Order')) part = models.ForeignKey('part.Part', on_delete=models.SET_NULL, related_name='sales_order_line_items', null=True, verbose_name=_('Part'), help_text=_('Part'), limit_choices_to={'salable': True}) + sale_price = MoneyField( + max_digits=19, + decimal_places=4, + default_currency='USD', + null=True, blank=True, + verbose_name=_('Sale Price'), + help_text=_('Unit sale price'), + ) + class Meta: unique_together = [ ] diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index a04798c303..2f4545fc30 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -278,6 +278,7 @@ class SOLineItemSerializer(InvenTreeModelSerializer): quantity = serializers.FloatField() allocated = serializers.FloatField(source='allocated_quantity', read_only=True) fulfilled = serializers.FloatField(source='fulfilled_quantity', read_only=True) + sale_price_string = serializers.CharField(source='sale_price', read_only=True) class Meta: model = SalesOrderLineItem @@ -294,6 +295,9 @@ class SOLineItemSerializer(InvenTreeModelSerializer): 'order_detail', 'part', 'part_detail', + 'sale_price', + 'sale_price_currency', + 'sale_price_string', ] diff --git a/InvenTree/order/templates/order/sales_order_detail.html b/InvenTree/order/templates/order/sales_order_detail.html index 392a236931..e4a399a0e1 100644 --- a/InvenTree/order/templates/order/sales_order_detail.html +++ b/InvenTree/order/templates/order/sales_order_detail.html @@ -223,6 +223,14 @@ $("#so-lines-table").inventreeTable({ field: 'quantity', title: '{% trans "Quantity" %}', }, + { + sortable: true, + field: 'sale_price', + title: '{% trans "Unit Price" %}', + formatter: function(value, row) { + return row.sale_price_string || row.sale_price; + } + }, { field: 'allocated', {% if order.status == SalesOrderStatus.PENDING %} @@ -279,7 +287,7 @@ $("#so-lines-table").inventreeTable({ html += makeIconButton('fa-hashtag icon-green', 'button-add-by-sn', pk, '{% trans "Allocate serial numbers" %}'); } - html += makeIconButton('fa-sign-in-alt icon-green', 'button-add', pk, '{% trans "Allocate stock" %}'); + html += makeIconButton('fa-sign-in-alt icon-green', 'button-add', pk, '{% trans "Allocate stock" %}'); if (part.purchaseable) { html += makeIconButton('fa-shopping-cart', 'button-buy', row.part, '{% trans "Purchase stock" %}'); @@ -288,7 +296,8 @@ $("#so-lines-table").inventreeTable({ if (part.assembly) { html += makeIconButton('fa-tools', 'button-build', row.part, '{% trans "Build stock" %}'); } - + + html += makeIconButton('fa-dollar-sign icon-green', 'button-price', pk, '{% trans "Calculate price" %}'); } html += makeIconButton('fa-edit icon-blue', 'button-edit', pk, '{% trans "Edit line item" %}'); @@ -388,6 +397,26 @@ function setupCallbacks() { }, }); }); + + $(".button-price").click(function() { + var pk = $(this).attr('pk'); + var idx = $(this).closest('tr').attr('data-index'); + var row = table.bootstrapTable('getData')[idx]; + + launchModalForm( + "{% url 'line-pricing' %}", + { + submit_text: '{% trans "Calculate price" %}', + data: { + line_item: pk, + quantity: row.quantity, + }, + buttons: [{name: 'update_price', + title: '{% trans "Update Unit Price" %}'},], + success: reloadTable, + } + ); + }); } {% endblock %} \ No newline at end of file diff --git a/InvenTree/order/urls.py b/InvenTree/order/urls.py index 97903d81c1..746a482c4a 100644 --- a/InvenTree/order/urls.py +++ b/InvenTree/order/urls.py @@ -31,6 +31,7 @@ purchase_order_urls = [ url(r'^new/', views.PurchaseOrderCreate.as_view(), name='po-create'), url(r'^order-parts/', views.OrderParts.as_view(), name='order-parts'), + url(r'^pricing/', views.LineItemPricing.as_view(), name='line-pricing'), # Display detail view for a single purchase order url(r'^(?P\d+)/', include(purchase_order_detail_urls)), diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index 0ffff4e340..6913799631 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -6,13 +6,14 @@ Django views for interacting with Order app from __future__ import unicode_literals from django.db import transaction +from django.http.response import JsonResponse from django.shortcuts import get_object_or_404 from django.core.exceptions import ValidationError from django.urls import reverse from django.utils.translation import ugettext_lazy as _ from django.views.generic import DetailView, ListView, UpdateView from django.views.generic.edit import FormMixin -from django.forms import HiddenInput +from django.forms import HiddenInput, IntegerField import logging from decimal import Decimal, InvalidOperation @@ -29,6 +30,7 @@ from part.models import Part from common.models import InvenTreeSetting from . import forms as order_forms +from part.views import PartPricing from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView from InvenTree.helpers import DownloadFile, str2bool @@ -1245,6 +1247,18 @@ class SOLineItemCreate(AjaxCreateView): return initials + def save(self, form): + ret = form.save() + # check if price s set in form - else autoset + if not ret.sale_price: + price = ret.part.get_price(ret.quantity) + # only if price is avail + if price: + ret.sale_price = price / ret.quantity + ret.save() + self.object = ret + return ret + class SOLineItemEdit(AjaxUpdateView): """ View for editing a SalesOrderLineItem """ @@ -1571,3 +1585,101 @@ class SalesOrderAllocationDelete(AjaxDeleteView): ajax_form_title = _("Remove allocation") context_object_name = 'allocation' ajax_template_name = "order/so_allocation_delete.html" + + +class LineItemPricing(PartPricing): + """ View for inspecting part pricing information """ + + class EnhancedForm(PartPricing.form_class): + pk = IntegerField(widget=HiddenInput()) + so_line = IntegerField(widget=HiddenInput()) + + form_class = EnhancedForm + + def get_part(self, id=False): + if 'line_item' in self.request.GET: + try: + part_id = self.request.GET.get('line_item') + part = SalesOrderLineItem.objects.get(id=part_id).part + except Part.DoesNotExist: + return None + elif 'pk' in self.request.POST: + try: + part_id = self.request.POST.get('pk') + part = Part.objects.get(id=part_id) + except Part.DoesNotExist: + return None + else: + return None + + if id: + return part.id + return part + + def get_so(self, pk=False): + so_line = self.request.GET.get('line_item', None) + if not so_line: + so_line = self.request.POST.get('so_line', None) + + if so_line: + try: + sales_order = SalesOrderLineItem.objects.get(pk=so_line) + if pk: + return sales_order.pk + return sales_order + except Part.DoesNotExist: + return None + return None + + def get_quantity(self): + """ Return set quantity in decimal format """ + qty = Decimal(self.request.GET.get('quantity', 1)) + if qty == 1: + return Decimal(self.request.POST.get('quantity', 1)) + return qty + + def get_initials(self): + initials = super().get_initials() + initials['pk'] = self.get_part(id=True) + initials['so_line'] = self.get_so(pk=True) + return initials + + def post(self, request, *args, **kwargs): + # parse extra actions + REF = 'act-btn_' + act_btn = [a.replace(REF, '') for a in self.request.POST if REF in a] + + # check if extra action was passed + if act_btn and act_btn[0] == 'update_price': + # get sales order + so_line = self.get_so() + if not so_line: + self.data = {'non_field_errors': [_('Sales order not found')]} + else: + quantity = self.get_quantity() + price = self.get_pricing(quantity).get('unit_part_price', None) + + if not price: + self.data = {'non_field_errors': [_('Price not found')]} + else: + # set normal update note + note = _('Updated {part} unit-price to {price}') + + # check qunatity and update if different + if so_line.quantity != quantity: + so_line.quantity = quantity + note = _('Updated {part} unit-price to {price} and quantity to {qty}') + + # update sale_price + so_line.sale_price = price + so_line.save() + + # parse response + data = { + 'form_valid': True, + 'success': note.format(part=str(so_line.part), price=str(so_line.sale_price), qty=quantity) + } + return JsonResponse(data=data) + + # let the normal pricing view run + return super().post(request, *args, **kwargs) diff --git a/InvenTree/part/migrations/0065_auto_20210505_2144.py b/InvenTree/part/migrations/0065_auto_20210505_2144.py new file mode 100644 index 0000000000..328ce1f588 --- /dev/null +++ b/InvenTree/part/migrations/0065_auto_20210505_2144.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2 on 2021-05-05 21:44 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0064_auto_20210404_2016'), + ] + + operations = [ + migrations.AddField( + model_name='part', + name='base_cost', + field=models.DecimalField(decimal_places=3, default=0, help_text='Minimum charge (e.g. stocking fee)', max_digits=10, validators=[django.core.validators.MinValueValidator(0)], verbose_name='base cost'), + ), + migrations.AddField( + model_name='part', + name='multiple', + field=models.PositiveIntegerField(default=1, help_text='Sell multiple', validators=[django.core.validators.MinValueValidator(1)], verbose_name='multiple'), + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 137781ba2b..4c7086f51d 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -1611,6 +1611,44 @@ class Part(MPTTModel): max(buy_price_range[1], bom_price_range[1]) ) + base_cost = models.DecimalField(max_digits=10, decimal_places=3, default=0, validators=[MinValueValidator(0)], verbose_name=_('base cost'), help_text=_('Minimum charge (e.g. stocking fee)')) + + multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], verbose_name=_('multiple'), help_text=_('Sell multiple')) + + get_price = common.models.get_price + + @property + def has_price_breaks(self): + return self.price_breaks.count() > 0 + + @property + def price_breaks(self): + """ Return the associated price breaks in the correct order """ + return self.salepricebreaks.order_by('quantity').all() + + @property + def unit_pricing(self): + return self.get_price(1) + + def add_price_break(self, quantity, price): + """ + Create a new price break for this part + + args: + quantity - Numerical quantity + price - Must be a Money object + """ + + # Check if a price break at that quantity already exists... + if self.price_breaks.filter(quantity=quantity, part=self.pk).exists(): + return + + PartSellPriceBreak.objects.create( + part=self, + quantity=quantity, + price=price + ) + @transaction.atomic def copy_bom_from(self, other, clear=True, **kwargs): """ diff --git a/InvenTree/part/templates/part/part_pricing.html b/InvenTree/part/templates/part/part_pricing.html index b14be2c61f..30628b5fc2 100644 --- a/InvenTree/part/templates/part/part_pricing.html +++ b/InvenTree/part/templates/part/part_pricing.html @@ -4,24 +4,20 @@ {% block pre_form_content %} -
-{% blocktrans %}Pricing information for:
{{part}}.{% endblocktrans %} -
- -

{% trans 'Quantity' %}

- +
- + - +
{% trans 'Part' %}{{ part }}{{ part }}
{% trans 'Quantity' %}{{ quantity }}{{ quantity }}
- {% if part.supplier_count > 0 %} + +{% if part.supplier_count > 0 %}

{% trans 'Supplier Pricing' %}

- +
{% if min_total_buy_price %} @@ -42,12 +38,12 @@ {% endif %} -
{% trans 'Unit Cost' %}
- {% endif %} + +{% endif %} - {% if part.bom_count > 0 %} +{% if part.bom_count > 0 %}

{% trans 'BOM Pricing' %}

- +
{% if min_total_bom_price %} @@ -75,8 +71,22 @@ {% endif %} -
{% trans 'Unit Cost' %}
- {% endif %} + +{% endif %} + +{% if total_part_price %} +

{% trans 'Sale Price' %}

+ + + + + + + + + +
{% trans 'Unit Cost' %}{% include "price.html" with price=unit_part_price %}
{% trans 'Total Cost' %}{% include "price.html" with price=total_part_price %}
+{% endif %} {% if min_unit_buy_price or min_unit_bom_price %} {% else %} @@ -84,7 +94,5 @@ {% trans 'No pricing information is available for this part.' %} {% endif %} -
- {% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templatetags/inventree_extras.py b/InvenTree/part/templatetags/inventree_extras.py index 536f25cb5b..d8b98c53a7 100644 --- a/InvenTree/part/templatetags/inventree_extras.py +++ b/InvenTree/part/templatetags/inventree_extras.py @@ -200,18 +200,28 @@ class I18nStaticNode(StaticNode): return ret -@register.tag('i18n_static') -def do_i18n_static(parser, token): - """ - Overrides normal static, adds language - lookup for prerenderd files #1485 +# use the dynamic url - tag if in Debugging-Mode +if settings.DEBUG: - usage (like static): - {% i18n_static path [as varname] %} - """ - bits = token.split_contents() - loc_name = settings.STATICFILES_I18_PREFIX + @register.simple_tag() + def i18n_static(url_name): + """ simple tag to enable {% url %} functionality instead of {% static %} """ + return reverse(url_name) - # change path to called ressource - bits[1] = f"'{loc_name}/{{lng}}.{bits[1][1:-1]}'" - token.contents = ' '.join(bits) - return I18nStaticNode.handle_token(parser, token) +else: + + @register.tag('i18n_static') + def do_i18n_static(parser, token): + """ + Overrides normal static, adds language - lookup for prerenderd files #1485 + + usage (like static): + {% i18n_static path [as varname] %} + """ + bits = token.split_contents() + loc_name = settings.STATICFILES_I18_PREFIX + + # change path to called ressource + bits[1] = f"'{loc_name}/{{lng}}.{bits[1][1:-1]}'" + token.contents = ' '.join(bits) + return I18nStaticNode.handle_token(parser, token) diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index b90b11b568..c734b7f610 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -30,11 +30,10 @@ sale_price_break_urls = [ ] part_parameter_urls = [ - url(r'^template/new/', views.PartParameterTemplateCreate.as_view(), name='part-param-template-create'), url(r'^template/(?P\d+)/edit/', views.PartParameterTemplateEdit.as_view(), name='part-param-template-edit'), url(r'^template/(?P\d+)/delete/', views.PartParameterTemplateDelete.as_view(), name='part-param-template-edit'), - + url(r'^new/', views.PartParameterCreate.as_view(), name='part-param-create'), url(r'^(?P\d+)/edit/', views.PartParameterEdit.as_view(), name='part-param-edit'), url(r'^(?P\d+)/delete/', views.PartParameterDelete.as_view(), name='part-param-delete'), @@ -49,10 +48,10 @@ part_detail_urls = [ url(r'^duplicate/', views.PartDuplicate.as_view(), name='part-duplicate'), url(r'^make-variant/', views.MakePartVariant.as_view(), name='make-part-variant'), url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'), - + url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'), url(r'^bom-duplicate/?', views.BomDuplicate.as_view(), name='duplicate-bom'), - + url(r'^params/', views.PartDetail.as_view(template_name='part/params.html'), name='part-params'), url(r'^variants/?', views.PartDetail.as_view(template_name='part/variants.html'), name='part-variants'), url(r'^stock/?', views.PartDetail.as_view(template_name='part/stock.html'), name='part-stock'), @@ -70,7 +69,7 @@ part_detail_urls = [ url(r'^related-parts/?', views.PartDetail.as_view(template_name='part/related.html'), name='part-related'), 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'), - + url(r'^qr_code/?', views.PartQRCode.as_view(), name='part-qr'), # Normal thumbnail with form @@ -104,7 +103,7 @@ category_urls = [ url(r'^subcategory/', views.CategoryDetail.as_view(template_name='part/subcategory.html'), name='category-subcategory'), url(r'^parametric/', views.CategoryParametric.as_view(), name='category-parametric'), - + # Anything else url(r'^.*$', views.CategoryDetail.as_view(), name='category-detail'), ])) diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 2cf86945d3..ad98095fb1 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -1956,10 +1956,9 @@ class PartPricing(AjaxView): form_class = part_forms.PartPriceForm role_required = ['sales_order.view', 'part.view'] - + def get_quantity(self): """ Return set quantity in decimal format """ - return Decimal(self.request.POST.get('quantity', 1)) def get_part(self): @@ -1969,12 +1968,7 @@ class PartPricing(AjaxView): return None def get_pricing(self, quantity=1, currency=None): - - # try: - # quantity = int(quantity) - # except ValueError: - # quantity = 1 - + """ returns context with pricing information """ if quantity <= 0: quantity = 1 @@ -2044,11 +2038,22 @@ class PartPricing(AjaxView): ctx['max_total_bom_price'] = max_bom_price ctx['max_unit_bom_price'] = max_unit_bom_price + # part pricing information + part_price = part.get_price(quantity) + if part_price is not None: + ctx['total_part_price'] = round(part_price, 3) + ctx['unit_part_price'] = round(part_price / quantity, 3) + return ctx - def get(self, request, *args, **kwargs): + def get_initials(self): + """ returns initials for form """ + return {'quantity': self.get_quantity()} - return self.renderJsonResponse(request, self.form_class(), context=self.get_pricing()) + def get(self, request, *args, **kwargs): + init = self.get_initials() + qty = self.get_quantity() + return self.renderJsonResponse(request, self.form_class(initial=init), context=self.get_pricing(qty)) def post(self, request, *args, **kwargs): @@ -2057,16 +2062,19 @@ class PartPricing(AjaxView): quantity = self.get_quantity() # Retain quantity value set by user - form = self.form_class() - form.fields['quantity'].initial = quantity + form = self.form_class(initial=self.get_initials()) # TODO - How to handle pricing in different currencies? currency = None + # check if data is set + try: + data = self.data + except AttributeError: + data = {} + # Always mark the form as 'invalid' (the user may wish to keep getting pricing data) - data = { - 'form_valid': False, - } + data['form_valid'] = False return self.renderJsonResponse(request, form, data=data, context=self.get_pricing(quantity, currency)) diff --git a/InvenTree/templates/js/modals.js b/InvenTree/templates/js/modals.js index 004e81c000..8d34d790d8 100644 --- a/InvenTree/templates/js/modals.js +++ b/InvenTree/templates/js/modals.js @@ -377,6 +377,15 @@ function modalSubmit(modal, callback) { $(modal).on('click', '#modal-form-submit', function() { callback(); }); + + $(modal).on('click', '.modal-form-button', function() { + // Append data to form + var name = $(this).attr('name'); + var value = $(this).attr('value'); + var input = ''; + $('.js-modal-form').append(input); + callback(); + }); } @@ -659,6 +668,25 @@ function attachSecondaries(modal, secondaries) { } } +function insertActionButton(modal, options) { + /* Insert a custom submition button */ + + var html = ""; + html += ""; + html += ""; + + $(modal).find('#modal-footer-buttons').append(html); +} + +function attachButtons(modal, buttons) { + /* Attach a provided list of buttons */ + + for (var i = 0; i < buttons.length; i++) { + insertActionButton(modal, buttons[i]); + } +} + function attachFieldCallback(modal, callback) { /* Attach a 'callback' function to a given field in the modal form. @@ -808,6 +836,9 @@ function launchModalForm(url, options = {}) { var submit_text = options.submit_text || '{% trans "Submit" %}'; var close_text = options.close_text || '{% trans "Close" %}'; + // Clean custom action buttons + $(modal).find('#modal-footer-buttons').html(''); + // Form the ajax request to retrieve the django form data ajax_data = { url: url, @@ -852,6 +883,10 @@ function launchModalForm(url, options = {}) { handleModalForm(url, options); } + if (options.buttons) { + attachButtons(modal, options.buttons); + } + } else { $(modal).modal('hide'); showAlertDialog('{% trans "Invalid server response" %}', '{% trans "JSON response missing form data" %}'); diff --git a/InvenTree/templates/modals.html b/InvenTree/templates/modals.html index 9850f482c5..e394b28314 100644 --- a/InvenTree/templates/modals.html +++ b/InvenTree/templates/modals.html @@ -25,6 +25,7 @@ @@ -49,6 +50,7 @@ @@ -69,6 +71,7 @@ @@ -90,6 +93,7 @@