Merge remote-tracking branch 'inventree/master' into scheduling

# Conflicts:
#	InvenTree/InvenTree/version.py

(Update API version)
This commit is contained in:
Oliver 2022-03-08 12:25:36 +11:00
commit f7d724aa0c
63 changed files with 20987 additions and 19004 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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() {

View File

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

View File

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

View File

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

View File

@ -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() {

View File

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

View File

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

View File

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

View File

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

View File

@ -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() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -239,15 +239,6 @@
});
{% endif %}
$("#stock-export").click(function() {
exportStock({
{% if location %}
location: {{ location.pk }}
{% endif %}
});
});
$('#location-create').click(function () {
createStockLocation({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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