\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 %}
{% trans 'Unit Cost' %} |
@@ -42,12 +38,12 @@
{% endif %}
-
- {% endif %}
+
+{% endif %}
- {% if part.bom_count > 0 %}
+{% if part.bom_count > 0 %}
{% trans 'BOM Pricing' %}
-
+
{% if min_total_bom_price %}
{% trans 'Unit Cost' %} |
@@ -75,8 +71,22 @@
{% endif %}
-
- {% 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 @@