mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
commit
3641e4465d
@ -466,6 +466,24 @@
|
|||||||
background: #eee;
|
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 {
|
.btn-glyph {
|
||||||
padding-left: 6px;
|
padding-left: 6px;
|
||||||
padding-right: 6px;
|
padding-right: 6px;
|
||||||
|
@ -7,6 +7,8 @@ These models are 'generic' and do not fit a particular business logic object.
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import decimal
|
||||||
|
import math
|
||||||
|
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.db.utils import IntegrityError, OperationalError
|
from django.db.utils import IntegrityError, OperationalError
|
||||||
@ -730,6 +732,72 @@ class PriceBreak(models.Model):
|
|||||||
return converted.amount
|
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):
|
class ColorTheme(models.Model):
|
||||||
""" Color Theme Setting """
|
""" Color Theme Setting """
|
||||||
|
|
||||||
|
@ -6,8 +6,6 @@ Company database model definitions
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import decimal
|
|
||||||
import math
|
|
||||||
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from django.core.validators import MinValueValidator
|
from django.core.validators import MinValueValidator
|
||||||
@ -26,7 +24,6 @@ from markdownx.models import MarkdownxField
|
|||||||
from stdimage.models import StdImageField
|
from stdimage.models import StdImageField
|
||||||
|
|
||||||
from InvenTree.helpers import getMediaUrl, getBlankImage, getBlankThumbnail
|
from InvenTree.helpers import getMediaUrl, getBlankImage, getBlankThumbnail
|
||||||
from InvenTree.helpers import normalize
|
|
||||||
from InvenTree.fields import InvenTreeURLField
|
from InvenTree.fields import InvenTreeURLField
|
||||||
from InvenTree.status_codes import PurchaseOrderStatus
|
from InvenTree.status_codes import PurchaseOrderStatus
|
||||||
|
|
||||||
@ -558,70 +555,7 @@ class SupplierPart(models.Model):
|
|||||||
price=price
|
price=price
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_price(self, quantity, moq=True, multiples=True, currency=None):
|
get_price = common.models.get_price
|
||||||
""" 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
|
|
||||||
|
|
||||||
def open_orders(self):
|
def open_orders(self):
|
||||||
""" Return a database query for PO line items for this SupplierPart,
|
""" Return a database query for PO line items for this SupplierPart,
|
||||||
|
@ -211,6 +211,7 @@ class EditSalesOrderLineItemForm(HelperForm):
|
|||||||
'part',
|
'part',
|
||||||
'quantity',
|
'quantity',
|
||||||
'reference',
|
'reference',
|
||||||
|
'sale_price',
|
||||||
'notes'
|
'notes'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
24
InvenTree/order/migrations/0045_auto_20210504_1946.py
Normal file
24
InvenTree/order/migrations/0045_auto_20210504_1946.py
Normal file
@ -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),
|
||||||
|
),
|
||||||
|
]
|
@ -672,12 +672,22 @@ class SalesOrderLineItem(OrderLineItem):
|
|||||||
Attributes:
|
Attributes:
|
||||||
order: Link to the SalesOrder that this line item belongs to
|
order: Link to the SalesOrder that this line item belongs to
|
||||||
part: Link to a Part object (may be null)
|
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'))
|
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})
|
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:
|
class Meta:
|
||||||
unique_together = [
|
unique_together = [
|
||||||
]
|
]
|
||||||
|
@ -278,6 +278,7 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
|
|||||||
quantity = serializers.FloatField()
|
quantity = serializers.FloatField()
|
||||||
allocated = serializers.FloatField(source='allocated_quantity', read_only=True)
|
allocated = serializers.FloatField(source='allocated_quantity', read_only=True)
|
||||||
fulfilled = serializers.FloatField(source='fulfilled_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:
|
class Meta:
|
||||||
model = SalesOrderLineItem
|
model = SalesOrderLineItem
|
||||||
@ -294,6 +295,9 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
|
|||||||
'order_detail',
|
'order_detail',
|
||||||
'part',
|
'part',
|
||||||
'part_detail',
|
'part_detail',
|
||||||
|
'sale_price',
|
||||||
|
'sale_price_currency',
|
||||||
|
'sale_price_string',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -223,6 +223,14 @@ $("#so-lines-table").inventreeTable({
|
|||||||
field: 'quantity',
|
field: 'quantity',
|
||||||
title: '{% trans "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',
|
field: 'allocated',
|
||||||
{% if order.status == SalesOrderStatus.PENDING %}
|
{% if order.status == SalesOrderStatus.PENDING %}
|
||||||
@ -289,6 +297,7 @@ $("#so-lines-table").inventreeTable({
|
|||||||
html += makeIconButton('fa-tools', 'button-build', row.part, '{% trans "Build stock" %}');
|
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" %}');
|
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 %}
|
{% endblock %}
|
@ -31,6 +31,7 @@ purchase_order_urls = [
|
|||||||
url(r'^new/', views.PurchaseOrderCreate.as_view(), name='po-create'),
|
url(r'^new/', views.PurchaseOrderCreate.as_view(), name='po-create'),
|
||||||
|
|
||||||
url(r'^order-parts/', views.OrderParts.as_view(), name='order-parts'),
|
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
|
# Display detail view for a single purchase order
|
||||||
url(r'^(?P<pk>\d+)/', include(purchase_order_detail_urls)),
|
url(r'^(?P<pk>\d+)/', include(purchase_order_detail_urls)),
|
||||||
|
@ -6,13 +6,14 @@ Django views for interacting with Order app
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
from django.http.response import JsonResponse
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from django.views.generic import DetailView, ListView, UpdateView
|
from django.views.generic import DetailView, ListView, UpdateView
|
||||||
from django.views.generic.edit import FormMixin
|
from django.views.generic.edit import FormMixin
|
||||||
from django.forms import HiddenInput
|
from django.forms import HiddenInput, IntegerField
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
@ -29,6 +30,7 @@ from part.models import Part
|
|||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
|
|
||||||
from . import forms as order_forms
|
from . import forms as order_forms
|
||||||
|
from part.views import PartPricing
|
||||||
|
|
||||||
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
|
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
|
||||||
from InvenTree.helpers import DownloadFile, str2bool
|
from InvenTree.helpers import DownloadFile, str2bool
|
||||||
@ -1245,6 +1247,18 @@ class SOLineItemCreate(AjaxCreateView):
|
|||||||
|
|
||||||
return initials
|
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):
|
class SOLineItemEdit(AjaxUpdateView):
|
||||||
""" View for editing a SalesOrderLineItem """
|
""" View for editing a SalesOrderLineItem """
|
||||||
@ -1571,3 +1585,101 @@ class SalesOrderAllocationDelete(AjaxDeleteView):
|
|||||||
ajax_form_title = _("Remove allocation")
|
ajax_form_title = _("Remove allocation")
|
||||||
context_object_name = 'allocation'
|
context_object_name = 'allocation'
|
||||||
ajax_template_name = "order/so_allocation_delete.html"
|
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)
|
||||||
|
24
InvenTree/part/migrations/0065_auto_20210505_2144.py
Normal file
24
InvenTree/part/migrations/0065_auto_20210505_2144.py
Normal file
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
@ -1611,6 +1611,44 @@ class Part(MPTTModel):
|
|||||||
max(buy_price_range[1], bom_price_range[1])
|
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
|
@transaction.atomic
|
||||||
def copy_bom_from(self, other, clear=True, **kwargs):
|
def copy_bom_from(self, other, clear=True, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
@ -4,24 +4,20 @@
|
|||||||
|
|
||||||
{% block pre_form_content %}
|
{% block pre_form_content %}
|
||||||
|
|
||||||
<div class='alert alert-info alert-block'>
|
<table class='table table-striped table-condensed table-price-two'>
|
||||||
{% blocktrans %}Pricing information for:<br>{{part}}.{% endblocktrans %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h4>{% trans 'Quantity' %}</h4>
|
|
||||||
<table class='table table-striped table-condensed'>
|
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>{% trans 'Part' %}</b></td>
|
<td><b>{% trans 'Part' %}</b></td>
|
||||||
<td colspan='2'>{{ part }}</td>
|
<td>{{ part }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>{% trans 'Quantity' %}</b></td>
|
<td><b>{% trans 'Quantity' %}</b></td>
|
||||||
<td colspan='2'>{{ quantity }}</td>
|
<td>{{ quantity }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
{% if part.supplier_count > 0 %}
|
|
||||||
|
{% if part.supplier_count > 0 %}
|
||||||
<h4>{% trans 'Supplier Pricing' %}</h4>
|
<h4>{% trans 'Supplier Pricing' %}</h4>
|
||||||
<table class='table table-striped table-condensed'>
|
<table class='table table-striped table-condensed table-price-three'>
|
||||||
{% if min_total_buy_price %}
|
{% if min_total_buy_price %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>{% trans 'Unit Cost' %}</b></td>
|
<td><b>{% trans 'Unit Cost' %}</b></td>
|
||||||
@ -42,12 +38,12 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</table>
|
</table>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if part.bom_count > 0 %}
|
{% if part.bom_count > 0 %}
|
||||||
<h4>{% trans 'BOM Pricing' %}</h4>
|
<h4>{% trans 'BOM Pricing' %}</h4>
|
||||||
<table class='table table-striped table-condensed'>
|
<table class='table table-striped table-condensed table-price-three'>
|
||||||
{% if min_total_bom_price %}
|
{% if min_total_bom_price %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>{% trans 'Unit Cost' %}</b></td>
|
<td><b>{% trans 'Unit Cost' %}</b></td>
|
||||||
@ -75,8 +71,22 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</table>
|
</table>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if total_part_price %}
|
||||||
|
<h4>{% trans 'Sale Price' %}</h4>
|
||||||
|
<table class='table table-striped table-condensed table-price-two'>
|
||||||
|
<tr>
|
||||||
|
<td><b>{% trans 'Unit Cost' %}</b></td>
|
||||||
|
<td>{% include "price.html" with price=unit_part_price %}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>{% trans 'Total Cost' %}</b></td>
|
||||||
|
<td>{% include "price.html" with price=total_part_price %}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if min_unit_buy_price or min_unit_bom_price %}
|
{% if min_unit_buy_price or min_unit_bom_price %}
|
||||||
{% else %}
|
{% else %}
|
||||||
@ -84,7 +94,5 @@
|
|||||||
{% trans 'No pricing information is available for this part.' %}
|
{% trans 'No pricing information is available for this part.' %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
@ -200,8 +200,18 @@ class I18nStaticNode(StaticNode):
|
|||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
@register.tag('i18n_static')
|
# use the dynamic url - tag if in Debugging-Mode
|
||||||
def do_i18n_static(parser, token):
|
if settings.DEBUG:
|
||||||
|
|
||||||
|
@register.simple_tag()
|
||||||
|
def i18n_static(url_name):
|
||||||
|
""" simple tag to enable {% url %} functionality instead of {% static %} """
|
||||||
|
return reverse(url_name)
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
@register.tag('i18n_static')
|
||||||
|
def do_i18n_static(parser, token):
|
||||||
"""
|
"""
|
||||||
Overrides normal static, adds language - lookup for prerenderd files #1485
|
Overrides normal static, adds language - lookup for prerenderd files #1485
|
||||||
|
|
||||||
|
@ -30,7 +30,6 @@ sale_price_break_urls = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
part_parameter_urls = [
|
part_parameter_urls = [
|
||||||
|
|
||||||
url(r'^template/new/', views.PartParameterTemplateCreate.as_view(), name='part-param-template-create'),
|
url(r'^template/new/', views.PartParameterTemplateCreate.as_view(), name='part-param-template-create'),
|
||||||
url(r'^template/(?P<pk>\d+)/edit/', views.PartParameterTemplateEdit.as_view(), name='part-param-template-edit'),
|
url(r'^template/(?P<pk>\d+)/edit/', views.PartParameterTemplateEdit.as_view(), name='part-param-template-edit'),
|
||||||
url(r'^template/(?P<pk>\d+)/delete/', views.PartParameterTemplateDelete.as_view(), name='part-param-template-edit'),
|
url(r'^template/(?P<pk>\d+)/delete/', views.PartParameterTemplateDelete.as_view(), name='part-param-template-edit'),
|
||||||
|
@ -1959,7 +1959,6 @@ class PartPricing(AjaxView):
|
|||||||
|
|
||||||
def get_quantity(self):
|
def get_quantity(self):
|
||||||
""" Return set quantity in decimal format """
|
""" Return set quantity in decimal format """
|
||||||
|
|
||||||
return Decimal(self.request.POST.get('quantity', 1))
|
return Decimal(self.request.POST.get('quantity', 1))
|
||||||
|
|
||||||
def get_part(self):
|
def get_part(self):
|
||||||
@ -1969,12 +1968,7 @@ class PartPricing(AjaxView):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def get_pricing(self, quantity=1, currency=None):
|
def get_pricing(self, quantity=1, currency=None):
|
||||||
|
""" returns context with pricing information """
|
||||||
# try:
|
|
||||||
# quantity = int(quantity)
|
|
||||||
# except ValueError:
|
|
||||||
# quantity = 1
|
|
||||||
|
|
||||||
if quantity <= 0:
|
if quantity <= 0:
|
||||||
quantity = 1
|
quantity = 1
|
||||||
|
|
||||||
@ -2044,11 +2038,22 @@ class PartPricing(AjaxView):
|
|||||||
ctx['max_total_bom_price'] = max_bom_price
|
ctx['max_total_bom_price'] = max_bom_price
|
||||||
ctx['max_unit_bom_price'] = max_unit_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
|
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):
|
def post(self, request, *args, **kwargs):
|
||||||
|
|
||||||
@ -2057,16 +2062,19 @@ class PartPricing(AjaxView):
|
|||||||
quantity = self.get_quantity()
|
quantity = self.get_quantity()
|
||||||
|
|
||||||
# Retain quantity value set by user
|
# Retain quantity value set by user
|
||||||
form = self.form_class()
|
form = self.form_class(initial=self.get_initials())
|
||||||
form.fields['quantity'].initial = quantity
|
|
||||||
|
|
||||||
# TODO - How to handle pricing in different currencies?
|
# TODO - How to handle pricing in different currencies?
|
||||||
currency = None
|
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)
|
# Always mark the form as 'invalid' (the user may wish to keep getting pricing data)
|
||||||
data = {
|
data['form_valid'] = False
|
||||||
'form_valid': False,
|
|
||||||
}
|
|
||||||
|
|
||||||
return self.renderJsonResponse(request, form, data=data, context=self.get_pricing(quantity, currency))
|
return self.renderJsonResponse(request, form, data=data, context=self.get_pricing(quantity, currency))
|
||||||
|
|
||||||
|
@ -377,6 +377,15 @@ function modalSubmit(modal, callback) {
|
|||||||
$(modal).on('click', '#modal-form-submit', function() {
|
$(modal).on('click', '#modal-form-submit', function() {
|
||||||
callback();
|
callback();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$(modal).on('click', '.modal-form-button', function() {
|
||||||
|
// Append data to form
|
||||||
|
var name = $(this).attr('name');
|
||||||
|
var value = $(this).attr('value');
|
||||||
|
var input = '<input id="id_act-btn_' + name + '" type="hidden" name="act-btn_' + name + '" value="' + value + '">';
|
||||||
|
$('.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 = "<span style='float: right;'>";
|
||||||
|
html += "<button name='" + options.name + "' type='submit' class='btn btn-default modal-form-button'";
|
||||||
|
html += " value='" + options.name + "'>" + options.title + "</button>";
|
||||||
|
html += "</span>";
|
||||||
|
|
||||||
|
$(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) {
|
function attachFieldCallback(modal, callback) {
|
||||||
/* Attach a 'callback' function to a given field in the modal form.
|
/* 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 submit_text = options.submit_text || '{% trans "Submit" %}';
|
||||||
var close_text = options.close_text || '{% trans "Close" %}';
|
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
|
// Form the ajax request to retrieve the django form data
|
||||||
ajax_data = {
|
ajax_data = {
|
||||||
url: url,
|
url: url,
|
||||||
@ -852,6 +883,10 @@ function launchModalForm(url, options = {}) {
|
|||||||
handleModalForm(url, options);
|
handleModalForm(url, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.buttons) {
|
||||||
|
attachButtons(modal, options.buttons);
|
||||||
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
$(modal).modal('hide');
|
$(modal).modal('hide');
|
||||||
showAlertDialog('{% trans "Invalid server response" %}', '{% trans "JSON response missing form data" %}');
|
showAlertDialog('{% trans "Invalid server response" %}', '{% trans "JSON response missing form data" %}');
|
||||||
|
@ -25,6 +25,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class='modal-footer'>
|
<div class='modal-footer'>
|
||||||
|
<div id='modal-footer-buttons'></div>
|
||||||
<button type='button' class='btn btn-default' id='modal-form-close' data-dismiss='modal'>{% trans "Close" %}</button>
|
<button type='button' class='btn btn-default' id='modal-form-close' data-dismiss='modal'>{% trans "Close" %}</button>
|
||||||
<button type='button' class='btn btn-primary' id='modal-form-submit'>{% trans "Submit" %}</button>
|
<button type='button' class='btn btn-primary' id='modal-form-submit'>{% trans "Submit" %}</button>
|
||||||
</div>
|
</div>
|
||||||
@ -49,6 +50,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class='modal-footer'>
|
<div class='modal-footer'>
|
||||||
|
<div id='modal-footer-buttons'></div>
|
||||||
<button type='button' class='btn btn-default' id='modal-form-close' data-dismiss='modal'>{% trans "Close" %}</button>
|
<button type='button' class='btn btn-default' id='modal-form-close' data-dismiss='modal'>{% trans "Close" %}</button>
|
||||||
<button type='button' class='btn btn-primary' id='modal-form-submit'>{% trans "Submit" %}</button>
|
<button type='button' class='btn btn-primary' id='modal-form-submit'>{% trans "Submit" %}</button>
|
||||||
</div>
|
</div>
|
||||||
@ -69,6 +71,7 @@
|
|||||||
<div class='modal-form-content'>
|
<div class='modal-form-content'>
|
||||||
</div>
|
</div>
|
||||||
<div class='modal-footer'>
|
<div class='modal-footer'>
|
||||||
|
<div id='modal-footer-buttons'></div>
|
||||||
<button type='button' class='btn btn-default' id='modal-form-cancel' data-dismiss='modal'>{% trans "Cancel" %}</button>
|
<button type='button' class='btn btn-default' id='modal-form-cancel' data-dismiss='modal'>{% trans "Cancel" %}</button>
|
||||||
<button type='button' class='btn btn-primary' id='modal-form-accept'>{% trans "Accept" %}</button>
|
<button type='button' class='btn btn-primary' id='modal-form-accept'>{% trans "Accept" %}</button>
|
||||||
</div>
|
</div>
|
||||||
@ -90,6 +93,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class='modal-footer'>
|
<div class='modal-footer'>
|
||||||
|
<div id='modal-footer-buttons'></div>
|
||||||
<button type='button' class='btn btn-default' data-dismiss='modal'>{% trans "Close" %}</button>
|
<button type='button' class='btn btn-default' data-dismiss='modal'>{% trans "Close" %}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
Loading…
Reference in New Issue
Block a user