Merge branch 'master' of https://github.com/inventree/InvenTree into matmair/issue2301

This commit is contained in:
Matthias 2022-04-27 13:13:04 +02:00
commit 6c23c94700
No known key found for this signature in database
GPG Key ID: AB6D0E6C4CB65093
29 changed files with 8475 additions and 406 deletions

View File

@ -4,11 +4,21 @@ InvenTree API version information
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 40 INVENTREE_API_VERSION = 43
""" """
Increment this API version number whenever there is a significant change to the API that any clients need to know about Increment this API version number whenever there is a significant change to the API that any clients need to know about
v43 -> 2022-04-26 : https://github.com/inventree/InvenTree/pull/2875
- Adds API detail endpoint for PartSalePrice model
- Adds API detail endpoint for PartInternalPrice model
v42 -> 2022-04-26 : https://github.com/inventree/InvenTree/pull/2833
- Adds variant stock information to the Part and BomItem serializers
v41 -> 2022-04-26
- Fixes 'variant_of' filter for Part list endpoint
v40 -> 2022-04-19 v40 -> 2022-04-19
- Adds ability to filter StockItem list by "tracked" parameter - Adds ability to filter StockItem list by "tracked" parameter
- This checks the serial number or batch code fields - This checks the serial number or batch code fields

View File

@ -1,11 +1,8 @@
from django.shortcuts import HttpResponseRedirect from django.shortcuts import HttpResponseRedirect
from django.urls import reverse_lazy, Resolver404 from django.urls import reverse_lazy, Resolver404
from django.db import connection
from django.shortcuts import redirect from django.shortcuts import redirect
from django.conf.urls import include, url from django.conf.urls import include, url
import logging import logging
import time
import operator
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from allauth_2fa.middleware import BaseRequire2FAMiddleware, AllauthTwoFactorMiddleware from allauth_2fa.middleware import BaseRequire2FAMiddleware, AllauthTwoFactorMiddleware
@ -92,67 +89,6 @@ class AuthRequiredMiddleware(object):
return response return response
class QueryCountMiddleware(object):
"""
This middleware will log the number of queries run
and the total time taken for each request (with a
status code of 200). It does not currently support
multi-db setups.
To enable this middleware, set 'log_queries: True' in the local InvenTree config file.
Reference: https://www.dabapps.com/blog/logging-sql-queries-django-13/
Note: 2020-08-15 - This is no longer used, instead we now rely on the django-debug-toolbar addon
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
t_start = time.time()
response = self.get_response(request)
t_stop = time.time()
if response.status_code == 200:
total_time = 0
if len(connection.queries) > 0:
queries = {}
for query in connection.queries:
query_time = query.get('time')
sql = query.get('sql').split('.')[0]
if sql in queries:
queries[sql] += 1
else:
queries[sql] = 1
if query_time is None:
# django-debug-toolbar monkeypatches the connection
# cursor wrapper and adds extra information in each
# item in connection.queries. The query time is stored
# under the key "duration" rather than "time" and is
# in milliseconds, not seconds.
query_time = float(query.get('duration', 0))
total_time += float(query_time)
logger.debug('{n} queries run, {a:.3f}s / {b:.3f}s'.format(
n=len(connection.queries),
a=total_time,
b=(t_stop - t_start)))
for x in sorted(queries.items(), key=operator.itemgetter(1), reverse=True):
print(x[0], ':', x[1])
return response
url_matcher = url('', include(frontendpatterns)) url_matcher = url('', include(frontendpatterns))

View File

@ -688,7 +688,8 @@ LANGUAGES = [
('nl', _('Dutch')), ('nl', _('Dutch')),
('no', _('Norwegian')), ('no', _('Norwegian')),
('pl', _('Polish')), ('pl', _('Polish')),
('pt', _('Portugese')), ('pt', _('Portuguese')),
('pt-BR', _('Portuguese (Brazilian)')),
('ru', _('Russian')), ('ru', _('Russian')),
('sv', _('Swedish')), ('sv', _('Swedish')),
('th', _('Thai')), ('th', _('Thai')),

View File

@ -871,6 +871,9 @@ class Build(MPTTModel, ReferenceIndexingMixin):
part__in=[p for p in available_parts], part__in=[p for p in available_parts],
) )
# Filter out "serialized" stock items, these cannot be auto-allocated
available_stock = available_stock.filter(Q(serial=None) | Q(serial=''))
if location: if location:
# Filter only stock items located "below" the specified location # Filter only stock items located "below" the specified location
sublocations = location.get_descendants(include_self=True) sublocations = location.get_descendants(include_self=True)

View File

@ -939,6 +939,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': bool, 'validator': bool,
}, },
'STOCK_BATCH_CODE_TEMPLATE': {
'name': _('Batch Code Template'),
'description': _('Template for generating default batch codes for stock items'),
'default': '',
},
'STOCK_ENABLE_EXPIRY': { 'STOCK_ENABLE_EXPIRY': {
'name': _('Stock Expiry'), 'name': _('Stock Expiry'),
'description': _('Enable stock expiry functionality'), 'description': _('Enable stock expiry functionality'),

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -262,6 +262,15 @@ class CategoryTree(generics.ListAPIView):
ordering = ['level', 'name'] ordering = ['level', 'name']
class PartSalePriceDetail(generics.RetrieveUpdateDestroyAPIView):
"""
Detail endpoint for PartSellPriceBreak model
"""
queryset = PartSellPriceBreak.objects.all()
serializer_class = part_serializers.PartSalePriceSerializer
class PartSalePriceList(generics.ListCreateAPIView): class PartSalePriceList(generics.ListCreateAPIView):
""" """
API endpoint for list view of PartSalePriceBreak model API endpoint for list view of PartSalePriceBreak model
@ -279,6 +288,15 @@ class PartSalePriceList(generics.ListCreateAPIView):
] ]
class PartInternalPriceDetail(generics.RetrieveUpdateDestroyAPIView):
"""
Detail endpoint for PartInternalPriceBreak model
"""
queryset = PartInternalPriceBreak.objects.all()
serializer_class = part_serializers.PartInternalPriceSerializer
class PartInternalPriceList(generics.ListCreateAPIView): class PartInternalPriceList(generics.ListCreateAPIView):
""" """
API endpoint for list view of PartInternalPriceBreak model API endpoint for list view of PartInternalPriceBreak model
@ -1175,6 +1193,18 @@ class PartList(generics.ListCreateAPIView):
except (ValueError, Part.DoesNotExist): except (ValueError, Part.DoesNotExist):
pass pass
# Filter by 'variant_of'
# Note that this is subtly different from 'ancestor' filter (above)
variant_of = params.get('variant_of', None)
if variant_of is not None:
try:
template = Part.objects.get(pk=variant_of)
variants = template.get_children()
queryset = queryset.filter(pk__in=[v.pk for v in variants])
except (ValueError, Part.DoesNotExist):
pass
# Filter only parts which are in the "BOM" for a given part # Filter only parts which are in the "BOM" for a given part
in_bom_for = params.get('in_bom_for', None) in_bom_for = params.get('in_bom_for', None)
@ -1339,10 +1369,6 @@ class PartList(generics.ListCreateAPIView):
filters.OrderingFilter, filters.OrderingFilter,
] ]
filter_fields = [
'variant_of',
]
ordering_fields = [ ordering_fields = [
'name', 'name',
'creation_date', 'creation_date',
@ -1912,11 +1938,13 @@ part_api_urls = [
# Base URL for part sale pricing # Base URL for part sale pricing
url(r'^sale-price/', include([ url(r'^sale-price/', include([
url(r'^(?P<pk>\d+)/', PartSalePriceDetail.as_view(), name='api-part-sale-price-detail'),
url(r'^.*$', PartSalePriceList.as_view(), name='api-part-sale-price-list'), url(r'^.*$', PartSalePriceList.as_view(), name='api-part-sale-price-list'),
])), ])),
# Base URL for part internal pricing # Base URL for part internal pricing
url(r'^internal-price/', include([ url(r'^internal-price/', include([
url(r'^(?P<pk>\d+)/', PartInternalPriceDetail.as_view(), name='api-part-internal-price-detail'),
url(r'^.*$', PartInternalPriceList.as_view(), name='api-part-internal-price-list'), url(r'^.*$', PartInternalPriceList.as_view(), name='api-part-internal-price-list'),
])), ])),

View File

@ -7,6 +7,7 @@
part: 100 part: 100
sub_part: 1 sub_part: 1
quantity: 10 quantity: 10
allow_variants: True
# 40 x R_2K2_0805 # 40 x R_2K2_0805
- model: part.bomitem - model: part.bomitem

View File

@ -177,6 +177,7 @@
fields: fields:
name: 'Green chair variant' name: 'Green chair variant'
variant_of: 10003 variant_of: 10003
is_template: true
category: 7 category: 7
trackable: true trackable: true
tree_id: 1 tree_id: 1

View File

@ -2732,7 +2732,21 @@ class BomItem(models.Model, DataImportMixin):
for sub in self.substitutes.all(): for sub in self.substitutes.all():
parts.add(sub.part) parts.add(sub.part)
return parts valid_parts = []
for p in parts:
# Inactive parts cannot be 'auto allocated'
if not p.active:
continue
# Trackable parts cannot be 'auto allocated'
if p.trackable:
continue
valid_parts.append(p)
return valid_parts
def is_stock_item_valid(self, stock_item): def is_stock_item_valid(self, stock_item):
""" """

View File

@ -7,7 +7,9 @@ from decimal import Decimal
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.db import models, transaction from django.db import models, transaction
from django.db.models import ExpressionWrapper, F, Q from django.db.models import ExpressionWrapper, F, Q, Func
from django.db.models import Subquery, OuterRef, FloatField
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@ -15,6 +17,8 @@ from rest_framework import serializers
from sql_util.utils import SubqueryCount, SubquerySum from sql_util.utils import SubqueryCount, SubquerySum
from djmoney.contrib.django_rest_framework import MoneyField from djmoney.contrib.django_rest_framework import MoneyField
from common.settings import currency_code_default, currency_code_mappings
from InvenTree.serializers import (DataFileUploadSerializer, from InvenTree.serializers import (DataFileUploadSerializer,
DataFileExtractSerializer, DataFileExtractSerializer,
InvenTreeAttachmentSerializerField, InvenTreeAttachmentSerializerField,
@ -146,6 +150,13 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
allow_null=True allow_null=True
) )
price_currency = serializers.ChoiceField(
choices=currency_code_mappings(),
default=currency_code_default,
label=_('Currency'),
help_text=_('Purchase currency of this stock item'),
)
price_string = serializers.CharField(source='price', read_only=True) price_string = serializers.CharField(source='price', read_only=True)
class Meta: class Meta:
@ -155,6 +166,7 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
'part', 'part',
'quantity', 'quantity',
'price', 'price',
'price_currency',
'price_string', 'price_string',
] ]
@ -170,6 +182,13 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
allow_null=True allow_null=True
) )
price_currency = serializers.ChoiceField(
choices=currency_code_mappings(),
default=currency_code_default,
label=_('Currency'),
help_text=_('Purchase currency of this stock item'),
)
price_string = serializers.CharField(source='price', read_only=True) price_string = serializers.CharField(source='price', read_only=True)
class Meta: class Meta:
@ -179,6 +198,7 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
'part', 'part',
'quantity', 'quantity',
'price', 'price',
'price_currency',
'price_string', 'price_string',
] ]
@ -308,9 +328,6 @@ class PartSerializer(InvenTreeModelSerializer):
to reduce database trips. to reduce database trips.
""" """
# TODO: Update the "in_stock" annotation to include stock for variants of the part
# Ref: https://github.com/inventree/InvenTree/issues/2240
# Annotate with the total 'in stock' quantity # Annotate with the total 'in stock' quantity
queryset = queryset.annotate( queryset = queryset.annotate(
in_stock=Coalesce( in_stock=Coalesce(
@ -325,6 +342,24 @@ class PartSerializer(InvenTreeModelSerializer):
stock_item_count=SubqueryCount('stock_items') stock_item_count=SubqueryCount('stock_items')
) )
# Annotate with the total variant stock quantity
variant_query = StockItem.objects.filter(
part__tree_id=OuterRef('tree_id'),
part__lft__gt=OuterRef('lft'),
part__rght__lt=OuterRef('rght'),
).filter(StockItem.IN_STOCK_FILTER)
queryset = queryset.annotate(
variant_stock=Coalesce(
Subquery(
variant_query.annotate(
total=Func(F('quantity'), function='SUM', output_field=FloatField())
).values('total')),
0,
output_field=FloatField(),
)
)
# Filter to limit builds to "active" # Filter to limit builds to "active"
build_filter = Q( build_filter = Q(
status__in=BuildStatus.ACTIVE_CODES status__in=BuildStatus.ACTIVE_CODES
@ -429,6 +464,7 @@ class PartSerializer(InvenTreeModelSerializer):
unallocated_stock = serializers.FloatField(read_only=True) unallocated_stock = serializers.FloatField(read_only=True)
building = serializers.FloatField(read_only=True) building = serializers.FloatField(read_only=True)
in_stock = serializers.FloatField(read_only=True) in_stock = serializers.FloatField(read_only=True)
variant_stock = serializers.FloatField(read_only=True)
ordering = serializers.FloatField(read_only=True) ordering = serializers.FloatField(read_only=True)
stock_item_count = serializers.IntegerField(read_only=True) stock_item_count = serializers.IntegerField(read_only=True)
suppliers = serializers.IntegerField(read_only=True) suppliers = serializers.IntegerField(read_only=True)
@ -463,6 +499,7 @@ class PartSerializer(InvenTreeModelSerializer):
'full_name', 'full_name',
'image', 'image',
'in_stock', 'in_stock',
'variant_stock',
'ordering', 'ordering',
'building', 'building',
'IPN', 'IPN',
@ -577,9 +614,10 @@ class BomItemSerializer(InvenTreeModelSerializer):
purchase_price_range = serializers.SerializerMethodField() purchase_price_range = serializers.SerializerMethodField()
# Annotated fields # Annotated fields for available stock
available_stock = serializers.FloatField(read_only=True) available_stock = serializers.FloatField(read_only=True)
available_substitute_stock = serializers.FloatField(read_only=True) available_substitute_stock = serializers.FloatField(read_only=True)
available_variant_stock = serializers.FloatField(read_only=True)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
# part_detail and sub_part_detail serializers are only included if requested. # part_detail and sub_part_detail serializers are only included if requested.
@ -613,11 +651,18 @@ class BomItemSerializer(InvenTreeModelSerializer):
queryset = queryset.prefetch_related('sub_part') queryset = queryset.prefetch_related('sub_part')
queryset = queryset.prefetch_related('sub_part__category') queryset = queryset.prefetch_related('sub_part__category')
queryset = queryset.prefetch_related( queryset = queryset.prefetch_related(
'sub_part__stock_items', 'sub_part__stock_items',
'sub_part__stock_items__allocations', 'sub_part__stock_items__allocations',
'sub_part__stock_items__sales_order_allocations', 'sub_part__stock_items__sales_order_allocations',
) )
queryset = queryset.prefetch_related(
'substitutes',
'substitutes__part__stock_items',
)
queryset = queryset.prefetch_related('sub_part__supplier_parts__pricebreaks') queryset = queryset.prefetch_related('sub_part__supplier_parts__pricebreaks')
return queryset return queryset
@ -707,7 +752,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
), ),
) )
# Calculate 'available_variant_stock' field # Calculate 'available_substitute_stock' field
queryset = queryset.annotate( queryset = queryset.annotate(
available_substitute_stock=ExpressionWrapper( available_substitute_stock=ExpressionWrapper(
F('substitute_stock') - F('substitute_build_allocations') - F('substitute_sales_allocations'), F('substitute_stock') - F('substitute_build_allocations') - F('substitute_sales_allocations'),
@ -715,6 +760,47 @@ class BomItemSerializer(InvenTreeModelSerializer):
) )
) )
# Annotate the queryset with 'available variant stock' information
variant_stock_query = StockItem.objects.filter(
part__tree_id=OuterRef('sub_part__tree_id'),
part__lft__gt=OuterRef('sub_part__lft'),
part__rght__lt=OuterRef('sub_part__rght'),
).filter(StockItem.IN_STOCK_FILTER)
queryset = queryset.alias(
variant_stock_total=Coalesce(
Subquery(
variant_stock_query.annotate(
total=Func(F('quantity'), function='SUM', output_field=FloatField())
).values('total')),
0,
output_field=FloatField()
),
variant_stock_build_order_allocations=Coalesce(
Subquery(
variant_stock_query.annotate(
total=Func(F('sales_order_allocations__quantity'), function='SUM', output_field=FloatField()),
).values('total')),
0,
output_field=FloatField(),
),
variant_stock_sales_order_allocations=Coalesce(
Subquery(
variant_stock_query.annotate(
total=Func(F('allocations__quantity'), function='SUM', output_field=FloatField()),
).values('total')),
0,
output_field=FloatField(),
)
)
queryset = queryset.annotate(
available_variant_stock=ExpressionWrapper(
F('variant_stock_total') - F('variant_stock_build_order_allocations') - F('variant_stock_sales_order_allocations'),
output_field=FloatField(),
)
)
return queryset return queryset
def get_purchase_price_range(self, obj): def get_purchase_price_range(self, obj):
@ -790,6 +876,8 @@ class BomItemSerializer(InvenTreeModelSerializer):
# Annotated fields describing available quantity # Annotated fields describing available quantity
'available_stock', 'available_stock',
'available_substitute_stock', 'available_substitute_stock',
'available_variant_stock',
] ]

View File

@ -124,8 +124,7 @@
</div> </div>
</div> </div>
{% settings_value "PART_SHOW_PRICE_HISTORY" as show_price_history %} {% if part.purchaseable or part.salable %}
{% if show_price_history %}
<div class='panel panel-hidden' id='panel-pricing'> <div class='panel panel-hidden' id='panel-pricing'>
{% include "part/prices.html" %} {% include "part/prices.html" %}
</div> </div>
@ -1009,7 +1008,7 @@
pb_url_slug: 'internal-price', pb_url_slug: 'internal-price',
pb_url: '{% url 'api-part-internal-price-list' %}', pb_url: '{% url 'api-part-internal-price-list' %}',
pb_new_btn: $('#new-internal-price-break'), pb_new_btn: $('#new-internal-price-break'),
pb_new_url: '{% url 'internal-price-break-create' %}', pb_new_url: '{% url 'api-part-internal-price-list' %}',
linkedGraph: $('#InternalPriceBreakChart'), linkedGraph: $('#InternalPriceBreakChart'),
}, },
); );
@ -1025,7 +1024,7 @@
pb_url_slug: 'sale-price', pb_url_slug: 'sale-price',
pb_url: "{% url 'api-part-sale-price-list' %}", pb_url: "{% url 'api-part-sale-price-list' %}",
pb_new_btn: $('#new-price-break'), pb_new_btn: $('#new-price-break'),
pb_new_url: '{% url 'sale-price-break-create' %}', pb_new_url: '{% url 'api-part-sale-price-list' %}',
linkedGraph: $('#SalePriceBreakChart'), linkedGraph: $('#SalePriceBreakChart'),
}, },
); );

View File

@ -211,44 +211,18 @@
{% if part.component %} {% if part.component %}
{% if required_build_order_quantity > 0 %} {% if required_build_order_quantity > 0 %}
<tr> <tr>
<td><span class='fas fa-clipboard-list'></span></td> <td><span class='fas fa-tools'></span></td>
<td>{% trans "Required for Build Orders" %}</td>
<td>{% decimal required_build_order_quantity %}</td>
</tr>
<tr>
<td><span class='fas fa-dolly'></span></td>
<td>{% trans "Allocated to Build Orders" %}</td> <td>{% trans "Allocated to Build Orders" %}</td>
<td> <td>{% progress_bar allocated_build_order_quantity required_build_order_quantity id='build-order-allocated' max_width='150px' %}</td>
{% decimal allocated_build_order_quantity %}
{% if allocated_build_order_quantity < required_build_order_quantity %}
<span class='fas fa-times-circle icon-red float-right' title='{% trans "Required quantity has not been allocated" %}'></span>
{% else %}
<span class='fas fa-check-circle icon-green float-right' title='{% trans "Required quantity has been allocated" %}'></span>
{% endif %}
</td>
</tr> </tr>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if part.salable %} {% if part.salable %}
{% if required_sales_order_quantity > 0 %} {% if required_sales_order_quantity > 0 %}
<tr> <tr>
<td><span class='fas fa-clipboard-list'></span></td> <td><span class='fas fa-truck'></span></td>
<td>{% trans "Required for Sales Orders" %}</td>
<td>
{% decimal required_sales_order_quantity %}
</td>
</tr>
<tr>
<td><span class='fas fa-dolly'></span></td>
<td>{% trans "Allocated to Sales Orders" %}</td> <td>{% trans "Allocated to Sales Orders" %}</td>
<td> <td>{% progress_bar allocated_sales_order_quantity required_sales_order_quantity id='sales-order-allocated' max_width='150px' %}</td>
{% decimal allocated_sales_order_quantity %}
{% if allocated_sales_order_quantity < required_sales_order_quantity %}
<span class='fas fa-times-circle icon-red float-right' title='{% trans "Required quantity has not been allocated" %}'></span>
{% else %}
<span class='fas fa-check-circle icon-green float-right' title='{% trans "Required quantity has been allocated" %}'></span>
{% endif %}
</td>
</tr> </tr>
{% endif %} {% endif %}
{% endif %} {% endif %}

View File

@ -4,7 +4,6 @@
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %} {% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
{% settings_value 'PART_SHOW_RELATED' as show_related %} {% settings_value 'PART_SHOW_RELATED' as show_related %}
{% settings_value "PART_SHOW_PRICE_HISTORY" as show_price_history %}
{% trans "Parameters" as text %} {% trans "Parameters" as text %}
{% include "sidebar_item.html" with label="part-parameters" text=text icon="fa-th-list" %} {% include "sidebar_item.html" with label="part-parameters" text=text icon="fa-th-list" %}
@ -28,7 +27,7 @@
{% trans "Used In" as text %} {% trans "Used In" as text %}
{% include "sidebar_item.html" with label="used-in" text=text icon="fa-layer-group" %} {% include "sidebar_item.html" with label="used-in" text=text icon="fa-layer-group" %}
{% endif %} {% endif %}
{% if show_price_history %} {% if part.purchaseable or part.salable %}
{% trans "Pricing" as text %} {% trans "Pricing" as text %}
{% include "sidebar_item.html" with label="pricing" text=text icon="fa-dollar-sign" %} {% include "sidebar_item.html" with label="pricing" text=text icon="fa-dollar-sign" %}
{% endif %} {% endif %}

View File

@ -3,6 +3,9 @@
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% load inventree_extras %} {% load inventree_extras %}
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
{% if show_price_history %}
<div class='panel-heading'> <div class='panel-heading'>
<h4>{% trans "Pricing Information" %}</h4> <h4>{% trans "Pricing Information" %}</h4>
</div> </div>
@ -43,7 +46,7 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if part.bom_count > 0 %} {% if part.assembly and part.bom_count > 0 %}
{% if min_total_bom_price %} {% if min_total_bom_price %}
<tr> <tr>
<td><strong>{% trans 'BOM Pricing' %}</strong> <td><strong>{% trans 'BOM Pricing' %}</strong>
@ -147,7 +150,7 @@
</div> </div>
</div> </div>
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %} {% endif %}
{% if part.purchaseable and roles.purchase_order.view %} {% if part.purchaseable and roles.purchase_order.view %}
<a class="anchor" id="supplier-cost"></a> <a class="anchor" id="supplier-cost"></a>
@ -170,7 +173,7 @@
</div> </div>
</div> </div>
{% if price_history %} {% if show_price_history %}
<a class="anchor" id="purchase-price"></a> <a class="anchor" id="purchase-price"></a>
<div class='panel-heading'> <div class='panel-heading'>
<h4>{% trans "Purchase Price" %} <h4>{% trans "Purchase Price" %}
@ -279,6 +282,7 @@
</div> </div>
</div> </div>
{% if show_price_history %}
<a class="anchor" id="sale-price"></a> <a class="anchor" id="sale-price"></a>
<div class='panel-heading'> <div class='panel-heading'>
<h4>{% trans "Sale Price" %} <h4>{% trans "Sale Price" %}
@ -298,3 +302,5 @@
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
{% endif %}

View File

@ -366,21 +366,24 @@ def visible_global_settings(*args, **kwargs):
@register.simple_tag() @register.simple_tag()
def progress_bar(val, max, *args, **kwargs): def progress_bar(val, max_val, *args, **kwargs):
""" """
Render a progress bar element Render a progress bar element
""" """
item_id = kwargs.get('id', 'progress-bar') item_id = kwargs.get('id', 'progress-bar')
if val > max: val = InvenTree.helpers.normalize(val)
max_val = InvenTree.helpers.normalize(max_val)
if val > max_val:
style = 'progress-bar-over' style = 'progress-bar-over'
elif val < max: elif val < max_val:
style = 'progress-bar-under' style = 'progress-bar-under'
else: else:
style = '' style = ''
percent = float(val / max) * 100 percent = float(val / max_val) * 100
if percent > 100: if percent > 100:
percent = 100 percent = 100
@ -397,7 +400,7 @@ def progress_bar(val, max, *args, **kwargs):
html = f""" html = f"""
<div id='{item_id}' class='progress' style='{" ".join(style_tags)}'> <div id='{item_id}' class='progress' style='{" ".join(style_tags)}'>
<div class='progress-bar {style}' role='progressbar' aria-valuemin='0' aria-valuemax='100' style='width:{percent}%'></div> <div class='progress-bar {style}' role='progressbar' aria-valuemin='0' aria-valuemax='100' style='width:{percent}%'></div>
<div class='progress-value'>{val} / {max}</div> <div class='progress-value'>{val} / {max_val}</div>
</div> </div>
""" """

View File

@ -567,6 +567,185 @@ class PartAPITest(InvenTreeAPITestCase):
self.assertEqual(response.data['name'], name) self.assertEqual(response.data['name'], name)
self.assertEqual(response.data['description'], description) self.assertEqual(response.data['description'], description)
def test_template_filters(self):
"""
Unit tests for API filters related to template parts:
- variant_of : Return children of specified part
- ancestor : Return descendants of specified part
Uses the 'chair template' part (pk=10000)
"""
# Rebuild the MPTT structure before running these tests
Part.objects.rebuild()
url = reverse('api-part-list')
response = self.get(
url,
{
'variant_of': 10000,
},
expected_code=200
)
# 3 direct children of template part
self.assertEqual(len(response.data), 3)
response = self.get(
url,
{
'ancestor': 10000,
},
expected_code=200,
)
# 4 total descendants
self.assertEqual(len(response.data), 4)
# Use the 'green chair' as our reference
response = self.get(
url,
{
'variant_of': 10003,
},
expected_code=200,
)
self.assertEqual(len(response.data), 1)
response = self.get(
url,
{
'ancestor': 10003,
},
expected_code=200,
)
self.assertEqual(len(response.data), 1)
# Add some more variants
p = Part.objects.get(pk=10004)
for i in range(100):
Part.objects.create(
name=f'Chair variant {i}',
description='A new chair variant',
variant_of=p,
)
# There should still be only one direct variant
response = self.get(
url,
{
'variant_of': 10003,
},
expected_code=200,
)
self.assertEqual(len(response.data), 1)
# However, now should be 101 descendants
response = self.get(
url,
{
'ancestor': 10003,
},
expected_code=200,
)
self.assertEqual(len(response.data), 101)
def test_variant_stock(self):
"""
Unit tests for the 'variant_stock' annotation,
which provides a stock count for *variant* parts
"""
# Ensure the MPTT structure is in a known state before running tests
Part.objects.rebuild()
# Initially, there are no "chairs" in stock,
# so each 'chair' template should report variant_stock=0
url = reverse('api-part-list')
# Look at the "detail" URL for the master chair template
response = self.get('/api/part/10000/', {}, expected_code=200)
# This part should report 'zero' as variant stock
self.assertEqual(response.data['variant_stock'], 0)
# Grab a list of all variant chairs *under* the master template
response = self.get(
url,
{
'ancestor': 10000,
},
expected_code=200,
)
# 4 total descendants
self.assertEqual(len(response.data), 4)
for variant in response.data:
self.assertEqual(variant['variant_stock'], 0)
# Now, let's make some variant stock
for variant in Part.objects.get(pk=10000).get_descendants(include_self=False):
StockItem.objects.create(
part=variant,
quantity=100,
)
response = self.get('/api/part/10000/', {}, expected_code=200)
self.assertEqual(response.data['in_stock'], 0)
self.assertEqual(response.data['variant_stock'], 400)
# Check that each variant reports the correct stock quantities
response = self.get(
url,
{
'ancestor': 10000,
},
expected_code=200,
)
expected_variant_stock = {
10001: 0,
10002: 0,
10003: 100,
10004: 0,
}
for variant in response.data:
self.assertEqual(variant['in_stock'], 100)
self.assertEqual(variant['variant_stock'], expected_variant_stock[variant['pk']])
# Add some 'sub variants' for the green chair variant
green_chair = Part.objects.get(pk=10004)
for i in range(10):
gcv = Part.objects.create(
name=f"GC Var {i}",
description="Green chair variant",
variant_of=green_chair,
)
StockItem.objects.create(
part=gcv,
quantity=50,
)
# Spot check of some values
response = self.get('/api/part/10000/', {})
self.assertEqual(response.data['variant_stock'], 900)
response = self.get('/api/part/10004/', {})
self.assertEqual(response.data['variant_stock'], 500)
class PartDetailTests(InvenTreeAPITestCase): class PartDetailTests(InvenTreeAPITestCase):
""" """
@ -1450,6 +1629,44 @@ class BomItemTest(InvenTreeAPITestCase):
self.assertEqual(len(response.data), i) self.assertEqual(len(response.data), i)
def test_bom_variant_stock(self):
"""
Test for 'available_variant_stock' annotation
"""
Part.objects.rebuild()
# BOM item we are interested in
bom_item = BomItem.objects.get(pk=1)
response = self.get('/api/bom/1/', {}, expected_code=200)
# Initially, no variant stock available
self.assertEqual(response.data['available_variant_stock'], 0)
# Create some 'variants' of the referenced sub_part
bom_item.sub_part.is_template = True
bom_item.sub_part.save()
for i in range(10):
# Create a variant part
vp = Part.objects.create(
name=f"Var {i}",
description="Variant part",
variant_of=bom_item.sub_part,
)
# Create a stock item
StockItem.objects.create(
part=vp,
quantity=100,
)
# There should now be variant stock available
response = self.get('/api/bom/1/', {}, expected_code=200)
self.assertEqual(response.data['available_variant_stock'], 1000)
class PartParameterTest(InvenTreeAPITestCase): class PartParameterTest(InvenTreeAPITestCase):
""" """

View File

@ -13,18 +13,6 @@ from django.conf.urls import url, include
from . import views from . import views
sale_price_break_urls = [
url(r'^new/', views.PartSalePriceBreakCreate.as_view(), name='sale-price-break-create'),
url(r'^(?P<pk>\d+)/edit/', views.PartSalePriceBreakEdit.as_view(), name='sale-price-break-edit'),
url(r'^(?P<pk>\d+)/delete/', views.PartSalePriceBreakDelete.as_view(), name='sale-price-break-delete'),
]
internal_price_break_urls = [
url(r'^new/', views.PartInternalPriceBreakCreate.as_view(), name='internal-price-break-create'),
url(r'^(?P<pk>\d+)/edit/', views.PartInternalPriceBreakEdit.as_view(), name='internal-price-break-edit'),
url(r'^(?P<pk>\d+)/delete/', views.PartInternalPriceBreakDelete.as_view(), name='internal-price-break-delete'),
]
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'),
@ -86,12 +74,6 @@ part_urls = [
# Part category # Part category
url(r'^category/', include(category_urls)), url(r'^category/', include(category_urls)),
# Part price breaks
url(r'^sale-price/', include(sale_price_break_urls)),
# Part internal price breaks
url(r'^internal-price/', include(internal_price_break_urls)),
# Part parameters # Part parameters
url(r'^parameter/', include(part_parameter_urls)), url(r'^parameter/', include(part_parameter_urls)),

View File

@ -18,7 +18,6 @@ from django.forms import HiddenInput
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from moneyed import CURRENCIES
from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.models import convert_money
from djmoney.contrib.exchange.exceptions import MissingRate from djmoney.contrib.exchange.exceptions import MissingRate
@ -33,7 +32,6 @@ from decimal import Decimal
from .models import PartCategory, Part from .models import PartCategory, Part
from .models import PartParameterTemplate from .models import PartParameterTemplate
from .models import PartCategoryParameterTemplate from .models import PartCategoryParameterTemplate
from .models import PartSellPriceBreak, PartInternalPriceBreak
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from company.models import SupplierPart from company.models import SupplierPart
@ -389,8 +387,12 @@ class PartDetail(InvenTreeRoleMixin, DetailView):
context.update(**ctx) context.update(**ctx)
show_price_history = InvenTreeSetting.get_setting('PART_SHOW_PRICE_HISTORY', False)
context['show_price_history'] = show_price_history
# Pricing information # Pricing information
if InvenTreeSetting.get_setting('PART_SHOW_PRICE_HISTORY', False): if show_price_history:
ctx = self.get_pricing(self.get_quantity()) ctx = self.get_pricing(self.get_quantity())
ctx['form'] = self.form_class(initial=self.get_initials()) ctx['form'] = self.form_class(initial=self.get_initials())
@ -1226,102 +1228,3 @@ class CategoryParameterTemplateDelete(AjaxDeleteView):
return None return None
return self.object return self.object
class PartSalePriceBreakCreate(AjaxCreateView):
"""
View for creating a sale price break for a part
"""
model = PartSellPriceBreak
form_class = part_forms.EditPartSalePriceBreakForm
ajax_form_title = _('Add Price Break')
def get_data(self):
return {
'success': _('Added new price break')
}
def get_part(self):
try:
part = Part.objects.get(id=self.request.GET.get('part'))
except (ValueError, Part.DoesNotExist):
part = None
if part is None:
try:
part = Part.objects.get(id=self.request.POST.get('part'))
except (ValueError, Part.DoesNotExist):
part = None
return part
def get_form(self):
form = super(AjaxCreateView, self).get_form()
form.fields['part'].widget = HiddenInput()
return form
def get_initial(self):
initials = super(AjaxCreateView, self).get_initial()
initials['part'] = self.get_part()
default_currency = inventree_settings.currency_code_default()
currency = CURRENCIES.get(default_currency, None)
if currency is not None:
initials['price'] = [1.0, currency]
return initials
class PartSalePriceBreakEdit(AjaxUpdateView):
""" View for editing a sale price break """
model = PartSellPriceBreak
form_class = part_forms.EditPartSalePriceBreakForm
ajax_form_title = _('Edit Price Break')
def get_form(self):
form = super().get_form()
form.fields['part'].widget = HiddenInput()
return form
class PartSalePriceBreakDelete(AjaxDeleteView):
""" View for deleting a sale price break """
model = PartSellPriceBreak
ajax_form_title = _("Delete Price Break")
ajax_template_name = "modal_delete_form.html"
class PartInternalPriceBreakCreate(PartSalePriceBreakCreate):
""" View for creating a internal price break for a part """
model = PartInternalPriceBreak
form_class = part_forms.EditPartInternalPriceBreakForm
ajax_form_title = _('Add Internal Price Break')
permission_required = 'roles.sales_order.add'
class PartInternalPriceBreakEdit(PartSalePriceBreakEdit):
""" View for editing a internal price break """
model = PartInternalPriceBreak
form_class = part_forms.EditPartInternalPriceBreakForm
ajax_form_title = _('Edit Internal Price Break')
permission_required = 'roles.sales_order.change'
class PartInternalPriceBreakDelete(PartSalePriceBreakDelete):
""" View for deleting a internal price break """
model = PartInternalPriceBreak
ajax_form_title = _("Delete Internal Price Break")
permission_required = 'roles.sales_order.delete'

View File

@ -504,10 +504,10 @@ class APICallMixin:
@property @property
def api_headers(self): def api_headers(self):
return { headers = {'Content-Type': 'application/json'}
self.API_TOKEN: self.get_setting(self.API_TOKEN_SETTING), if getattr(self, 'API_TOKEN_SETTING'):
'Content-Type': 'application/json' headers[self.API_TOKEN] = self.get_setting(self.API_TOKEN_SETTING)
} return headers
def api_build_url_args(self, arguments): def api_build_url_args(self, arguments):
groups = [] groups = []
@ -515,16 +515,21 @@ class APICallMixin:
groups.append(f'{key}={",".join([str(a) for a in val])}') groups.append(f'{key}={",".join([str(a) for a in val])}')
return f'?{"&".join(groups)}' return f'?{"&".join(groups)}'
def api_call(self, endpoint, method: str = 'GET', url_args=None, data=None, headers=None, simple_response: bool = True): def api_call(self, endpoint, method: str = 'GET', url_args=None, data=None, headers=None, simple_response: bool = True, endpoint_is_url: bool = False):
if url_args: if url_args:
endpoint += self.api_build_url_args(url_args) endpoint += self.api_build_url_args(url_args)
if headers is None: if headers is None:
headers = self.api_headers headers = self.api_headers
if endpoint_is_url:
url = endpoint
else:
url = f'{self.api_url}/{endpoint}'
# build kwargs for call # build kwargs for call
kwargs = { kwargs = {
'url': f'{self.api_url}/{endpoint}', 'url': url,
'headers': headers, 'headers': headers,
} }
if data: if data:

View File

@ -1,116 +0,0 @@
"""
This script is used to simplify the translation process.
Django provides a framework for working out which strings are "translatable",
and these strings are then dumped in a file under InvenTree/locale/<lang>/LC_MESSAGES/django.po
This script presents the translator with a list of strings which have not yet been translated,
allowing for a simpler and quicker translation process.
If a string translation needs to be updated, this will still need to be done manually,
by editing the appropriate .po file.
"""
import argparse
import os
import sys
def manually_translate_file(filename, save=False):
"""
Manually translate a .po file.
Present any missing translation strings to the translator,
and write their responses back to the file.
"""
print("Add manual translations to '{f}'".format(f=filename))
print("For each missing translation:")
print("a) Directly enter a new tranlation in the target language")
print("b) Leave empty to skip")
print("c) Press Ctrl+C to exit")
print("-------------------------")
input("Press <ENTER> to start")
print("")
with open(filename, 'r') as f:
lines = f.readlines()
out = []
# Context data
source_line = ''
msgid = ''
for num, line in enumerate(lines):
# Keep track of context data BEFORE an empty msgstr object
line = line.strip()
if line.startswith("#: "):
source_line = line.replace("#: ", "")
elif line.startswith("msgid "):
msgid = line.replace("msgid ", "")
if line.strip() == 'msgstr ""':
# We have found an empty translation!
if msgid and len(msgid) > 0 and not msgid == '""':
print("Source:", source_line)
print("Enter translation for {t}".format(t=msgid))
try:
translation = str(input(">"))
except KeyboardInterrupt:
break
if translation and len(translation) > 0:
# Update the line with the new translation
line = 'msgstr "{msg}"'.format(msg=translation)
out.append(line + "\r\n")
if save:
with open(filename, 'w') as output_file:
output_file.writelines(out)
print("Translation done: written to", filename)
print("Run 'invoke translate' to rebuild translation data")
if __name__ == '__main__':
MY_DIR = os.path.dirname(os.path.realpath(__file__))
LOCALE_DIR = os.path.join(MY_DIR, '..', 'locale')
if not os.path.exists(LOCALE_DIR):
print("Error: {d} does not exist!".format(d=LOCALE_DIR))
sys.exit(1)
parser = argparse.ArgumentParser(description="InvenTree Translation Helper")
parser.add_argument('language', help='Language code', action='store')
parser.add_argument('--fake', help="Do not save updated translations", action='store_true')
args = parser.parse_args()
language = args.language
LANGUAGE_DIR = os.path.abspath(os.path.join(LOCALE_DIR, language))
# Check that a locale directory exists for the given language!
if not os.path.exists(LANGUAGE_DIR):
print("Error: Locale directory for language '{l}' does not exist".format(l=language))
sys.exit(1)
# Check that a .po file exists for the given language!
PO_FILE = os.path.join(LANGUAGE_DIR, 'LC_MESSAGES', 'django.po')
if not os.path.exists(PO_FILE):
print("Error: File '{f}' does not exist".format(f=PO_FILE))
sys.exit(1)
# Ok, now we run the user through the translation file
manually_translate_file(PO_FILE, save=args.fake is not True)

View File

@ -0,0 +1,19 @@
# Generated by Django 3.2.12 on 2022-04-26 10:19
from django.db import migrations, models
import stock.models
class Migration(migrations.Migration):
dependencies = [
('stock', '0073_alter_stockitem_belongs_to'),
]
operations = [
migrations.AlterField(
model_name='stockitem',
name='batch',
field=models.CharField(blank=True, default=stock.models.generate_batch_code, help_text='Batch code for this stock item', max_length=100, null=True, verbose_name='Batch Code'),
),
]

View File

@ -8,6 +8,8 @@ from __future__ import unicode_literals
import os import os
from jinja2 import Template
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError, FieldError from django.core.exceptions import ValidationError, FieldError
from django.urls import reverse from django.urls import reverse
@ -213,6 +215,32 @@ class StockItemManager(TreeManager):
) )
def generate_batch_code():
"""
Generate a default 'batch code' for a new StockItem.
This uses the value of the 'STOCK_BATCH_CODE_TEMPLATE' setting (if configured),
which can be passed through a simple template.
"""
batch_template = common.models.InvenTreeSetting.get_setting('STOCK_BATCH_CODE_TEMPLATE', '')
now = datetime.now()
# Pass context data through to the template randering.
# The folowing context variables are availble for custom batch code generation
context = {
'date': now,
'year': now.year,
'month': now.month,
'day': now.day,
'hour': now.minute,
'minute': now.minute,
}
return Template(batch_template).render(context)
class StockItem(MPTTModel): class StockItem(MPTTModel):
""" """
A StockItem object represents a quantity of physical instances of a part. A StockItem object represents a quantity of physical instances of a part.
@ -644,7 +672,8 @@ class StockItem(MPTTModel):
batch = models.CharField( batch = models.CharField(
verbose_name=_('Batch Code'), verbose_name=_('Batch Code'),
max_length=100, blank=True, null=True, max_length=100, blank=True, null=True,
help_text=_('Batch code for this stock item') help_text=_('Batch code for this stock item'),
default=generate_batch_code,
) )
quantity = models.DecimalField( quantity = models.DecimalField(

View File

@ -11,6 +11,7 @@
<table class='table table-striped table-condensed'> <table class='table table-striped table-condensed'>
<tbody> <tbody>
{% include "InvenTree/settings/setting.html" with key="STOCK_BATCH_CODE_TEMPLATE" icon="fa-layer-group" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_ENABLE_EXPIRY" icon="fa-stopwatch" %} {% include "InvenTree/settings/setting.html" with key="STOCK_ENABLE_EXPIRY" icon="fa-stopwatch" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_STALE_DAYS" icon="fa-calendar" %} {% include "InvenTree/settings/setting.html" with key="STOCK_STALE_DAYS" icon="fa-calendar" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_SALE" icon="fa-truck" %} {% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_SALE" icon="fa-truck" %}

View File

@ -691,8 +691,24 @@ function loadBomTable(table, options={}) {
setupFilterList('bom', $(table)); setupFilterList('bom', $(table));
// Construct the table columns function availableQuantity(row) {
// Base stock
var available = row.available_stock;
// Substitute stock
available += (row.available_substitute_stock || 0);
// Variant stock
if (row.allow_variants) {
available += (row.available_variant_stock || 0);
}
return available;
}
// Construct the table columns
var cols = []; var cols = [];
if (options.editable) { if (options.editable) {
@ -807,15 +823,27 @@ function loadBomTable(table, options={}) {
var url = `/part/${row.sub_part_detail.pk}/?display=part-stock`; var url = `/part/${row.sub_part_detail.pk}/?display=part-stock`;
// Calculate total "available" (unallocated) quantity // Calculate total "available" (unallocated) quantity
var total = row.available_stock + row.available_substitute_stock; var substitute_stock = row.available_substitute_stock || 0;
var variant_stock = row.allow_variants ? (row.available_variant_stock || 0) : 0;
var text = `${total}`; var available_stock = availableQuantity(row);
if (total <= 0) { var text = `${available_stock}`;
if (available_stock <= 0) {
text = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock Available" %}</span>`; text = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock Available" %}</span>`;
} else { } else {
if (row.available_substitute_stock > 0) { var extra = '';
text += `<span title='{% trans "Includes substitute stock" %}' class='fas fa-info-circle float-right icon-blue'></span>`; if ((substitute_stock > 0) && (variant_stock > 0)) {
extra = '{% trans "Includes variant and substitute stock" %}';
} else if (variant_stock > 0) {
extra = '{% trans "Includes variant stock" %}';
} else if (substitute_stock > 0) {
extra = '{% trans "Includes substitute stock" %}';
}
if (extra) {
text += `<span title='${extra}' class='fas fa-info-circle float-right icon-blue'></span>`;
} }
} }
@ -910,7 +938,7 @@ function loadBomTable(table, options={}) {
formatter: function(value, row) { formatter: function(value, row) {
var can_build = 0; var can_build = 0;
var available = row.available_stock + row.available_substitute_stock; var available = availableQuantity(row);
if (row.quantity > 0) { if (row.quantity > 0) {
can_build = available / row.quantity; can_build = available / row.quantity;
@ -924,11 +952,11 @@ function loadBomTable(table, options={}) {
var cb_b = 0; var cb_b = 0;
if (rowA.quantity > 0) { if (rowA.quantity > 0) {
cb_a = (rowA.available_stock + rowA.available_substitute_stock) / rowA.quantity; cb_a = availableQuantity(rowA) / rowA.quantity;
} }
if (rowB.quantity > 0) { if (rowB.quantity > 0) {
cb_b = (rowB.available_stock + rowB.available_substitute_stock) / rowB.quantity; cb_b = availableQuantity(rowB) / rowB.quantity;
} }
return (cb_a > cb_b) ? 1 : -1; return (cb_a > cb_b) ? 1 : -1;

View File

@ -1031,6 +1031,23 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
return row.required; return row.required;
} }
function availableQuantity(row) {
// Base stock
var available = row.available_stock;
// Substitute stock
available += (row.available_substitute_stock || 0);
// Variant stock
if (row.allow_variants) {
available += (row.available_variant_stock || 0);
}
return available;
}
function sumAllocations(row) { function sumAllocations(row) {
// Calculat total allocations for a given row // Calculat total allocations for a given row
if (!row.allocations) { if (!row.allocations) {
@ -1425,20 +1442,52 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
title: '{% trans "Available" %}', title: '{% trans "Available" %}',
sortable: true, sortable: true,
formatter: function(value, row) { formatter: function(value, row) {
var total = row.available_stock + row.available_substitute_stock;
var text = `${total}`; var url = `/part/${row.sub_part_detail.pk}/?display=part-stock`;
if (total <= 0) { // Calculate total "available" (unallocated) quantity
text = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock Available" %}</span>`; var substitute_stock = row.available_substitute_stock || 0;
var variant_stock = row.allow_variants ? (row.available_variant_stock || 0) : 0;
var available_stock = availableQuantity(row);
var required = requiredQuantity(row);
var text = '';
if (available_stock > 0) {
text += `${available_stock}`;
}
if (available_stock < required) {
text += `<span class='fas fa-times-circle icon-red float-right' title='{% trans "Insufficient stock available" %}'></span>`;
} else { } else {
if (row.available_substitute_stock > 0) { text += `<span class='fas fa-check-circle icon-green float-right' title='{% trans "Sufficient stock available" %}'></span>`;
text += `<span title='{% trans "Includes substitute stock" %}' class='fas fa-info-circle float-right icon-blue'></span>`; }
if (available_stock <= 0) {
text += `<span class='badge rounded-pill bg-danger'>{% trans "No Stock Available" %}</span>`;
} else {
var extra = '';
if ((substitute_stock > 0) && (variant_stock > 0)) {
extra = '{% trans "Includes variant and substitute stock" %}';
} else if (variant_stock > 0) {
extra = '{% trans "Includes variant stock" %}';
} else if (substitute_stock > 0) {
extra = '{% trans "Includes substitute stock" %}';
}
if (extra) {
text += `<span title='${extra}' class='fas fa-info-circle float-right icon-blue'></span>`;
} }
} }
return text; return renderLink(text, url);
} },
sorter: function(valA, valB, rowA, rowB) {
return availableQuantity(rowA) > availableQuantity(rowB) ? 1 : -1;
},
}, },
{ {
field: 'allocated', field: 'allocated',

View File

@ -672,7 +672,20 @@ function loadPartVariantTable(table, partId, options={}) {
field: 'in_stock', field: 'in_stock',
title: '{% trans "Stock" %}', title: '{% trans "Stock" %}',
formatter: function(value, row) { formatter: function(value, row) {
return renderLink(value, `/part/${row.pk}/?display=part-stock`);
var base_stock = row.in_stock;
var variant_stock = row.variant_stock || 0;
var total = base_stock + variant_stock;
var text = `${total}`;
if (variant_stock > 0) {
text = `<em>${text}</em>`;
text += `<span title='{% trans "Includes variant stock" %}' class='fas fa-info-circle float-right icon-blue'></span>`;
}
return renderLink(text, `/part/${row.pk}/?display=part-stock`);
} }
} }
]; ];
@ -1917,7 +1930,9 @@ function loadPriceBreakTable(table, options) {
formatNoMatches: function() { formatNoMatches: function() {
return `{% trans "No ${human_name} information found" %}`; return `{% trans "No ${human_name} information found" %}`;
}, },
queryParams: {part: options.part}, queryParams: {
part: options.part
},
url: options.url, url: options.url,
onLoadSuccess: function(tableData) { onLoadSuccess: function(tableData) {
if (linkedGraph) { if (linkedGraph) {
@ -2023,36 +2038,45 @@ function initPriceBreakSet(table, options) {
} }
pb_new_btn.click(function() { pb_new_btn.click(function() {
launchModalForm(pb_new_url,
{ constructForm(pb_new_url, {
success: reloadPriceBreakTable, fields: {
data: { part: {
part: part_id, hidden: true,
} value: part_id,
} },
); quantity: {},
price: {},
price_currency: {},
},
method: 'POST',
title: '{% trans "Add Price Break" %}',
onSuccess: reloadPriceBreakTable,
});
}); });
table.on('click', `.button-${pb_url_slug}-delete`, function() { table.on('click', `.button-${pb_url_slug}-delete`, function() {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
launchModalForm( constructForm(`${pb_url}${pk}/`, {
`/part/${pb_url_slug}/${pk}/delete/`, method: 'DELETE',
{ title: '{% trans "Delete Price Break" %}',
success: reloadPriceBreakTable onSuccess: reloadPriceBreakTable,
} });
);
}); });
table.on('click', `.button-${pb_url_slug}-edit`, function() { table.on('click', `.button-${pb_url_slug}-edit`, function() {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
launchModalForm( constructForm(`${pb_url}${pk}/`, {
`/part/${pb_url_slug}/${pk}/edit/`, fields: {
{ quantity: {},
success: reloadPriceBreakTable price: {},
} price_currency: {},
); },
title: '{% trans "Edit Price Break" %}',
onSuccess: reloadPriceBreakTable,
});
}); });
} }

View File

@ -65,8 +65,8 @@ RUN apk add --no-cache git make bash \
libjpeg-turbo libjpeg-turbo-dev jpeg jpeg-dev \ libjpeg-turbo libjpeg-turbo-dev jpeg jpeg-dev \
libffi libffi-dev \ libffi libffi-dev \
zlib zlib-dev \ zlib zlib-dev \
# Cairo deps for WeasyPrint (these will be deprecated once WeasyPrint drops cairo requirement) # Special deps for WeasyPrint (these will be deprecated once WeasyPrint drops cairo requirement)
cairo cairo-dev pango pango-dev \ cairo cairo-dev pango pango-dev gdk-pixbuf \
# Fonts # Fonts
fontconfig ttf-droid ttf-liberation ttf-dejavu ttf-opensans ttf-ubuntu-font-family font-croscore font-noto \ fontconfig ttf-droid ttf-liberation ttf-dejavu ttf-opensans ttf-ubuntu-font-family font-croscore font-noto \
# Core python # Core python