2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-29 20:16:44 +00:00

Merged master and resolved conflicts

This commit is contained in:
eeintech 2021-05-07 18:02:40 -04:00
commit 88000200b3
21 changed files with 454 additions and 286 deletions

View File

@ -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;

View File

@ -100,7 +100,7 @@ function makeIconButton(icon, cls, pk, title, options={}) {
if (options.disabled) { if (options.disabled) {
extraProps += "disabled='true' "; extraProps += "disabled='true' ";
} }
html += `<button pk='${pk}' id='${id}' class='${classes}' title='${title}' ${extraProps}>`; html += `<button pk='${pk}' id='${id}' class='${classes}' title='${title}' ${extraProps}>`;
html += `<span class='fas ${icon}'></span>`; html += `<span class='fas ${icon}'></span>`;
html += `</button>`; html += `</button>`;

View File

@ -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 """

View File

@ -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,

View File

@ -211,6 +211,7 @@ class EditSalesOrderLineItemForm(HelperForm):
'part', 'part',
'quantity', 'quantity',
'reference', 'reference',
'sale_price',
'notes' 'notes'
] ]

View 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),
),
]

View File

@ -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 = [
] ]

View File

@ -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',
] ]

View File

@ -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 %}
@ -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-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) { if (part.purchaseable) {
html += makeIconButton('fa-shopping-cart', 'button-buy', row.part, '{% trans "Purchase stock" %}'); html += makeIconButton('fa-shopping-cart', 'button-buy', row.part, '{% trans "Purchase stock" %}');
@ -288,7 +296,8 @@ $("#so-lines-table").inventreeTable({
if (part.assembly) { if (part.assembly) {
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 %}

View File

@ -32,6 +32,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)),

View File

@ -7,6 +7,7 @@ from __future__ import unicode_literals
from django.db import transaction from django.db import transaction
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
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
@ -14,7 +15,7 @@ from django.http import HttpResponseRedirect
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
@ -24,7 +25,7 @@ from .models import SalesOrder, SalesOrderLineItem, SalesOrderAttachment
from .models import SalesOrderAllocation from .models import SalesOrderAllocation
from .admin import POLineItemResource from .admin import POLineItemResource
from build.models import Build 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 stock.models import StockItem, StockLocation
from part.models import Part from part.models import Part
@ -32,6 +33,7 @@ from common.models import InvenTreeSetting
from common.views import FileManagementFormView from common.views import FileManagementFormView
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
@ -567,165 +569,6 @@ class SalesOrderShip(AjaxUpdateView):
return self.renderJsonResponse(request, form, data, context) 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): class PurchaseOrderExport(AjaxView):
""" File download for a purchase order """ File download for a purchase order
@ -1407,6 +1250,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 """
@ -1733,3 +1588,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)

View 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'),
),
]

View File

@ -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):
""" """

View File

@ -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 %}

View File

@ -200,18 +200,28 @@ 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:
"""
Overrides normal static, adds language - lookup for prerenderd files #1485
usage (like static): @register.simple_tag()
{% i18n_static path [as varname] %} def i18n_static(url_name):
""" """ simple tag to enable {% url %} functionality instead of {% static %} """
bits = token.split_contents() return reverse(url_name)
loc_name = settings.STATICFILES_I18_PREFIX
# change path to called ressource else:
bits[1] = f"'{loc_name}/{{lng}}.{bits[1][1:-1]}'"
token.contents = ' '.join(bits) @register.tag('i18n_static')
return I18nStaticNode.handle_token(parser, token) 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)

View File

@ -30,11 +30,10 @@ 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'),
url(r'^new/', views.PartParameterCreate.as_view(), name='part-param-create'), url(r'^new/', views.PartParameterCreate.as_view(), name='part-param-create'),
url(r'^(?P<pk>\d+)/edit/', views.PartParameterEdit.as_view(), name='part-param-edit'), url(r'^(?P<pk>\d+)/edit/', views.PartParameterEdit.as_view(), name='part-param-edit'),
url(r'^(?P<pk>\d+)/delete/', views.PartParameterDelete.as_view(), name='part-param-delete'), url(r'^(?P<pk>\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'^duplicate/', views.PartDuplicate.as_view(), name='part-duplicate'),
url(r'^make-variant/', views.MakePartVariant.as_view(), name='make-part-variant'), url(r'^make-variant/', views.MakePartVariant.as_view(), name='make-part-variant'),
url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'), url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'),
url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'), url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'),
url(r'^bom-duplicate/?', views.BomDuplicate.as_view(), name='duplicate-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'^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'^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'), 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'^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'^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'^notes/?', views.PartNotes.as_view(), name='part-notes'),
url(r'^qr_code/?', views.PartQRCode.as_view(), name='part-qr'), url(r'^qr_code/?', views.PartQRCode.as_view(), name='part-qr'),
# Normal thumbnail with form # 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'^subcategory/', views.CategoryDetail.as_view(template_name='part/subcategory.html'), name='category-subcategory'),
url(r'^parametric/', views.CategoryParametric.as_view(), name='category-parametric'), url(r'^parametric/', views.CategoryParametric.as_view(), name='category-parametric'),
# Anything else # Anything else
url(r'^.*$', views.CategoryDetail.as_view(), name='category-detail'), url(r'^.*$', views.CategoryDetail.as_view(), name='category-detail'),
])) ]))

View File

@ -1956,10 +1956,9 @@ class PartPricing(AjaxView):
form_class = part_forms.PartPriceForm form_class = part_forms.PartPriceForm
role_required = ['sales_order.view', 'part.view'] role_required = ['sales_order.view', 'part.view']
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))

View File

@ -56,7 +56,7 @@ class LocationAdmin(ImportExportModelAdmin):
class StockItemResource(ModelResource): class StockItemResource(ModelResource):
""" Class for managing StockItem data import/export """ """ 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 = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
part_name = Field(attribute='part__full_name', readonly=True) part_name = Field(attribute='part__full_name', readonly=True)

View File

@ -228,7 +228,7 @@ class StockItem(MPTTModel):
super(StockItem, self).validate_unique(exclude) super(StockItem, self).validate_unique(exclude)
# If the serial number is set, make sure it is not a duplicate # 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 # Query to look for duplicate serial numbers
parts = PartModels.Part.objects.filter(tree_id=self.part.tree_id) parts = PartModels.Part.objects.filter(tree_id=self.part.tree_id)
stock = StockItem.objects.filter(part__in=parts, serial=self.serial) stock = StockItem.objects.filter(part__in=parts, serial=self.serial)
@ -281,7 +281,7 @@ class StockItem(MPTTModel):
if self.part is not None: if self.part is not None:
# A part with a serial number MUST have the quantity set to 1 # 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: if self.quantity > 1:
raise ValidationError({ raise ValidationError({
'quantity': _('Quantity must be 1 for item with a serial number'), 'quantity': _('Quantity must be 1 for item with a serial number'),

View File

@ -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" %}');

View File

@ -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>