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 9e25c2e870..112a8cf297 100644 --- a/InvenTree/order/urls.py +++ b/InvenTree/order/urls.py @@ -32,6 +32,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 039ff15e1e..0c62d0725e 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -7,6 +7,7 @@ from __future__ import unicode_literals from django.db import transaction from django.db.utils import IntegrityError +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 @@ -14,7 +15,7 @@ from django.http import HttpResponseRedirect 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 @@ -24,7 +25,7 @@ from .models import SalesOrder, SalesOrderLineItem, SalesOrderAttachment from .models import SalesOrderAllocation from .admin import POLineItemResource from build.models import Build -from company.models import Company, SupplierPart # ManufacturerPart +from company.models import Company, SupplierPart from stock.models import StockItem, StockLocation from part.models import Part @@ -32,6 +33,7 @@ from common.models import InvenTreeSetting from common.views import FileManagementFormView 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 @@ -567,165 +569,6 @@ class SalesOrderShip(AjaxUpdateView): return self.renderJsonResponse(request, form, data, context) -class PurchaseOrderUpload(FileManagementFormView): - ''' PurchaseOrder: Upload file, match to fields and parts (using multi-Step form) ''' - - name = 'order' - form_steps_template = [ - 'order/order_wizard/po_upload.html', - 'order/order_wizard/match_fields.html', - 'order/order_wizard/match_parts.html', - ] - form_steps_description = [ - _("Upload File"), - _("Match Fields"), - _("Match Supplier Parts"), - ] - - def get_order(self): - """ Get order or return 404 """ - - return get_object_or_404(PurchaseOrder, pk=self.kwargs['pk']) - - def get_context_data(self, form, **kwargs): - context = super().get_context_data(form=form, **kwargs) - - order = self.get_order() - - context.update({'order': order}) - - return context - - def get_field_selection(self): - """ Once data columns have been selected, attempt to pre-select the proper data from the database. - This function is called once the field selection has been validated. - The pre-fill data are then passed through to the SupplierPart selection form. - """ - - order = self.get_order() - - self.allowed_items = SupplierPart.objects.filter(supplier=order.supplier).prefetch_related('manufacturer_part') - - # Fields prefixed with "Part_" can be used to do "smart matching" against Part objects in the database - q_idx = self.get_column_index('Quantity') - s_idx = self.get_column_index('Supplier_SKU') - m_idx = self.get_column_index('Manufacturer_MPN') - # p_idx = self.get_column_index('Unit_Price') - # e_idx = self.get_column_index('Extended_Price') - - for row in self.rows: - - # Initially use a quantity of zero - quantity = Decimal(0) - - # Initially we do not have a part to reference - exact_match_part = None - - # Check if there is a column corresponding to "quantity" - if q_idx >= 0: - q_val = row['data'][q_idx]['cell'] - - if q_val: - # Delete commas - q_val = q_val.replace(',', '') - - try: - # Attempt to extract a valid quantity from the field - quantity = Decimal(q_val) - except (ValueError, InvalidOperation): - pass - - # Store the 'quantity' value - row['quantity'] = quantity - - # Check if there is a column corresponding to "Supplier SKU" - if s_idx >= 0: - sku = row['data'][s_idx]['cell'] - - try: - # Attempt SupplierPart lookup based on SKU value - exact_match_part = self.allowed_items.get(SKU__contains=sku) - except (ValueError, SupplierPart.DoesNotExist, SupplierPart.MultipleObjectsReturned): - exact_match_part = None - - # Check if there is a column corresponding to "Manufacturer MPN" - if m_idx >= 0: - mpn = row['data'][m_idx]['cell'] - - try: - # Attempt SupplierPart lookup based on MPN value - exact_match_part = self.allowed_items.get(manufacturer_part__MPN__contains=mpn) - except (ValueError, SupplierPart.DoesNotExist, SupplierPart.MultipleObjectsReturned): - exact_match_part = None - - # Supply list of part options for each row, sorted by how closely they match the part name - row['item_options'] = self.allowed_items - - # Unless found, the 'part_match' is blank - row['item_match'] = None - - if exact_match_part: - # If there is an exact match based on SKU or MPN, use that - row['item_match'] = exact_match_part - - def done(self, form_list, **kwargs): - """ Once all the data is in, process it to add SupplierPart items to the order """ - - order = self.get_order() - - items = {} - - for form_key, form_value in self.get_all_cleaned_data().items(): - # Split key from row value - try: - (field, idx) = form_key.split('-') - except ValueError: - continue - - if field == self.key_item_select: - if idx not in items: - # Insert into items - items.update({ - idx: { - 'field': form_value, - } - }) - else: - # Update items - items[idx]['field'] = form_value - - if field == self.key_quantity_select: - if idx not in items: - # Insert into items - items.update({ - idx: { - 'quantity': form_value, - } - }) - else: - # Update items - items[idx]['quantity'] = form_value - - # Create PurchaseOrderLineItem instances - for purchase_order_item in items.values(): - try: - supplier_part = SupplierPart.objects.get(pk=int(purchase_order_item['field'])) - except (ValueError, SupplierPart.DoesNotExist): - continue - purchase_order_line_item = PurchaseOrderLineItem( - order=order, - part=supplier_part, - quantity=purchase_order_item['quantity'], - ) - try: - purchase_order_line_item.save() - except IntegrityError: - # PurchaseOrderLineItem already exists - pass - - return HttpResponseRedirect(reverse('po-detail', kwargs={'pk': self.kwargs['pk']})) - - class PurchaseOrderExport(AjaxView): """ File download for a purchase order @@ -1407,6 +1250,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 """ @@ -1733,3 +1588,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/stock/admin.py b/InvenTree/stock/admin.py index 5f3c08839d..3aa0ee451f 100644 --- a/InvenTree/stock/admin.py +++ b/InvenTree/stock/admin.py @@ -56,7 +56,7 @@ class LocationAdmin(ImportExportModelAdmin): class StockItemResource(ModelResource): """ Class for managing StockItem data import/export """ - # Custom manaegrs for ForeignKey fields + # Custom managers for ForeignKey fields part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part)) part_name = Field(attribute='part__full_name', readonly=True) diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 7f22b7a4ef..0d996c5a7c 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -228,7 +228,7 @@ class StockItem(MPTTModel): super(StockItem, self).validate_unique(exclude) # If the serial number is set, make sure it is not a duplicate - if self.serial is not None: + if self.serial: # Query to look for duplicate serial numbers parts = PartModels.Part.objects.filter(tree_id=self.part.tree_id) stock = StockItem.objects.filter(part__in=parts, serial=self.serial) @@ -281,7 +281,7 @@ class StockItem(MPTTModel): if self.part is not None: # A part with a serial number MUST have the quantity set to 1 - if self.serial is not None: + if self.serial: if self.quantity > 1: raise ValidationError({ 'quantity': _('Quantity must be 1 for item with a serial number'), 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 @@