mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master' into scheduling
# Conflicts: # InvenTree/InvenTree/version.py (Update API version)
This commit is contained in:
commit
f7d724aa0c
@ -4,8 +4,13 @@ import logging
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.core.exceptions import AppRegistryNotReady
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
from django.db.utils import IntegrityError
|
||||
|
||||
from InvenTree.ready import isInTestMode, canAppAccessDatabase
|
||||
from .config import get_setting
|
||||
import InvenTree.tasks
|
||||
|
||||
|
||||
@ -26,6 +31,9 @@ class InvenTreeConfig(AppConfig):
|
||||
if not isInTestMode():
|
||||
self.update_exchange_rates()
|
||||
|
||||
if canAppAccessDatabase() or settings.TESTING_ENV:
|
||||
self.add_user_on_startup()
|
||||
|
||||
def remove_obsolete_tasks(self):
|
||||
"""
|
||||
Delete any obsolete scheduled tasks in the database
|
||||
@ -138,3 +146,54 @@ class InvenTreeConfig(AppConfig):
|
||||
update_exchange_rates()
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating exchange rates: {e}")
|
||||
|
||||
def add_user_on_startup(self):
|
||||
"""Add a user on startup"""
|
||||
# stop if checks were already created
|
||||
if hasattr(settings, 'USER_ADDED') and settings.USER_ADDED:
|
||||
return
|
||||
|
||||
# get values
|
||||
add_user = get_setting(
|
||||
'INVENTREE_ADMIN_USER',
|
||||
settings.CONFIG.get('admin_user', False)
|
||||
)
|
||||
add_email = get_setting(
|
||||
'INVENTREE_ADMIN_EMAIL',
|
||||
settings.CONFIG.get('admin_email', False)
|
||||
)
|
||||
add_password = get_setting(
|
||||
'INVENTREE_ADMIN_PASSWORD',
|
||||
settings.CONFIG.get('admin_password', False)
|
||||
)
|
||||
|
||||
# check if all values are present
|
||||
set_variables = 0
|
||||
for tested_var in [add_user, add_email, add_password]:
|
||||
if tested_var:
|
||||
set_variables += 1
|
||||
|
||||
# no variable set -> do not try anything
|
||||
if set_variables == 0:
|
||||
settings.USER_ADDED = True
|
||||
return
|
||||
|
||||
# not all needed variables set
|
||||
if set_variables < 3:
|
||||
logger.warn('Not all required settings for adding a user on startup are present:\nINVENTREE_SET_USER, INVENTREE_SET_EMAIL, INVENTREE_SET_PASSWORD')
|
||||
settings.USER_ADDED = True
|
||||
return
|
||||
|
||||
# good to go -> create user
|
||||
user = get_user_model()
|
||||
try:
|
||||
with transaction.atomic():
|
||||
new_user = user.objects.create_superuser(add_user, add_email, add_password)
|
||||
logger.info(f'User {str(new_user)} was created!')
|
||||
except IntegrityError as _e:
|
||||
logger.warning(f'The user "{add_user}" could not be created due to the following error:\n{str(_e)}')
|
||||
if settings.TESTING_ENV:
|
||||
raise _e
|
||||
|
||||
# do not try again
|
||||
settings.USER_ADDED = True
|
||||
|
@ -133,7 +133,7 @@ class ReferenceIndexingMixin(models.Model):
|
||||
reference_int = models.BigIntegerField(default=0)
|
||||
|
||||
|
||||
def extract_int(reference):
|
||||
def extract_int(reference, clip=0x7fffffff):
|
||||
# Default value if we cannot convert to an integer
|
||||
ref_int = 0
|
||||
|
||||
@ -146,6 +146,15 @@ def extract_int(reference):
|
||||
ref_int = int(ref)
|
||||
except:
|
||||
ref_int = 0
|
||||
|
||||
# Ensure that the returned values are within the range that can be stored in an IntegerField
|
||||
# Note: This will result in large values being "clipped"
|
||||
if clip is not None:
|
||||
if ref_int > clip:
|
||||
ref_int = clip
|
||||
elif ref_int < -clip:
|
||||
ref_int = -clip
|
||||
|
||||
return ref_int
|
||||
|
||||
|
||||
|
@ -37,6 +37,8 @@ def _is_true(x):
|
||||
|
||||
# Determine if we are running in "test" mode e.g. "manage.py test"
|
||||
TESTING = 'test' in sys.argv
|
||||
# Are enviroment variables manipulated by tests? Needs to be set by testing code
|
||||
TESTING_ENV = False
|
||||
|
||||
# New requirement for django 3.2+
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
|
||||
|
@ -1,9 +1,12 @@
|
||||
|
||||
import json
|
||||
from test.support import EnvironmentVarGuard
|
||||
|
||||
from django.test import TestCase
|
||||
from django.test import TestCase, override_settings
|
||||
import django.core.exceptions as django_exceptions
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.conf import settings
|
||||
|
||||
from djmoney.money import Money
|
||||
from djmoney.contrib.exchange.models import Rate, convert_money
|
||||
@ -407,3 +410,46 @@ class TestStatus(TestCase):
|
||||
|
||||
def test_Importing(self):
|
||||
self.assertEqual(ready.isImportingData(), False)
|
||||
|
||||
|
||||
class TestSettings(TestCase):
|
||||
"""
|
||||
Unit tests for settings
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.user_mdl = get_user_model()
|
||||
self.env = EnvironmentVarGuard()
|
||||
|
||||
def run_reload(self):
|
||||
from plugin import registry
|
||||
|
||||
with self.env:
|
||||
settings.USER_ADDED = False
|
||||
registry.reload_plugins()
|
||||
|
||||
@override_settings(TESTING_ENV=True)
|
||||
def test_set_user_to_few(self):
|
||||
# add shortcut
|
||||
user_count = self.user_mdl.objects.count
|
||||
# enable testing mode
|
||||
settings.TESTING_ENV = True
|
||||
|
||||
# nothing set
|
||||
self.run_reload()
|
||||
self.assertEqual(user_count(), 0)
|
||||
|
||||
# not enough set
|
||||
self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username
|
||||
self.run_reload()
|
||||
self.assertEqual(user_count(), 0)
|
||||
|
||||
# enough set
|
||||
self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username
|
||||
self.env.set('INVENTREE_ADMIN_EMAIL', 'info@example.com') # set email
|
||||
self.env.set('INVENTREE_ADMIN_PASSWORD', 'password123') # set password
|
||||
self.run_reload()
|
||||
self.assertEqual(user_count(), 1)
|
||||
|
||||
# make sure to clean up
|
||||
settings.TESTING_ENV = False
|
||||
|
@ -12,14 +12,18 @@ import common.models
|
||||
INVENTREE_SW_VERSION = "0.7.0 dev"
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 28
|
||||
INVENTREE_API_VERSION = 29
|
||||
|
||||
"""
|
||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||
|
||||
v28 -> 2022-03-01
|
||||
v29 -> 2022-03-08
|
||||
- Adds "scheduling" endpoint for predicted stock scheduling information
|
||||
|
||||
v28 -> 2022-03-04
|
||||
- Adds an API endpoint for auto allocation of stock items against a build order
|
||||
- Ref: https://github.com/inventree/InvenTree/pull/2713
|
||||
|
||||
v27 -> 2022-02-28
|
||||
- Adds target_date field to individual line items for purchase orders and sales orders
|
||||
|
||||
|
@ -12,6 +12,7 @@ from rest_framework.views import APIView
|
||||
from stock.models import StockItem
|
||||
from stock.serializers import StockItemSerializer
|
||||
|
||||
from barcodes.plugins.inventree_barcode import InvenTreeBarcodePlugin
|
||||
from barcodes.barcode import hash_barcode
|
||||
from plugin import registry
|
||||
|
||||
@ -57,6 +58,9 @@ class BarcodeScan(APIView):
|
||||
|
||||
barcode_data = data.get('barcode')
|
||||
|
||||
# Ensure that the default barcode handler is installed
|
||||
plugins.append(InvenTreeBarcodePlugin())
|
||||
|
||||
# Look for a barcode plugin which knows how to deal with this barcode
|
||||
plugin = None
|
||||
|
||||
|
@ -52,7 +52,7 @@ class InvenTreeBarcodePlugin(BarcodePlugin):
|
||||
# If any of the following keys are in the JSON data,
|
||||
# let's go ahead and assume that the code is a valid InvenTree one...
|
||||
|
||||
for key in ['tool', 'version', 'InvenTree', 'stockitem', 'location', 'part']:
|
||||
for key in ['tool', 'version', 'InvenTree', 'stockitem', 'stocklocation', 'part']:
|
||||
if key in self.data.keys():
|
||||
return True
|
||||
|
||||
|
@ -56,6 +56,66 @@ class BarcodeAPITest(APITestCase):
|
||||
self.assertIn('plugin', data)
|
||||
self.assertIsNone(data['plugin'])
|
||||
|
||||
def test_find_part(self):
|
||||
"""
|
||||
Test that we can lookup a part based on ID
|
||||
"""
|
||||
|
||||
response = self.client.post(
|
||||
self.scan_url,
|
||||
{
|
||||
'barcode': {
|
||||
'part': 1,
|
||||
},
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertIn('part', response.data)
|
||||
self.assertIn('barcode_data', response.data)
|
||||
self.assertEqual(response.data['part']['pk'], 1)
|
||||
|
||||
def test_find_stock_item(self):
|
||||
"""
|
||||
Test that we can lookup a stock item based on ID
|
||||
"""
|
||||
|
||||
response = self.client.post(
|
||||
self.scan_url,
|
||||
{
|
||||
'barcode': {
|
||||
'stockitem': 1,
|
||||
}
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertIn('stockitem', response.data)
|
||||
self.assertIn('barcode_data', response.data)
|
||||
self.assertEqual(response.data['stockitem']['pk'], 1)
|
||||
|
||||
def test_find_location(self):
|
||||
"""
|
||||
Test that we can lookup a stock location based on ID
|
||||
"""
|
||||
|
||||
response = self.client.post(
|
||||
self.scan_url,
|
||||
{
|
||||
'barcode': {
|
||||
'stocklocation': 1,
|
||||
},
|
||||
},
|
||||
format='json'
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertIn('stocklocation', response.data)
|
||||
self.assertIn('barcode_data', response.data)
|
||||
self.assertEqual(response.data['stocklocation']['pk'], 1)
|
||||
|
||||
def test_integer_barcode(self):
|
||||
|
||||
response = self.postBarcode(self.scan_url, '123456789')
|
||||
|
@ -322,6 +322,37 @@ class BuildFinish(generics.CreateAPIView):
|
||||
return ctx
|
||||
|
||||
|
||||
class BuildAutoAllocate(generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint for 'automatically' allocating stock against a build order.
|
||||
|
||||
- Only looks at 'untracked' parts
|
||||
- If stock exists in a single location, easy!
|
||||
- If user decides that stock items are "fungible", allocate against multiple stock items
|
||||
- If the user wants to, allocate substite parts if the primary parts are not available.
|
||||
"""
|
||||
|
||||
queryset = Build.objects.none()
|
||||
|
||||
serializer_class = build.serializers.BuildAutoAllocationSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""
|
||||
Provide the Build object to the serializer context
|
||||
"""
|
||||
|
||||
context = super().get_serializer_context()
|
||||
|
||||
try:
|
||||
context['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
|
||||
except:
|
||||
pass
|
||||
|
||||
context['request'] = self.request
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class BuildAllocate(generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint to allocate stock items to a build order
|
||||
@ -477,6 +508,7 @@ build_api_urls = [
|
||||
# Build Detail
|
||||
url(r'^(?P<pk>\d+)/', include([
|
||||
url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
|
||||
url(r'^auto-allocate/', BuildAutoAllocate.as_view(), name='api-build-auto-allocate'),
|
||||
url(r'^complete/', BuildOutputComplete.as_view(), name='api-build-output-complete'),
|
||||
url(r'^create-output/', BuildOutputCreate.as_view(), name='api-build-output-create'),
|
||||
url(r'^delete-outputs/', BuildOutputDelete.as_view(), name='api-build-output-delete'),
|
||||
|
@ -25,6 +25,8 @@ from markdownx.models import MarkdownxField
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
from mptt.exceptions import InvalidMove
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode
|
||||
from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode
|
||||
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
|
||||
@ -823,6 +825,106 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
|
||||
self.save()
|
||||
|
||||
@transaction.atomic
|
||||
def auto_allocate_stock(self, **kwargs):
|
||||
"""
|
||||
Automatically allocate stock items against this build order,
|
||||
following a number of 'guidelines':
|
||||
|
||||
- Only "untracked" BOM items are considered (tracked BOM items must be manually allocated)
|
||||
- If a particular BOM item is already fully allocated, it is skipped
|
||||
- Extract all available stock items for the BOM part
|
||||
- If variant stock is allowed, extract stock for those too
|
||||
- If substitute parts are available, extract stock for those also
|
||||
- If a single stock item is found, we can allocate that and move on!
|
||||
- If multiple stock items are found, we *may* be able to allocate:
|
||||
- If the calling function has specified that items are interchangeable
|
||||
"""
|
||||
|
||||
location = kwargs.get('location', None)
|
||||
interchangeable = kwargs.get('interchangeable', False)
|
||||
substitutes = kwargs.get('substitutes', True)
|
||||
|
||||
# Get a list of all 'untracked' BOM items
|
||||
for bom_item in self.untracked_bom_items:
|
||||
|
||||
variant_parts = bom_item.sub_part.get_descendants(include_self=False)
|
||||
|
||||
unallocated_quantity = self.unallocated_quantity(bom_item)
|
||||
|
||||
if unallocated_quantity <= 0:
|
||||
# This BomItem is fully allocated, we can continue
|
||||
continue
|
||||
|
||||
# Check which parts we can "use" (may include variants and substitutes)
|
||||
available_parts = bom_item.get_valid_parts_for_allocation(
|
||||
allow_variants=True,
|
||||
allow_substitutes=substitutes,
|
||||
)
|
||||
|
||||
# Look for available stock items
|
||||
available_stock = StockModels.StockItem.objects.filter(StockModels.StockItem.IN_STOCK_FILTER)
|
||||
|
||||
# Filter by list of available parts
|
||||
available_stock = available_stock.filter(
|
||||
part__in=[p for p in available_parts],
|
||||
)
|
||||
|
||||
if location:
|
||||
# Filter only stock items located "below" the specified location
|
||||
sublocations = location.get_descendants(include_self=True)
|
||||
available_stock = available_stock.filter(location__in=[loc for loc in sublocations])
|
||||
|
||||
"""
|
||||
Next, we sort the available stock items with the following priority:
|
||||
1. Direct part matches (+1)
|
||||
2. Variant part matches (+2)
|
||||
3. Substitute part matches (+3)
|
||||
|
||||
This ensures that allocation priority is first given to "direct" parts
|
||||
"""
|
||||
def stock_sort(item):
|
||||
if item.part == bom_item.sub_part:
|
||||
return 1
|
||||
elif item.part in variant_parts:
|
||||
return 2
|
||||
else:
|
||||
return 3
|
||||
|
||||
available_stock = sorted(available_stock, key=stock_sort)
|
||||
|
||||
if len(available_stock) == 0:
|
||||
# No stock items are available
|
||||
continue
|
||||
elif len(available_stock) == 1 or interchangeable:
|
||||
# Either there is only a single stock item available,
|
||||
# or all items are "interchangeable" and we don't care where we take stock from
|
||||
|
||||
for stock_item in available_stock:
|
||||
# How much of the stock item is "available" for allocation?
|
||||
quantity = min(unallocated_quantity, stock_item.unallocated_quantity())
|
||||
|
||||
if quantity > 0:
|
||||
|
||||
try:
|
||||
BuildItem.objects.create(
|
||||
build=self,
|
||||
bom_item=bom_item,
|
||||
stock_item=stock_item,
|
||||
quantity=quantity,
|
||||
)
|
||||
|
||||
# Subtract the required quantity
|
||||
unallocated_quantity -= quantity
|
||||
|
||||
except (ValidationError, serializers.ValidationError) as exc:
|
||||
# Catch model errors and re-throw as DRF errors
|
||||
raise ValidationError(detail=serializers.as_serializer_error(exc))
|
||||
|
||||
if unallocated_quantity <= 0:
|
||||
# We have now fully-allocated this BomItem - no need to continue!
|
||||
break
|
||||
|
||||
def required_quantity(self, bom_item, output=None):
|
||||
"""
|
||||
Get the quantity of a part required to complete the particular build output.
|
||||
|
@ -709,6 +709,52 @@ class BuildAllocationSerializer(serializers.Serializer):
|
||||
raise ValidationError(detail=serializers.as_serializer_error(exc))
|
||||
|
||||
|
||||
class BuildAutoAllocationSerializer(serializers.Serializer):
|
||||
"""
|
||||
DRF serializer for auto allocating stock items against a build order
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
'location',
|
||||
'interchangeable',
|
||||
'substitutes',
|
||||
]
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockLocation.objects.all(),
|
||||
many=False,
|
||||
allow_null=True,
|
||||
required=False,
|
||||
label=_('Source Location'),
|
||||
help_text=_('Stock location where parts are to be sourced (leave blank to take from any location)'),
|
||||
)
|
||||
|
||||
interchangeable = serializers.BooleanField(
|
||||
default=False,
|
||||
label=_('Interchangeable Stock'),
|
||||
help_text=_('Stock items in multiple locations can be used interchangeably'),
|
||||
)
|
||||
|
||||
substitutes = serializers.BooleanField(
|
||||
default=True,
|
||||
label=_('Substitute Stock'),
|
||||
help_text=_('Allow allocation of substitute parts'),
|
||||
)
|
||||
|
||||
def save(self):
|
||||
|
||||
data = self.validated_data
|
||||
|
||||
build = self.context['build']
|
||||
|
||||
build.auto_allocate_stock(
|
||||
location=data.get('location', None),
|
||||
interchangeable=data['interchangeable'],
|
||||
substitutes=data['substitutes'],
|
||||
)
|
||||
|
||||
|
||||
class BuildItemSerializer(InvenTreeModelSerializer):
|
||||
""" Serializes a BuildItem object """
|
||||
|
||||
|
@ -177,7 +177,10 @@
|
||||
<button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Unallocate stock" %}'>
|
||||
<span class='fas fa-minus-circle'></span> {% trans "Unallocate Stock" %}
|
||||
</button>
|
||||
<button class='btn btn-success' type='button' id='btn-auto-allocate' title='{% trans "Allocate stock to build" %}'>
|
||||
<button class='btn btn-primary' type='button' id='btn-auto-allocate' title='{% trans "Automatically allocate stock to build" %}'>
|
||||
<span class='fas fa-magic'></span> {% trans "Auto Allocate" %}
|
||||
</button>
|
||||
<button class='btn btn-success' type='button' id='btn-allocate' title='{% trans "Manually allocate stock to build" %}'>
|
||||
<span class='fas fa-sign-in-alt'></span> {% trans "Allocate Stock" %}
|
||||
</button>
|
||||
<!--
|
||||
@ -485,8 +488,22 @@ function reloadTable() {
|
||||
}
|
||||
|
||||
{% if build.active %}
|
||||
|
||||
$("#btn-auto-allocate").on('click', function() {
|
||||
|
||||
autoAllocateStockToBuild(
|
||||
{{ build.pk }},
|
||||
[],
|
||||
{
|
||||
{% if build.take_from %}
|
||||
location: {{ build.take_from.pk }},
|
||||
{% endif %}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$("#btn-allocate").on('click', function() {
|
||||
|
||||
var bom_items = $("#allocation-table-untracked").bootstrapTable("getData");
|
||||
|
||||
var incomplete_bom_items = [];
|
||||
|
@ -8,11 +8,11 @@ from django.db.utils import IntegrityError
|
||||
from InvenTree import status_codes as status
|
||||
|
||||
from build.models import Build, BuildItem, get_next_build_number
|
||||
from part.models import Part, BomItem
|
||||
from part.models import Part, BomItem, BomItemSubstitute
|
||||
from stock.models import StockItem
|
||||
|
||||
|
||||
class BuildTest(TestCase):
|
||||
class BuildTestBase(TestCase):
|
||||
"""
|
||||
Run some tests to ensure that the Build model is working properly.
|
||||
"""
|
||||
@ -107,13 +107,20 @@ class BuildTest(TestCase):
|
||||
)
|
||||
|
||||
# Create some stock items to assign to the build
|
||||
self.stock_1_1 = StockItem.objects.create(part=self.sub_part_1, quantity=1000)
|
||||
self.stock_1_1 = StockItem.objects.create(part=self.sub_part_1, quantity=3)
|
||||
self.stock_1_2 = StockItem.objects.create(part=self.sub_part_1, quantity=100)
|
||||
|
||||
self.stock_2_1 = StockItem.objects.create(part=self.sub_part_2, quantity=5000)
|
||||
self.stock_2_1 = StockItem.objects.create(part=self.sub_part_2, quantity=5)
|
||||
self.stock_2_2 = StockItem.objects.create(part=self.sub_part_2, quantity=5)
|
||||
self.stock_2_2 = StockItem.objects.create(part=self.sub_part_2, quantity=5)
|
||||
self.stock_2_2 = StockItem.objects.create(part=self.sub_part_2, quantity=5)
|
||||
self.stock_2_2 = StockItem.objects.create(part=self.sub_part_2, quantity=5)
|
||||
|
||||
self.stock_3_1 = StockItem.objects.create(part=self.sub_part_3, quantity=1000)
|
||||
|
||||
|
||||
class BuildTest(BuildTestBase):
|
||||
|
||||
def test_ref_int(self):
|
||||
"""
|
||||
Test the "integer reference" field used for natural sorting
|
||||
@ -137,7 +144,7 @@ class BuildTest(TestCase):
|
||||
def test_init(self):
|
||||
# Perform some basic tests before we start the ball rolling
|
||||
|
||||
self.assertEqual(StockItem.objects.count(), 6)
|
||||
self.assertEqual(StockItem.objects.count(), 10)
|
||||
|
||||
# Build is PENDING
|
||||
self.assertEqual(self.build.status, status.BuildStatus.PENDING)
|
||||
@ -183,7 +190,7 @@ class BuildTest(TestCase):
|
||||
b.clean()
|
||||
|
||||
# Ok, what about we make one that does *not* fail?
|
||||
b = BuildItem(stock_item=self.stock_1_1, build=self.build, install_into=self.output_1, quantity=10)
|
||||
b = BuildItem(stock_item=self.stock_1_2, build=self.build, install_into=self.output_1, quantity=10)
|
||||
b.save()
|
||||
|
||||
def test_duplicate_bom_line(self):
|
||||
@ -274,11 +281,14 @@ class BuildTest(TestCase):
|
||||
|
||||
self.assertFalse(self.build.are_untracked_parts_allocated())
|
||||
|
||||
self.stock_2_1.quantity = 500
|
||||
self.stock_2_1.save()
|
||||
|
||||
# Now we "fully" allocate the untracked untracked items
|
||||
self.allocate_stock(
|
||||
None,
|
||||
{
|
||||
self.stock_1_1: 50,
|
||||
self.stock_1_2: 50,
|
||||
self.stock_2_1: 50,
|
||||
}
|
||||
)
|
||||
@ -305,6 +315,12 @@ class BuildTest(TestCase):
|
||||
Test completion of a build output
|
||||
"""
|
||||
|
||||
self.stock_1_1.quantity = 1000
|
||||
self.stock_1_1.save()
|
||||
|
||||
self.stock_2_1.quantity = 30
|
||||
self.stock_2_1.save()
|
||||
|
||||
# Allocate non-tracked parts
|
||||
self.allocate_stock(
|
||||
None,
|
||||
@ -351,16 +367,15 @@ class BuildTest(TestCase):
|
||||
self.assertEqual(BuildItem.objects.count(), 0)
|
||||
|
||||
# New stock items should have been created!
|
||||
self.assertEqual(StockItem.objects.count(), 7)
|
||||
self.assertEqual(StockItem.objects.count(), 10)
|
||||
|
||||
# This stock item has been depleted!
|
||||
with self.assertRaises(StockItem.DoesNotExist):
|
||||
StockItem.objects.get(pk=self.stock_1_1.pk)
|
||||
|
||||
# This stock item has *not* been depleted
|
||||
x = StockItem.objects.get(pk=self.stock_2_1.pk)
|
||||
|
||||
self.assertEqual(x.quantity, 4970)
|
||||
# This stock item has also been depleted
|
||||
with self.assertRaises(StockItem.DoesNotExist):
|
||||
StockItem.objects.get(pk=self.stock_2_1.pk)
|
||||
|
||||
# And 10 new stock items created for the build output
|
||||
outputs = StockItem.objects.filter(build=self.build)
|
||||
@ -369,3 +384,108 @@ class BuildTest(TestCase):
|
||||
|
||||
for output in outputs:
|
||||
self.assertFalse(output.is_building)
|
||||
|
||||
|
||||
class AutoAllocationTests(BuildTestBase):
|
||||
"""
|
||||
Tests for auto allocating stock against a build order
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
# Add a "substitute" part for bom_item_2
|
||||
alt_part = Part.objects.create(
|
||||
name="alt part",
|
||||
description="An alternative part!",
|
||||
component=True,
|
||||
)
|
||||
|
||||
BomItemSubstitute.objects.create(
|
||||
bom_item=self.bom_item_2,
|
||||
part=alt_part,
|
||||
)
|
||||
|
||||
StockItem.objects.create(
|
||||
part=alt_part,
|
||||
quantity=500,
|
||||
)
|
||||
|
||||
def test_auto_allocate(self):
|
||||
"""
|
||||
Run the 'auto-allocate' function. What do we expect to happen?
|
||||
|
||||
There are two "untracked" parts:
|
||||
- sub_part_1 (quantity 5 per BOM = 50 required total) / 103 in stock (2 items)
|
||||
- sub_part_2 (quantity 3 per BOM = 30 required total) / 25 in stock (5 items)
|
||||
|
||||
A "fully auto" allocation should allocate *all* of these stock items to the build
|
||||
"""
|
||||
|
||||
# No build item allocations have been made against the build
|
||||
self.assertEqual(self.build.allocated_stock.count(), 0)
|
||||
|
||||
self.assertFalse(self.build.are_untracked_parts_allocated())
|
||||
|
||||
# Stock is not interchangeable, nothing will happen
|
||||
self.build.auto_allocate_stock(
|
||||
interchangeable=False,
|
||||
substitutes=False,
|
||||
)
|
||||
|
||||
self.assertFalse(self.build.are_untracked_parts_allocated())
|
||||
|
||||
self.assertEqual(self.build.allocated_stock.count(), 0)
|
||||
|
||||
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_1))
|
||||
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2))
|
||||
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 50)
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 30)
|
||||
|
||||
# This time we expect stock to be allocated!
|
||||
self.build.auto_allocate_stock(
|
||||
interchangeable=True,
|
||||
substitutes=False,
|
||||
)
|
||||
|
||||
self.assertFalse(self.build.are_untracked_parts_allocated())
|
||||
|
||||
self.assertEqual(self.build.allocated_stock.count(), 7)
|
||||
|
||||
self.assertTrue(self.build.is_bom_item_allocated(self.bom_item_1))
|
||||
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2))
|
||||
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0)
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 5)
|
||||
|
||||
# This time, allow substitue parts to be used!
|
||||
self.build.auto_allocate_stock(
|
||||
interchangeable=True,
|
||||
substitutes=True,
|
||||
)
|
||||
|
||||
# self.assertTrue(self.build.are_untracked_parts_allocated())
|
||||
|
||||
# self.assertEqual(self.build.allocated_stock.count(), 8)
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0)
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 0)
|
||||
|
||||
self.assertTrue(self.build.is_bom_item_allocated(self.bom_item_1))
|
||||
self.assertTrue(self.build.is_bom_item_allocated(self.bom_item_2))
|
||||
|
||||
def test_fully_auto(self):
|
||||
"""
|
||||
We should be able to auto-allocate against a build in a single go
|
||||
"""
|
||||
|
||||
self.build.auto_allocate_stock(
|
||||
interchangeable=True,
|
||||
substitutes=True
|
||||
)
|
||||
|
||||
self.assertTrue(self.build.are_untracked_parts_allocated())
|
||||
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0)
|
||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 0)
|
||||
|
@ -169,7 +169,9 @@
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='assigned-stock-button-toolbar'>
|
||||
{% include "filter_list.html" with id="customerstock" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% include "filter_list.html" with id="customerstock" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class='table table-striped table-condensed' id='assigned-stock-table' data-toolbar='#assigned-stock-button-toolbar'></table>
|
||||
@ -282,12 +284,6 @@
|
||||
filterKey: "companystock",
|
||||
});
|
||||
|
||||
$("#stock-export").click(function() {
|
||||
exportStock({
|
||||
supplier: {{ company.id }}
|
||||
});
|
||||
});
|
||||
|
||||
{% if company.is_manufacturer %}
|
||||
|
||||
function reloadManufacturerPartTable() {
|
||||
|
@ -308,14 +308,6 @@ loadStockTable($("#stock-table"), {
|
||||
url: "{% url 'api-stock-list' %}",
|
||||
});
|
||||
|
||||
$("#stock-export").click(function() {
|
||||
|
||||
exportStock({
|
||||
supplier_part: {{ part.pk }},
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
$("#item-create").click(function() {
|
||||
createNewStockItem({
|
||||
data: {
|
||||
|
@ -154,6 +154,11 @@ static_root: '/home/inventree/data/static'
|
||||
# Use environment variable INVENTREE_LOGIN_ATTEMPTS
|
||||
#login_attempts: 5
|
||||
|
||||
# Add new user on first startup
|
||||
#admin_user: admin
|
||||
#admin_email: info@example.com
|
||||
#admin_password: inventree
|
||||
|
||||
# Permit custom authentication backends
|
||||
#authentication_backends:
|
||||
# - 'django.contrib.auth.backends.ModelBackend'
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -16,10 +16,11 @@ from rest_framework.response import Response
|
||||
from company.models import SupplierPart
|
||||
|
||||
from InvenTree.filters import InvenTreeOrderingFilter
|
||||
from InvenTree.helpers import str2bool
|
||||
from InvenTree.helpers import str2bool, DownloadFile
|
||||
from InvenTree.api import AttachmentMixin
|
||||
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus
|
||||
|
||||
from order.admin import POLineItemResource
|
||||
import order.models as models
|
||||
import order.serializers as serializers
|
||||
from part.models import Part
|
||||
@ -370,6 +371,34 @@ class POLineItemList(generics.ListCreateAPIView):
|
||||
|
||||
return queryset
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
# Check if we wish to export the queried data to a file
|
||||
export_format = request.query_params.get('export', None)
|
||||
|
||||
if export_format:
|
||||
export_format = str(export_format).strip().lower()
|
||||
|
||||
if export_format in ['csv', 'tsv', 'xls', 'xlsx']:
|
||||
dataset = POLineItemResource().export(queryset=queryset)
|
||||
|
||||
filedata = dataset.export(export_format)
|
||||
|
||||
filename = f"InvenTree_PurchaseOrderData.{export_format}"
|
||||
|
||||
return DownloadFile(filedata, filename)
|
||||
|
||||
page = self.paginate_queryset(queryset)
|
||||
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
filter_backends = [
|
||||
rest_filters.DjangoFilterBackend,
|
||||
filters.SearchFilter,
|
||||
|
@ -152,32 +152,16 @@
|
||||
{% if order.status == PurchaseOrderStatus.PENDING %}
|
||||
$('#new-po-line').click(function() {
|
||||
|
||||
var fields = poLineItemFields({
|
||||
order: {{ order.pk }},
|
||||
supplier: {{ order.supplier.pk }},
|
||||
{% if order.supplier.currency %}
|
||||
currency: '{{ order.supplier.currency }}',
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
constructForm('{% url "api-po-line-list" %}', {
|
||||
fields: {
|
||||
order: {
|
||||
value: {{ order.pk }},
|
||||
hidden: true,
|
||||
},
|
||||
part: {
|
||||
filters: {
|
||||
part_detail: true,
|
||||
supplier_detail: true,
|
||||
supplier: {{ order.supplier.pk }},
|
||||
},
|
||||
},
|
||||
quantity: {},
|
||||
reference: {},
|
||||
purchase_price: {},
|
||||
purchase_price_currency: {
|
||||
{% if order.supplier.currency %}
|
||||
value: '{{ order.supplier.currency }}',
|
||||
{% endif %}
|
||||
},
|
||||
target_date: {},
|
||||
destination: {},
|
||||
notes: {},
|
||||
},
|
||||
fields: fields,
|
||||
method: 'POST',
|
||||
title: '{% trans "Add Line Item" %}',
|
||||
onSuccess: function() {
|
||||
|
@ -221,29 +221,19 @@
|
||||
},
|
||||
});
|
||||
|
||||
function reloadTable() {
|
||||
$("#so-lines-table").bootstrapTable("refresh");
|
||||
}
|
||||
|
||||
$("#new-so-line").click(function() {
|
||||
|
||||
var fields = soLineItemFields({
|
||||
order: {{ order.pk }},
|
||||
});
|
||||
|
||||
constructForm('{% url "api-so-line-list" %}', {
|
||||
fields: {
|
||||
order: {
|
||||
value: {{ order.pk }},
|
||||
hidden: true,
|
||||
},
|
||||
part: {},
|
||||
quantity: {},
|
||||
reference: {},
|
||||
sale_price: {},
|
||||
sale_price_currency: {},
|
||||
target_date: {},
|
||||
notes: {},
|
||||
},
|
||||
fields: fields,
|
||||
method: 'POST',
|
||||
title: '{% trans "Add Line Item" %}',
|
||||
onSuccess: reloadTable,
|
||||
onSuccess: function() {
|
||||
$("#so-lines-table").bootstrapTable("refresh");
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -112,17 +112,16 @@ class PurchaseOrderTest(OrderTest):
|
||||
self.assignRole('purchase_order.add')
|
||||
|
||||
url = reverse('api-po-list')
|
||||
huge_numer = 9223372036854775808
|
||||
huge_number = 9223372036854775808
|
||||
|
||||
# too big
|
||||
self.post(
|
||||
url,
|
||||
{
|
||||
'supplier': 1,
|
||||
'reference': huge_numer,
|
||||
'reference': huge_number,
|
||||
'description': 'PO not created via the API',
|
||||
},
|
||||
expected_code=400
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
def test_po_attachments(self):
|
||||
|
@ -28,6 +28,8 @@ from djmoney.contrib.exchange.exceptions import MissingRate
|
||||
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from part.admin import PartResource
|
||||
|
||||
from .models import Part, PartCategory, PartRelated
|
||||
from .models import BomItem, BomItemSubstitute
|
||||
from .models import PartParameter, PartParameterTemplate
|
||||
@ -46,6 +48,7 @@ import order.models
|
||||
from . import serializers as part_serializers
|
||||
|
||||
from InvenTree.helpers import str2bool, isNull, increment
|
||||
from InvenTree.helpers import DownloadFile
|
||||
from InvenTree.api import AttachmentMixin
|
||||
|
||||
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus
|
||||
@ -865,6 +868,22 @@ class PartList(generics.ListCreateAPIView):
|
||||
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
# Check if we wish to export the queried data to a file.
|
||||
# If so, skip pagination!
|
||||
export_format = request.query_params.get('export', None)
|
||||
|
||||
if export_format:
|
||||
export_format = str(export_format).strip().lower()
|
||||
|
||||
if export_format in ['csv', 'tsv', 'xls', 'xlsx']:
|
||||
dataset = PartResource().export(queryset=queryset)
|
||||
|
||||
filedata = dataset.export(export_format)
|
||||
|
||||
filename = f"InvenTree_Parts.{export_format}"
|
||||
|
||||
return DownloadFile(filedata, filename)
|
||||
|
||||
page = self.paginate_queryset(queryset)
|
||||
|
||||
if page is not None:
|
||||
|
@ -2651,7 +2651,7 @@ class BomItem(models.Model, DataImportMixin):
|
||||
def get_api_url():
|
||||
return reverse('api-bom-list')
|
||||
|
||||
def get_valid_parts_for_allocation(self):
|
||||
def get_valid_parts_for_allocation(self, allow_variants=True, allow_substitutes=True):
|
||||
"""
|
||||
Return a list of valid parts which can be allocated against this BomItem:
|
||||
|
||||
@ -2666,13 +2666,14 @@ class BomItem(models.Model, DataImportMixin):
|
||||
parts.add(self.sub_part)
|
||||
|
||||
# Variant parts (if allowed)
|
||||
if self.allow_variants:
|
||||
if allow_variants and self.allow_variants:
|
||||
for variant in self.sub_part.get_descendants(include_self=False):
|
||||
parts.add(variant)
|
||||
|
||||
# Substitute parts
|
||||
for sub in self.substitutes.all():
|
||||
parts.add(sub.part)
|
||||
if allow_substitutes:
|
||||
for sub in self.substitutes.all():
|
||||
parts.add(sub.part)
|
||||
|
||||
return parts
|
||||
|
||||
|
@ -153,9 +153,6 @@
|
||||
<h4>{% trans "Parts" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
<button type='button' class='btn btn-outline-secondary' id='part-export' title='{% trans "Export Part Data" %}'>
|
||||
<span class='fas fa-file-download'></span> {% trans "Export" %}
|
||||
</button>
|
||||
{% if roles.part.add %}
|
||||
<button type='button' class='btn btn-success' id='part-create' title='{% trans "Create new part" %}'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "New Part" %}
|
||||
@ -290,13 +287,6 @@
|
||||
});
|
||||
});
|
||||
|
||||
$("#part-export").click(function() {
|
||||
|
||||
var url = "{% url 'part-export' %}?category={{ category.id }}";
|
||||
|
||||
location.href = url;
|
||||
});
|
||||
|
||||
{% if roles.part.add %}
|
||||
$("#part-create").click(function() {
|
||||
|
||||
|
@ -28,11 +28,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
{% if part.is_template %}
|
||||
<div class='alert alert-info alert-block'>
|
||||
{% blocktrans with full_name=part.full_name%}Showing stock for all variants of <em>{{full_name}}</em>{% endblocktrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include "stock_table.html" %}
|
||||
</div>
|
||||
</div>
|
||||
@ -296,9 +291,7 @@
|
||||
</button>
|
||||
<ul class='dropdown-menu' role='menu'>
|
||||
<li><a class='dropdown-item' href='#' id='bom-upload'><span class='fas fa-file-upload'></span> {% trans "Upload BOM" %}</a></li>
|
||||
{% if part.variant_of %}
|
||||
<li><a class='dropdown-item' href='#' id='bom-duplicate'><span class='fas fa-clone'></span> {% trans "Copy BOM" %}</a></li>
|
||||
{% endif %}
|
||||
<li><a class='dropdown-item' href='#' id='validate-bom'><span class='fas fa-clipboard-check icon-green'></span> {% trans "Validate BOM" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
@ -851,14 +844,7 @@
|
||||
],
|
||||
url: "{% url 'api-stock-list' %}",
|
||||
});
|
||||
|
||||
$("#stock-export").click(function() {
|
||||
|
||||
exportStock({
|
||||
part: {{ part.pk }}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
$('#item-create').click(function () {
|
||||
createNewStockItem({
|
||||
data: {
|
||||
|
@ -5,7 +5,7 @@ This module provides template tags for extra functionality,
|
||||
over and above the built-in Django tags.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from datetime import date, datetime
|
||||
import os
|
||||
import sys
|
||||
|
||||
@ -87,7 +87,9 @@ def render_date(context, date_object):
|
||||
# Update the context cache
|
||||
context['user_date_format'] = user_date_format
|
||||
|
||||
return date_object.strftime(user_date_format)
|
||||
if isinstance(date_object, (datetime, date)):
|
||||
return date_object.strftime(user_date_format)
|
||||
return date_object
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
|
@ -58,14 +58,6 @@ class PartListTest(PartViewTestCase):
|
||||
self.assertIn('parts', keys)
|
||||
self.assertIn('user', keys)
|
||||
|
||||
def test_export(self):
|
||||
""" Export part data to CSV """
|
||||
|
||||
response = self.client.get(reverse('part-export'), {'parts': '1,2,3,4,5,6,7,8,9,10'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('streaming_content', dir(response))
|
||||
|
||||
|
||||
class PartDetailTest(PartViewTestCase):
|
||||
|
||||
|
@ -80,9 +80,6 @@ part_urls = [
|
||||
# Download a BOM upload template
|
||||
url(r'^bom_template/?', views.BomUploadTemplate.as_view(), name='bom-upload-template'),
|
||||
|
||||
# Export data for multiple parts
|
||||
url(r'^export/', views.PartExport.as_view(), name='part-export'),
|
||||
|
||||
# Individual part using pk
|
||||
url(r'^(?P<pk>\d+)/', include(part_detail_urls)),
|
||||
|
||||
|
@ -49,13 +49,11 @@ from . import settings as part_settings
|
||||
from .bom import MakeBomTemplate, ExportBom, IsValidBOMFormat
|
||||
from order.models import PurchaseOrderLineItem
|
||||
|
||||
from .admin import PartResource
|
||||
|
||||
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
|
||||
from InvenTree.views import QRCodeView
|
||||
from InvenTree.views import InvenTreeRoleMixin
|
||||
|
||||
from InvenTree.helpers import DownloadFile, str2bool
|
||||
from InvenTree.helpers import str2bool
|
||||
|
||||
|
||||
class PartIndex(InvenTreeRoleMixin, ListView):
|
||||
@ -709,69 +707,6 @@ class BomUpload(InvenTreeRoleMixin, DetailView):
|
||||
template_name = 'part/upload_bom.html'
|
||||
|
||||
|
||||
class PartExport(AjaxView):
|
||||
""" Export a CSV file containing information on multiple parts """
|
||||
|
||||
role_required = 'part.view'
|
||||
|
||||
def get_parts(self, request):
|
||||
""" Extract part list from the POST parameters.
|
||||
Parts can be supplied as:
|
||||
|
||||
- Part category
|
||||
- List of part PK values
|
||||
"""
|
||||
|
||||
# Filter by part category
|
||||
cat_id = request.GET.get('category', None)
|
||||
|
||||
part_list = None
|
||||
|
||||
if cat_id is not None:
|
||||
try:
|
||||
category = PartCategory.objects.get(pk=cat_id)
|
||||
part_list = category.get_parts()
|
||||
except (ValueError, PartCategory.DoesNotExist):
|
||||
pass
|
||||
|
||||
# Backup - All parts
|
||||
if part_list is None:
|
||||
part_list = Part.objects.all()
|
||||
|
||||
# Also optionally filter by explicit list of part IDs
|
||||
part_ids = request.GET.get('parts', '')
|
||||
parts = []
|
||||
|
||||
for pk in part_ids.split(','):
|
||||
try:
|
||||
parts.append(int(pk))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if len(parts) > 0:
|
||||
part_list = part_list.filter(pk__in=parts)
|
||||
|
||||
# Prefetch related fields to reduce DB hits
|
||||
part_list = part_list.prefetch_related(
|
||||
'category',
|
||||
'used_in',
|
||||
'builds',
|
||||
'supplier_parts__purchase_order_line_items',
|
||||
'stock_items__allocations',
|
||||
)
|
||||
|
||||
return part_list
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
|
||||
parts = self.get_parts(request)
|
||||
|
||||
dataset = PartResource().export(queryset=parts)
|
||||
|
||||
csv = dataset.export('csv')
|
||||
return DownloadFile(csv, 'InvenTree_Parts.csv')
|
||||
|
||||
|
||||
class BomUploadTemplate(AjaxView):
|
||||
"""
|
||||
Provide a BOM upload template file for download.
|
||||
|
@ -30,6 +30,7 @@ from company.models import Company, SupplierPart
|
||||
from company.serializers import CompanySerializer, SupplierPartSerializer
|
||||
|
||||
from InvenTree.helpers import str2bool, isNull, extract_serial_numbers
|
||||
from InvenTree.helpers import DownloadFile
|
||||
from InvenTree.api import AttachmentMixin
|
||||
from InvenTree.filters import InvenTreeOrderingFilter
|
||||
|
||||
@ -40,6 +41,7 @@ from order.serializers import POSerializer
|
||||
from part.models import BomItem, Part, PartCategory
|
||||
from part.serializers import PartBriefSerializer
|
||||
|
||||
from stock.admin import StockItemResource
|
||||
from stock.models import StockLocation, StockItem
|
||||
from stock.models import StockItemTracking
|
||||
from stock.models import StockItemAttachment
|
||||
@ -611,6 +613,27 @@ class StockList(generics.ListCreateAPIView):
|
||||
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
params = request.query_params
|
||||
|
||||
# Check if we wish to export the queried data to a file.
|
||||
# If so, skip pagination!
|
||||
export_format = params.get('export', None)
|
||||
|
||||
if export_format:
|
||||
export_format = str(export_format).strip().lower()
|
||||
|
||||
if export_format in ['csv', 'tsv', 'xls', 'xlsx']:
|
||||
dataset = StockItemResource().export(queryset=queryset)
|
||||
|
||||
filedata = dataset.export(export_format)
|
||||
|
||||
filename = 'InvenTree_Stocktake_{date}.{fmt}'.format(
|
||||
date=datetime.now().strftime("%d-%b-%Y"),
|
||||
fmt=export_format
|
||||
)
|
||||
|
||||
return DownloadFile(filedata, filename)
|
||||
|
||||
page = self.paginate_queryset(queryset)
|
||||
|
||||
if page is not None:
|
||||
@ -641,7 +664,7 @@ class StockList(generics.ListCreateAPIView):
|
||||
supplier_part_ids.add(sp)
|
||||
|
||||
# Do we wish to include Part detail?
|
||||
if str2bool(request.query_params.get('part_detail', False)):
|
||||
if str2bool(params.get('part_detail', False)):
|
||||
|
||||
# Fetch only the required Part objects from the database
|
||||
parts = Part.objects.filter(pk__in=part_ids).prefetch_related(
|
||||
@ -659,7 +682,7 @@ class StockList(generics.ListCreateAPIView):
|
||||
stock_item['part_detail'] = part_map.get(part_id, None)
|
||||
|
||||
# Do we wish to include SupplierPart detail?
|
||||
if str2bool(request.query_params.get('supplier_part_detail', False)):
|
||||
if str2bool(params.get('supplier_part_detail', False)):
|
||||
|
||||
supplier_parts = SupplierPart.objects.filter(pk__in=supplier_part_ids)
|
||||
|
||||
@ -673,7 +696,7 @@ class StockList(generics.ListCreateAPIView):
|
||||
stock_item['supplier_part_detail'] = supplier_part_map.get(part_id, None)
|
||||
|
||||
# Do we wish to include StockLocation detail?
|
||||
if str2bool(request.query_params.get('location_detail', False)):
|
||||
if str2bool(params.get('location_detail', False)):
|
||||
|
||||
# Fetch only the required StockLocation objects from the database
|
||||
locations = StockLocation.objects.filter(pk__in=location_ids).prefetch_related(
|
||||
|
@ -269,10 +269,62 @@ class StockItem(MPTTModel):
|
||||
serial_int = 0
|
||||
|
||||
if serial is not None:
|
||||
serial_int = extract_int(str(serial))
|
||||
|
||||
serial = str(serial).strip()
|
||||
|
||||
serial_int = extract_int(serial)
|
||||
|
||||
self.serial_int = serial_int
|
||||
|
||||
def get_next_serialized_item(self, include_variants=True, reverse=False):
|
||||
"""
|
||||
Get the "next" serial number for the part this stock item references.
|
||||
|
||||
e.g. if this stock item has a serial number 100, we may return the stock item with serial number 101
|
||||
|
||||
Note that this only works for "serialized" stock items with integer values
|
||||
|
||||
Args:
|
||||
include_variants: True if we wish to include stock for variant parts
|
||||
reverse: True if we want to return the "previous" (lower) serial number
|
||||
|
||||
Returns:
|
||||
A StockItem object matching the requirements, or None
|
||||
|
||||
"""
|
||||
|
||||
if not self.serialized:
|
||||
return None
|
||||
|
||||
# Find only serialized stock items
|
||||
items = StockItem.objects.exclude(serial=None).exclude(serial='')
|
||||
|
||||
if include_variants:
|
||||
# Match against any part within the variant tree
|
||||
items = items.filter(part__tree_id=self.part.tree_id)
|
||||
else:
|
||||
# Match only against the specific part
|
||||
items = items.filter(part=self.part)
|
||||
|
||||
serial = self.serial_int
|
||||
|
||||
if reverse:
|
||||
# Select only stock items with lower serial numbers, in decreasing order
|
||||
items = items.filter(serial_int__lt=serial)
|
||||
items = items.order_by('-serial_int')
|
||||
else:
|
||||
# Select only stock items with higher serial numbers, in increasing order
|
||||
items = items.filter(serial_int__gt=serial)
|
||||
items = items.order_by('serial_int')
|
||||
|
||||
if items.count() > 0:
|
||||
item = items.first()
|
||||
|
||||
if item.serialized:
|
||||
return item
|
||||
|
||||
return None
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Save this StockItem to the database. Performs a number of checks:
|
||||
@ -350,7 +402,7 @@ class StockItem(MPTTModel):
|
||||
@property
|
||||
def serialized(self):
|
||||
""" Return True if this StockItem is serialized """
|
||||
return self.serial is not None and self.quantity == 1
|
||||
return self.serial is not None and len(str(self.serial).strip()) > 0 and self.quantity == 1
|
||||
|
||||
def validate_unique(self, exclude=None):
|
||||
"""
|
||||
|
@ -244,7 +244,7 @@
|
||||
|
||||
{% for allocation in item.allocations.all %}
|
||||
<div class='alert alert-block alert-info'>
|
||||
{% object_link 'build-detail' allocation.build.id allocation.build %}
|
||||
{% object_link 'build-detail' allocation.build.id allocation.build as link %}
|
||||
{% decimal allocation.quantity as qty %}
|
||||
{% trans "This stock item is allocated to Build Order" %} {{ link }} {% if qty < item.quantity %}({% trans "Quantity" %}: {{ qty }}){% endif %}
|
||||
</div>
|
||||
|
@ -239,15 +239,6 @@
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
$("#stock-export").click(function() {
|
||||
|
||||
exportStock({
|
||||
{% if location %}
|
||||
location: {{ location.pk }}
|
||||
{% endif %}
|
||||
});
|
||||
});
|
||||
|
||||
$('#location-create').click(function () {
|
||||
|
||||
createStockLocation({
|
||||
|
@ -6,9 +6,12 @@ Unit testing for the Stock API
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import os
|
||||
import io
|
||||
import tablib
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import django.http
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
@ -261,6 +264,56 @@ class StockItemListTest(StockAPITestCase):
|
||||
|
||||
self.assertEqual(len(response['results']), n)
|
||||
|
||||
def export_data(self, filters=None):
|
||||
|
||||
if not filters:
|
||||
filters = {}
|
||||
|
||||
filters['export'] = 'csv'
|
||||
|
||||
response = self.client.get(self.list_url, data=filters)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.assertTrue(isinstance(response, django.http.response.StreamingHttpResponse))
|
||||
|
||||
file_object = io.StringIO(response.getvalue().decode('utf-8'))
|
||||
|
||||
dataset = tablib.Dataset().load(file_object, 'csv', headers=True)
|
||||
|
||||
return dataset
|
||||
|
||||
def test_export(self):
|
||||
"""
|
||||
Test exporting of Stock data via the API
|
||||
"""
|
||||
|
||||
dataset = self.export_data({})
|
||||
|
||||
self.assertEqual(len(dataset), 20)
|
||||
|
||||
# Expected headers
|
||||
headers = [
|
||||
'part',
|
||||
'customer',
|
||||
'location',
|
||||
'parent',
|
||||
'quantity',
|
||||
'status',
|
||||
]
|
||||
|
||||
for h in headers:
|
||||
self.assertIn(h, dataset.headers)
|
||||
|
||||
# Now, add a filter to the results
|
||||
dataset = self.export_data({'location': 1})
|
||||
|
||||
self.assertEqual(len(dataset), 2)
|
||||
|
||||
dataset = self.export_data({'part': 25})
|
||||
|
||||
self.assertEqual(len(dataset), 8)
|
||||
|
||||
|
||||
class StockItemTest(StockAPITestCase):
|
||||
"""
|
||||
|
@ -346,6 +346,118 @@ class StockTest(TestCase):
|
||||
with self.assertRaises(StockItem.DoesNotExist):
|
||||
w2 = StockItem.objects.get(pk=101)
|
||||
|
||||
def test_serials(self):
|
||||
"""
|
||||
Tests for stock serialization
|
||||
"""
|
||||
|
||||
p = Part.objects.create(
|
||||
name='trackable part',
|
||||
description='trackable part',
|
||||
trackable=True,
|
||||
)
|
||||
|
||||
item = StockItem.objects.create(
|
||||
part=p,
|
||||
quantity=1,
|
||||
)
|
||||
|
||||
self.assertFalse(item.serialized)
|
||||
|
||||
item.serial = None
|
||||
item.save()
|
||||
self.assertFalse(item.serialized)
|
||||
|
||||
item.serial = ' '
|
||||
item.save()
|
||||
self.assertFalse(item.serialized)
|
||||
|
||||
item.serial = ''
|
||||
item.save()
|
||||
self.assertFalse(item.serialized)
|
||||
|
||||
item.serial = '1'
|
||||
item.save()
|
||||
self.assertTrue(item.serialized)
|
||||
|
||||
def test_big_serials(self):
|
||||
"""
|
||||
Unit tests for "large" serial numbers which exceed integer encoding
|
||||
"""
|
||||
|
||||
p = Part.objects.create(
|
||||
name='trackable part',
|
||||
description='trackable part',
|
||||
trackable=True,
|
||||
)
|
||||
|
||||
item = StockItem.objects.create(
|
||||
part=p,
|
||||
quantity=1,
|
||||
)
|
||||
|
||||
for sn in [12345, '12345', ' 12345 ']:
|
||||
item.serial = sn
|
||||
item.save()
|
||||
|
||||
self.assertEqual(item.serial_int, 12345)
|
||||
|
||||
item.serial = "-123"
|
||||
item.save()
|
||||
|
||||
# Negative number should map to zero
|
||||
self.assertEqual(item.serial_int, 0)
|
||||
|
||||
# Test a very very large value
|
||||
item.serial = '99999999999999999999999999999999999999999999999999999'
|
||||
item.save()
|
||||
|
||||
self.assertEqual(item.serial_int, 0x7fffffff)
|
||||
|
||||
# Non-numeric values should encode to zero
|
||||
for sn in ['apple', 'banana', 'carrot']:
|
||||
item.serial = sn
|
||||
item.save()
|
||||
|
||||
self.assertEqual(item.serial_int, 0)
|
||||
|
||||
# Next, test for incremenet / decrement functionality
|
||||
item.serial = 100
|
||||
item.save()
|
||||
|
||||
item_next = StockItem.objects.create(
|
||||
part=p,
|
||||
serial=150,
|
||||
quantity=1
|
||||
)
|
||||
|
||||
self.assertEqual(item.get_next_serialized_item(), item_next)
|
||||
|
||||
item_prev = StockItem.objects.create(
|
||||
part=p,
|
||||
serial=' 57',
|
||||
quantity=1,
|
||||
)
|
||||
|
||||
self.assertEqual(item.get_next_serialized_item(reverse=True), item_prev)
|
||||
|
||||
# Create a number of serialized stock items around the current item
|
||||
for i in range(75, 125):
|
||||
try:
|
||||
StockItem.objects.create(
|
||||
part=p,
|
||||
serial=i,
|
||||
quantity=1,
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
item_next = item.get_next_serialized_item()
|
||||
item_prev = item.get_next_serialized_item(reverse=True)
|
||||
|
||||
self.assertEqual(item_next.serial_int, 101)
|
||||
self.assertEqual(item_prev.serial_int, 99)
|
||||
|
||||
def test_serialize_stock_invalid(self):
|
||||
"""
|
||||
Test manual serialization of parts.
|
||||
|
@ -47,8 +47,6 @@ stock_urls = [
|
||||
|
||||
url(r'^track/', include(stock_tracking_urls)),
|
||||
|
||||
url(r'^export/?', views.StockExport.as_view(), name='stock-export'),
|
||||
|
||||
# Individual stock items
|
||||
url(r'^item/(?P<pk>\d+)/', include(stock_item_detail_urls)),
|
||||
|
||||
|
@ -25,13 +25,13 @@ from InvenTree.views import QRCodeView
|
||||
from InvenTree.views import InvenTreeRoleMixin
|
||||
from InvenTree.forms import ConfirmForm
|
||||
|
||||
from InvenTree.helpers import str2bool, DownloadFile, GetExportFormats
|
||||
from InvenTree.helpers import str2bool
|
||||
from InvenTree.helpers import extract_serial_numbers
|
||||
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from company.models import Company, SupplierPart
|
||||
from company.models import SupplierPart
|
||||
from part.models import Part
|
||||
from .models import StockItem, StockLocation, StockItemTracking
|
||||
|
||||
@ -39,8 +39,6 @@ import common.settings
|
||||
from common.models import InvenTreeSetting
|
||||
from users.models import Owner
|
||||
|
||||
from .admin import StockItemResource
|
||||
|
||||
from . import forms as StockForms
|
||||
|
||||
|
||||
@ -103,43 +101,16 @@ class StockItemDetail(InvenTreeRoleMixin, DetailView):
|
||||
model = StockItem
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
""" add previous and next item """
|
||||
"""
|
||||
Add information on the "next" and "previous" StockItem objects,
|
||||
based on the serial numbers.
|
||||
"""
|
||||
|
||||
data = super().get_context_data(**kwargs)
|
||||
|
||||
if self.object.serialized:
|
||||
|
||||
serial_elem = {}
|
||||
|
||||
try:
|
||||
current = int(self.object.serial)
|
||||
|
||||
for item in self.object.part.stock_items.all():
|
||||
|
||||
if item.serialized:
|
||||
try:
|
||||
sn = int(item.serial)
|
||||
serial_elem[sn] = item
|
||||
except ValueError:
|
||||
# We only support integer serial number progression
|
||||
pass
|
||||
|
||||
serials = serial_elem.keys()
|
||||
|
||||
# previous
|
||||
for nbr in range(current - 1, min(serials), -1):
|
||||
if nbr in serials:
|
||||
data['previous'] = serial_elem.get(nbr, None)
|
||||
break
|
||||
|
||||
# next
|
||||
for nbr in range(current + 1, max(serials) + 1):
|
||||
if nbr in serials:
|
||||
data['next'] = serial_elem.get(nbr, None)
|
||||
break
|
||||
|
||||
except ValueError:
|
||||
# We only support integer serial number progression
|
||||
pass
|
||||
data['previous'] = self.object.get_next_serialized_item(reverse=True)
|
||||
data['next'] = self.object.get_next_serialized_item()
|
||||
|
||||
data['ownership_enabled'] = common.models.InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
|
||||
data['item_owner'] = self.object.get_item_owner()
|
||||
@ -380,95 +351,6 @@ class StockItemDeleteTestData(AjaxUpdateView):
|
||||
return self.renderJsonResponse(request, form, data)
|
||||
|
||||
|
||||
class StockExport(AjaxView):
|
||||
""" Export stock data from a particular location.
|
||||
Returns a file containing stock information for that location.
|
||||
"""
|
||||
|
||||
model = StockItem
|
||||
role_required = 'stock.view'
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
|
||||
export_format = request.GET.get('format', 'csv').lower()
|
||||
|
||||
# Check if a particular location was specified
|
||||
loc_id = request.GET.get('location', None)
|
||||
location = None
|
||||
|
||||
if loc_id:
|
||||
try:
|
||||
location = StockLocation.objects.get(pk=loc_id)
|
||||
except (ValueError, StockLocation.DoesNotExist):
|
||||
pass
|
||||
|
||||
# Check if a particular supplier was specified
|
||||
sup_id = request.GET.get('supplier', None)
|
||||
supplier = None
|
||||
|
||||
if sup_id:
|
||||
try:
|
||||
supplier = Company.objects.get(pk=sup_id)
|
||||
except (ValueError, Company.DoesNotExist):
|
||||
pass
|
||||
|
||||
# Check if a particular supplier_part was specified
|
||||
sup_part_id = request.GET.get('supplier_part', None)
|
||||
supplier_part = None
|
||||
|
||||
if sup_part_id:
|
||||
try:
|
||||
supplier_part = SupplierPart.objects.get(pk=sup_part_id)
|
||||
except (ValueError, SupplierPart.DoesNotExist):
|
||||
pass
|
||||
|
||||
# Check if a particular part was specified
|
||||
part_id = request.GET.get('part', None)
|
||||
part = None
|
||||
|
||||
if part_id:
|
||||
try:
|
||||
part = Part.objects.get(pk=part_id)
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
pass
|
||||
|
||||
if export_format not in GetExportFormats():
|
||||
export_format = 'csv'
|
||||
|
||||
filename = 'InvenTree_Stocktake_{date}.{fmt}'.format(
|
||||
date=datetime.now().strftime("%d-%b-%Y"),
|
||||
fmt=export_format
|
||||
)
|
||||
|
||||
if location:
|
||||
# Check if locations should be cascading
|
||||
cascade = str2bool(request.GET.get('cascade', True))
|
||||
stock_items = location.get_stock_items(cascade)
|
||||
else:
|
||||
stock_items = StockItem.objects.all()
|
||||
|
||||
if part:
|
||||
stock_items = stock_items.filter(part=part)
|
||||
|
||||
if supplier:
|
||||
stock_items = stock_items.filter(supplier_part__supplier=supplier)
|
||||
|
||||
if supplier_part:
|
||||
stock_items = stock_items.filter(supplier_part=supplier_part)
|
||||
|
||||
# Filter out stock items that are not 'in stock'
|
||||
stock_items = stock_items.filter(StockItem.IN_STOCK_FILTER)
|
||||
|
||||
# Pre-fetch related fields to reduce DB queries
|
||||
stock_items = stock_items.prefetch_related('part', 'supplier_part__supplier', 'location', 'purchase_order', 'build')
|
||||
|
||||
dataset = StockItemResource().export(queryset=stock_items)
|
||||
|
||||
filedata = dataset.export(export_format)
|
||||
|
||||
return DownloadFile(filedata, filename)
|
||||
|
||||
|
||||
class StockItemQRCode(QRCodeView):
|
||||
""" View for displaying a QR code for a StockItem object """
|
||||
|
||||
|
@ -20,6 +20,7 @@
|
||||
|
||||
/* exported
|
||||
allocateStockToBuild,
|
||||
autoAllocateStockToBuild,
|
||||
completeBuildOrder,
|
||||
createBuildOutput,
|
||||
editBuildOrder,
|
||||
@ -754,7 +755,7 @@ function loadBuildOutputTable(build_info, options={}) {
|
||||
filters[key] = params[key];
|
||||
}
|
||||
|
||||
// TODO: Initialize filter list
|
||||
setupFilterList('builditems', $(table), options.filterTarget || '#filter-list-incompletebuilditems');
|
||||
|
||||
function setupBuildOutputButtonCallbacks() {
|
||||
|
||||
@ -999,7 +1000,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
filters[key] = params[key];
|
||||
}
|
||||
|
||||
setupFilterList('builditems', $(table), options.filterTarget || null);
|
||||
setupFilterList('builditems', $(table), options.filterTarget);
|
||||
|
||||
// If an "output" is specified, then only "trackable" parts are allocated
|
||||
// Otherwise, only "untrackable" parts are allowed
|
||||
@ -1512,6 +1513,16 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
*/
|
||||
function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
|
||||
|
||||
if (bom_items.length == 0) {
|
||||
|
||||
showAlertDialog(
|
||||
'{% trans "Select Parts" %}',
|
||||
'{% trans "You must select at least one part to allocate" %}',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// ID of the associated "build output" (or null)
|
||||
var output_id = options.output || null;
|
||||
|
||||
@ -1626,8 +1637,8 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
|
||||
if (table_entries.length == 0) {
|
||||
|
||||
showAlertDialog(
|
||||
'{% trans "Select Parts" %}',
|
||||
'{% trans "You must select at least one part to allocate" %}',
|
||||
'{% trans "All Parts Allocated" %}',
|
||||
'{% trans "All selected parts have been fully allocated" %}',
|
||||
);
|
||||
|
||||
return;
|
||||
@ -1844,6 +1855,48 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Automatically allocate stock items to a build
|
||||
*/
|
||||
function autoAllocateStockToBuild(build_id, bom_items=[], options={}) {
|
||||
|
||||
var html = `
|
||||
<div class='alert alert-block alert-info'>
|
||||
<strong>{% trans "Automatic Stock Allocation" %}</strong><br>
|
||||
{% trans "Stock items will be automatically allocated to this build order, according to the provided guidelines" %}:
|
||||
<ul>
|
||||
<li>{% trans "If a location is specifed, stock will only be allocated from that location" %}</li>
|
||||
<li>{% trans "If stock is considered interchangeable, it will be allocated from the first location it is found" %}</li>
|
||||
<li>{% trans "If substitute stock is allowed, it will be used where stock of the primary part cannot be found" %}</li>
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
|
||||
var fields = {
|
||||
location: {
|
||||
value: options.location,
|
||||
},
|
||||
interchangeable: {
|
||||
value: true,
|
||||
},
|
||||
substitutes: {
|
||||
value: true,
|
||||
},
|
||||
};
|
||||
|
||||
constructForm(`/api/build/${build_id}/auto-allocate/`, {
|
||||
method: 'POST',
|
||||
fields: fields,
|
||||
title: '{% trans "Allocate Stock Items" %}',
|
||||
confirm: true,
|
||||
preFormContent: html,
|
||||
onSuccess: function(response) {
|
||||
$('#allocation-table-untracked').bootstrapTable('refresh');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Display a table of Build orders
|
||||
*/
|
||||
|
@ -256,7 +256,7 @@ function generateFilterInput(tableKey, filterKey) {
|
||||
* @param {*} table - bootstrapTable element to update
|
||||
* @param {*} target - name of target element on page
|
||||
*/
|
||||
function setupFilterList(tableKey, table, target) {
|
||||
function setupFilterList(tableKey, table, target, options={}) {
|
||||
|
||||
var addClicked = false;
|
||||
|
||||
@ -283,6 +283,11 @@ function setupFilterList(tableKey, table, target) {
|
||||
|
||||
var buttons = '';
|
||||
|
||||
// Add download button
|
||||
if (options.download) {
|
||||
buttons += `<button id='download-${tableKey}' title='{% trans "Download data" %}' class='btn btn-outline-secondary filter-button'><span class='fas fa-download'></span></button>`;
|
||||
}
|
||||
|
||||
buttons += `<button id='reload-${tableKey}' title='{% trans "Reload data" %}' class='btn btn-outline-secondary filter-button'><span class='fas fa-redo-alt'></span></button>`;
|
||||
|
||||
// If there are filters defined for this table, add more buttons
|
||||
@ -295,7 +300,7 @@ function setupFilterList(tableKey, table, target) {
|
||||
}
|
||||
|
||||
element.html(`
|
||||
<div class='btn-group' role='group'>
|
||||
<div class='btn-group filter-group' role='group'>
|
||||
${buttons}
|
||||
</div>
|
||||
`);
|
||||
@ -322,6 +327,13 @@ function setupFilterList(tableKey, table, target) {
|
||||
$(table).bootstrapTable('refresh');
|
||||
});
|
||||
|
||||
// Add a callback for downloading table data
|
||||
if (options.download) {
|
||||
element.find(`#download-${tableKey}`).click(function() {
|
||||
downloadTableData($(table));
|
||||
});
|
||||
}
|
||||
|
||||
// Add a callback for adding a new filter
|
||||
element.find(`#${add}`).click(function clicked() {
|
||||
|
||||
@ -358,14 +370,14 @@ function setupFilterList(tableKey, table, target) {
|
||||
reloadTableFilters(table, filters);
|
||||
|
||||
// Run this function again
|
||||
setupFilterList(tableKey, table, target);
|
||||
setupFilterList(tableKey, table, target, options);
|
||||
}
|
||||
|
||||
});
|
||||
} else {
|
||||
addClicked = false;
|
||||
|
||||
setupFilterList(tableKey, table, target);
|
||||
setupFilterList(tableKey, table, target, options);
|
||||
}
|
||||
|
||||
});
|
||||
@ -376,7 +388,7 @@ function setupFilterList(tableKey, table, target) {
|
||||
|
||||
reloadTableFilters(table, filters);
|
||||
|
||||
setupFilterList(tableKey, table, target);
|
||||
setupFilterList(tableKey, table, target, options);
|
||||
});
|
||||
|
||||
// Add callback for deleting each filter
|
||||
@ -390,7 +402,7 @@ function setupFilterList(tableKey, table, target) {
|
||||
reloadTableFilters(table, filters);
|
||||
|
||||
// Run this function again!
|
||||
setupFilterList(tableKey, table, target);
|
||||
setupFilterList(tableKey, table, target, options);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -281,6 +281,65 @@ function createPurchaseOrder(options={}) {
|
||||
}
|
||||
|
||||
|
||||
/* Construct a set of fields for the SalesOrderLineItem form */
|
||||
function soLineItemFields(options={}) {
|
||||
|
||||
var fields = {
|
||||
order: {
|
||||
hidden: true,
|
||||
},
|
||||
part: {},
|
||||
quantity: {},
|
||||
reference: {},
|
||||
sale_price: {},
|
||||
sale_price_currency: {},
|
||||
target_date: {},
|
||||
notes: {},
|
||||
};
|
||||
|
||||
if (options.order) {
|
||||
fields.order.value = options.order;
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
|
||||
/* Construct a set of fields for the PurchaseOrderLineItem form */
|
||||
function poLineItemFields(options={}) {
|
||||
|
||||
var fields = {
|
||||
order: {
|
||||
hidden: true,
|
||||
},
|
||||
part: {
|
||||
filters: {
|
||||
part_detail: true,
|
||||
supplier_detail: true,
|
||||
supplier: options.supplier,
|
||||
}
|
||||
},
|
||||
quantity: {},
|
||||
reference: {},
|
||||
purchase_price: {},
|
||||
purchase_price_currency: {},
|
||||
target_date: {},
|
||||
destination: {},
|
||||
notes: {},
|
||||
};
|
||||
|
||||
if (options.order) {
|
||||
fields.order.value = options.order;
|
||||
}
|
||||
|
||||
if (options.currency) {
|
||||
fields.purchase_price_currency.value = options.currency;
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
|
||||
function removeOrderRowFromOrderWizard(e) {
|
||||
/* Remove a part selection from an order form. */
|
||||
|
||||
@ -293,6 +352,7 @@ function removeOrderRowFromOrderWizard(e) {
|
||||
$('#' + row).remove();
|
||||
}
|
||||
|
||||
|
||||
function newSupplierPartFromOrderWizard(e) {
|
||||
/* Create a new supplier part directly from an order form.
|
||||
* Launches a secondary modal and (if successful),
|
||||
@ -899,7 +959,7 @@ function loadPurchaseOrderTable(table, options) {
|
||||
sortable: true,
|
||||
sortName: 'supplier__name',
|
||||
formatter: function(value, row) {
|
||||
return imageHoverIcon(row.supplier_detail.image) + renderLink(row.supplier_detail.name, `/company/${row.supplier}/purchase-orders/`);
|
||||
return imageHoverIcon(row.supplier_detail.image) + renderLink(row.supplier_detail.name, `/company/${row.supplier}/?display=purchase-orders`);
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -991,10 +1051,36 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
|
||||
|
||||
var target = options.filter_target || '#filter-list-purchase-order-lines';
|
||||
|
||||
setupFilterList('purchaseorderlineitem', $(table), target);
|
||||
setupFilterList('purchaseorderlineitem', $(table), target, {download: true});
|
||||
|
||||
function setupCallbacks() {
|
||||
if (options.allow_edit) {
|
||||
|
||||
// Callback for "duplicate" button
|
||||
$(table).find('.button-line-duplicate').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
inventreeGet(`/api/order/po-line/${pk}/`, {}, {
|
||||
success: function(data) {
|
||||
|
||||
var fields = poLineItemFields({
|
||||
supplier: options.supplier,
|
||||
});
|
||||
|
||||
constructForm('{% url "api-po-line-list" %}', {
|
||||
method: 'POST',
|
||||
fields: fields,
|
||||
data: data,
|
||||
title: '{% trans "Duplicate Line Item" %}',
|
||||
onSuccess: function(response) {
|
||||
$(table).bootstrapTable('refresh');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Callback for "edit" button
|
||||
$(table).find('.button-line-edit').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
@ -1022,6 +1108,7 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
|
||||
});
|
||||
});
|
||||
|
||||
// Callback for "delete" button
|
||||
$(table).find('.button-line-delete').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
@ -1270,6 +1357,7 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
|
||||
}
|
||||
|
||||
if (options.allow_edit) {
|
||||
html += makeIconButton('fa-clone', 'button-line-duplicate', pk, '{% trans "Duplicate line item" %}');
|
||||
html += makeIconButton('fa-edit icon-blue', 'button-line-edit', pk, '{% trans "Edit line item" %}');
|
||||
html += makeIconButton('fa-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete line item" %}');
|
||||
}
|
||||
@ -2449,6 +2537,7 @@ function loadSalesOrderLineItemTable(table, options={}) {
|
||||
html += makeIconButton('fa-dollar-sign icon-green', 'button-price', pk, '{% trans "Calculate price" %}');
|
||||
}
|
||||
|
||||
html += makeIconButton('fa-clone', 'button-duplicate', pk, '{% trans "Duplicate line item" %}');
|
||||
html += makeIconButton('fa-edit icon-blue', 'button-edit', pk, '{% trans "Edit line item" %}');
|
||||
|
||||
var delete_disabled = false;
|
||||
@ -2480,6 +2569,28 @@ function loadSalesOrderLineItemTable(table, options={}) {
|
||||
// Configure callback functions once the table is loaded
|
||||
function setupCallbacks() {
|
||||
|
||||
// Callback for duplicating line items
|
||||
$(table).find('.button-duplicate').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
inventreeGet(`/api/order/so-line/${pk}/`, {}, {
|
||||
success: function(data) {
|
||||
|
||||
var fields = soLineItemFields();
|
||||
|
||||
constructForm('{% url "api-so-line-list" %}', {
|
||||
method: 'POST',
|
||||
fields: fields,
|
||||
data: data,
|
||||
title: '{% trans "Duplicate Line Item" %}',
|
||||
onSuccess: function(response) {
|
||||
$(table).bootstrapTable('refresh');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Callback for editing line items
|
||||
$(table).find('.button-edit').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
@ -1219,7 +1219,7 @@ function loadPartTable(table, url, options={}) {
|
||||
filters[key] = params[key];
|
||||
}
|
||||
|
||||
setupFilterList('parts', $(table), options.filterTarget || null);
|
||||
setupFilterList('parts', $(table), options.filterTarget, {download: true});
|
||||
|
||||
var columns = [
|
||||
{
|
||||
|
@ -43,7 +43,6 @@
|
||||
duplicateStockItem,
|
||||
editStockItem,
|
||||
editStockLocation,
|
||||
exportStock,
|
||||
findStockItemBySerialNumber,
|
||||
installStockItem,
|
||||
loadInstalledInTable,
|
||||
@ -506,49 +505,6 @@ function stockStatusCodes() {
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Export stock table
|
||||
*/
|
||||
function exportStock(params={}) {
|
||||
|
||||
constructFormBody({}, {
|
||||
title: '{% trans "Export Stock" %}',
|
||||
fields: {
|
||||
format: {
|
||||
label: '{% trans "Format" %}',
|
||||
help_text: '{% trans "Select file format" %}',
|
||||
required: true,
|
||||
type: 'choice',
|
||||
value: 'csv',
|
||||
choices: exportFormatOptions(),
|
||||
},
|
||||
sublocations: {
|
||||
label: '{% trans "Include Sublocations" %}',
|
||||
help_text: '{% trans "Include stock items in sublocations" %}',
|
||||
type: 'boolean',
|
||||
value: 'true',
|
||||
}
|
||||
},
|
||||
onSubmit: function(fields, form_options) {
|
||||
|
||||
var format = getFormFieldValue('format', fields['format'], form_options);
|
||||
var cascade = getFormFieldValue('sublocations', fields['sublocations'], form_options);
|
||||
|
||||
// Hide the modal
|
||||
$(form_options.modal).modal('hide');
|
||||
|
||||
var url = `{% url "stock-export" %}?format=${format}&cascade=${cascade}`;
|
||||
|
||||
for (var key in params) {
|
||||
url += `&${key}=${params[key]}`;
|
||||
}
|
||||
|
||||
location.href = url;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Assign multiple stock items to a customer
|
||||
*/
|
||||
@ -1615,7 +1571,7 @@ function loadStockTable(table, options) {
|
||||
original[k] = params[k];
|
||||
}
|
||||
|
||||
setupFilterList(filterKey, table, filterTarget);
|
||||
setupFilterList(filterKey, table, filterTarget, {download: true});
|
||||
|
||||
// Override the default values, or add new ones
|
||||
for (var key in params) {
|
||||
|
@ -7,6 +7,7 @@
|
||||
|
||||
/* exported
|
||||
customGroupSorter,
|
||||
downloadTableData,
|
||||
reloadtable,
|
||||
renderLink,
|
||||
reloadTableFilters,
|
||||
@ -21,6 +22,62 @@ function reloadtable(table) {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Download data from a table, via the API.
|
||||
* This requires a number of conditions to be met:
|
||||
*
|
||||
* - The API endpoint supports data download (on the server side)
|
||||
* - The table is "flat" (does not support multi-level loading, etc)
|
||||
* - The table has been loaded using the inventreeTable() function, not bootstrapTable()
|
||||
* (Refer to the "reloadTableFilters" function to see why!)
|
||||
*/
|
||||
function downloadTableData(table, opts={}) {
|
||||
|
||||
// Extract table configuration options
|
||||
var table_options = table.bootstrapTable('getOptions');
|
||||
|
||||
var url = table_options.url;
|
||||
|
||||
if (!url) {
|
||||
console.log('Error: downloadTableData could not find "url" parameter.');
|
||||
}
|
||||
|
||||
var query_params = table_options.query_params || {};
|
||||
|
||||
url += '?';
|
||||
|
||||
constructFormBody({}, {
|
||||
title: opts.title || '{% trans "Export Table Data" %}',
|
||||
fields: {
|
||||
format: {
|
||||
label: '{% trans "Format" %}',
|
||||
help_text: '{% trans "Select File Format" %}',
|
||||
required: true,
|
||||
type: 'choice',
|
||||
value: 'csv',
|
||||
choices: exportFormatOptions(),
|
||||
}
|
||||
},
|
||||
onSubmit: function(fields, form_options) {
|
||||
var format = getFormFieldValue('format', fields['format'], form_options);
|
||||
|
||||
// Hide the modal
|
||||
$(form_options.modal).modal('hide');
|
||||
|
||||
for (const [key, value] of Object.entries(query_params)) {
|
||||
url += `${key}=${value}&`;
|
||||
}
|
||||
|
||||
url += `export=${format}`;
|
||||
|
||||
location.href = url;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Render a URL for display
|
||||
* @param {String} text
|
||||
@ -114,6 +171,10 @@ function reloadTableFilters(table, filters) {
|
||||
}
|
||||
}
|
||||
|
||||
// Store the total set of query params
|
||||
// This is necessary for the "downloadTableData" function to work
|
||||
options.query_params = params;
|
||||
|
||||
options.queryParams = function(tableParams) {
|
||||
return convertQueryParameters(tableParams, params);
|
||||
};
|
||||
@ -221,7 +282,11 @@ $.fn.inventreeTable = function(options) {
|
||||
// Extract query params
|
||||
var filters = options.queryParams || options.filters || {};
|
||||
|
||||
// Store the total set of query params
|
||||
options.query_params = filters;
|
||||
|
||||
options.queryParams = function(params) {
|
||||
// Update the query parameters callback with the *new* filters
|
||||
return convertQueryParameters(params, filters);
|
||||
};
|
||||
|
||||
|
@ -11,9 +11,6 @@
|
||||
<div id='{{ prefix }}button-toolbar'>
|
||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||
<div class='btn-group' role='group'>
|
||||
<button class='btn btn-outline-secondary' id='stock-export' title='{% trans "Export Stock Information" %}'>
|
||||
<span class='fas fa-download'></span>
|
||||
</button>
|
||||
{% if barcodes %}
|
||||
<!-- Barcode actions menu -->
|
||||
<div class='btn-group' role='group'>
|
||||
|
Loading…
Reference in New Issue
Block a user