mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'master' of https://github.com/inventree/InvenTree into feature/performance-op
This commit is contained in:
commit
a36b22692e
3
.github/workflows/coverage.yaml
vendored
3
.github/workflows/coverage.yaml
vendored
@ -29,6 +29,7 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install gettext
|
||||
pip3 install invoke
|
||||
invoke install
|
||||
invoke static
|
||||
@ -43,6 +44,8 @@ jobs:
|
||||
rm test_db.sqlite
|
||||
invoke migrate
|
||||
invoke import-records -f data.json
|
||||
- name: Test Translations
|
||||
run: invoke translate
|
||||
- name: Check Migration Files
|
||||
run: python3 ci/check_migration_files.py
|
||||
- name: Upload Coverage Report
|
||||
|
29
.github/workflows/docker_build.yaml
vendored
29
.github/workflows/docker_build.yaml
vendored
@ -1,8 +1,11 @@
|
||||
# Test that the docker file builds correctly
|
||||
# Build and push latest docker image on push to master branch
|
||||
|
||||
name: Docker
|
||||
name: Docker Build
|
||||
|
||||
on: ["push", "pull_request"]
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
|
||||
jobs:
|
||||
|
||||
@ -10,7 +13,19 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Build Docker Image
|
||||
run: cd docker && docker build . --tag inventree:$(date +%s)
|
||||
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v2
|
||||
- name: Login to Dockerhub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build and Push
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: ./docker
|
||||
push: true
|
||||
repository: inventree/inventree
|
||||
tags: inventree/inventree:latest
|
||||
- name: Image Digest
|
||||
run: echo ${{ steps.docker_build.outputs.digest }}
|
||||
|
@ -52,6 +52,9 @@ def get_setting(environment_var, backup_val, default_value=None):
|
||||
# Determine if we are running in "test" mode e.g. "manage.py test"
|
||||
TESTING = 'test' in sys.argv
|
||||
|
||||
# New requirement for django 3.2+
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
|
||||
|
||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
@ -56,17 +56,26 @@ def is_email_configured():
|
||||
configured = True
|
||||
|
||||
if not settings.EMAIL_HOST:
|
||||
logger.warning("EMAIL_HOST is not configured")
|
||||
configured = False
|
||||
|
||||
# Display warning unless in test mode
|
||||
if not settings.TESTING:
|
||||
logger.warning("EMAIL_HOST is not configured")
|
||||
|
||||
if not settings.EMAIL_HOST_USER:
|
||||
logger.warning("EMAIL_HOST_USER is not configured")
|
||||
configured = False
|
||||
|
||||
# Display warning unless in test mode
|
||||
if not settings.TESTING:
|
||||
logger.warning("EMAIL_HOST_USER is not configured")
|
||||
|
||||
if not settings.EMAIL_HOST_PASSWORD:
|
||||
logger.warning("EMAIL_HOST_PASSWORD is not configured")
|
||||
configured = False
|
||||
|
||||
# Display warning unless in test mode
|
||||
if not settings.TESTING:
|
||||
logger.warning("EMAIL_HOST_PASSWORD is not configured")
|
||||
|
||||
return configured
|
||||
|
||||
|
||||
|
@ -62,6 +62,14 @@ class StatusCode:
|
||||
def items(cls):
|
||||
return cls.options.items()
|
||||
|
||||
@classmethod
|
||||
def keys(cls):
|
||||
return cls.options.keys()
|
||||
|
||||
@classmethod
|
||||
def labels(cls):
|
||||
return cls.options.values()
|
||||
|
||||
@classmethod
|
||||
def label(cls, value):
|
||||
""" Return the status code label associated with the provided value """
|
||||
|
@ -11,7 +11,7 @@ from rest_framework import generics
|
||||
|
||||
from django.conf.urls import url, include
|
||||
|
||||
from InvenTree.helpers import str2bool
|
||||
from InvenTree.helpers import str2bool, isNull
|
||||
from InvenTree.status_codes import BuildStatus
|
||||
|
||||
from .models import Build, BuildItem
|
||||
@ -194,7 +194,11 @@ class BuildItemList(generics.ListCreateAPIView):
|
||||
output = params.get('output', None)
|
||||
|
||||
if output:
|
||||
queryset = queryset.filter(install_into=output)
|
||||
|
||||
if isNull(output):
|
||||
queryset = queryset.filter(install_into=None)
|
||||
else:
|
||||
queryset = queryset.filter(install_into=output)
|
||||
|
||||
return queryset
|
||||
|
||||
|
@ -3,6 +3,7 @@ Django Forms for interacting with Build objects
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
@ -12,6 +13,8 @@ from InvenTree.forms import HelperForm
|
||||
from InvenTree.fields import RoundingDecimalFormField
|
||||
from InvenTree.fields import DatePickerFormField
|
||||
|
||||
from InvenTree.status_codes import StockStatus
|
||||
|
||||
from .models import Build, BuildItem, BuildOrderAttachment
|
||||
|
||||
from stock.models import StockLocation, StockItem
|
||||
@ -165,16 +168,10 @@ class AutoAllocateForm(HelperForm):
|
||||
|
||||
confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Confirm stock allocation'))
|
||||
|
||||
# Keep track of which build output we are interested in
|
||||
output = forms.ModelChoiceField(
|
||||
queryset=StockItem.objects.all(),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Build
|
||||
fields = [
|
||||
'confirm',
|
||||
'output',
|
||||
]
|
||||
|
||||
|
||||
@ -214,6 +211,13 @@ class CompleteBuildOutputForm(HelperForm):
|
||||
help_text=_('Location of completed parts'),
|
||||
)
|
||||
|
||||
stock_status = forms.ChoiceField(
|
||||
label=_('Status'),
|
||||
help_text=_('Build output stock status'),
|
||||
initial=StockStatus.OK,
|
||||
choices=StockStatus.items(),
|
||||
)
|
||||
|
||||
confirm_incomplete = forms.BooleanField(
|
||||
required=False,
|
||||
label=_('Confirm incomplete'),
|
||||
@ -232,10 +236,15 @@ class CompleteBuildOutputForm(HelperForm):
|
||||
fields = [
|
||||
'location',
|
||||
'output',
|
||||
'stock_status',
|
||||
'confirm',
|
||||
'confirm_incomplete',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class CancelBuildForm(HelperForm):
|
||||
""" Form for cancelling a build """
|
||||
|
@ -22,7 +22,7 @@ from markdownx.models import MarkdownxField
|
||||
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
|
||||
from InvenTree.status_codes import BuildStatus
|
||||
from InvenTree.status_codes import BuildStatus, StockStatus
|
||||
from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode
|
||||
from InvenTree.validators import validate_build_order_reference
|
||||
from InvenTree.models import InvenTreeAttachment
|
||||
@ -314,6 +314,42 @@ class Build(MPTTModel):
|
||||
'sub_part'
|
||||
)
|
||||
|
||||
@property
|
||||
def tracked_bom_items(self):
|
||||
"""
|
||||
Returns the "trackable" BOM items for this BuildOrder
|
||||
"""
|
||||
|
||||
items = self.bom_items
|
||||
items = items.filter(sub_part__trackable=True)
|
||||
|
||||
return items
|
||||
|
||||
def has_tracked_bom_items(self):
|
||||
"""
|
||||
Returns True if this BuildOrder has trackable BomItems
|
||||
"""
|
||||
|
||||
return self.tracked_bom_items.count() > 0
|
||||
|
||||
@property
|
||||
def untracked_bom_items(self):
|
||||
"""
|
||||
Returns the "non trackable" BOM items for this BuildOrder
|
||||
"""
|
||||
|
||||
items = self.bom_items
|
||||
items = items.filter(sub_part__trackable=False)
|
||||
|
||||
return items
|
||||
|
||||
def has_untracked_bom_items(self):
|
||||
"""
|
||||
Returns True if this BuildOrder has non trackable BomItems
|
||||
"""
|
||||
|
||||
return self.untracked_bom_items.count() > 0
|
||||
|
||||
@property
|
||||
def remaining(self):
|
||||
"""
|
||||
@ -449,6 +485,9 @@ class Build(MPTTModel):
|
||||
if self.completed < self.quantity:
|
||||
return False
|
||||
|
||||
if not self.areUntrackedPartsFullyAllocated():
|
||||
return False
|
||||
|
||||
# No issues!
|
||||
return True
|
||||
|
||||
@ -458,7 +497,7 @@ class Build(MPTTModel):
|
||||
Mark this build as complete
|
||||
"""
|
||||
|
||||
if not self.can_complete:
|
||||
if self.incomplete_count > 0:
|
||||
return
|
||||
|
||||
self.completion_date = datetime.now().date()
|
||||
@ -466,6 +505,9 @@ class Build(MPTTModel):
|
||||
self.status = BuildStatus.COMPLETE
|
||||
self.save()
|
||||
|
||||
# Remove untracked allocated stock
|
||||
self.subtractUntrackedStock(user)
|
||||
|
||||
# Ensure that there are no longer any BuildItem objects
|
||||
# which point to thie Build Order
|
||||
self.allocated_stock.all().delete()
|
||||
@ -489,7 +531,7 @@ class Build(MPTTModel):
|
||||
self.status = BuildStatus.CANCELLED
|
||||
self.save()
|
||||
|
||||
def getAutoAllocations(self, output):
|
||||
def getAutoAllocations(self):
|
||||
"""
|
||||
Return a list of StockItem objects which will be allocated
|
||||
using the 'AutoAllocate' function.
|
||||
@ -521,15 +563,19 @@ class Build(MPTTModel):
|
||||
|
||||
part = bom_item.sub_part
|
||||
|
||||
# If the part is "trackable" it cannot be auto-allocated
|
||||
if part.trackable:
|
||||
continue
|
||||
|
||||
# Skip any parts which are already fully allocated
|
||||
if self.isPartFullyAllocated(part, output):
|
||||
if self.isPartFullyAllocated(part, None):
|
||||
continue
|
||||
|
||||
# How many parts are required to complete the output?
|
||||
required = self.unallocatedQuantity(part, output)
|
||||
required = self.unallocatedQuantity(part, None)
|
||||
|
||||
# Grab a list of stock items which are available
|
||||
stock_items = self.availableStockItems(part, output)
|
||||
stock_items = self.availableStockItems(part, None)
|
||||
|
||||
# Ensure that the available stock items are in the correct location
|
||||
if self.take_from is not None:
|
||||
@ -544,7 +590,6 @@ class Build(MPTTModel):
|
||||
build_items = BuildItem.objects.filter(
|
||||
build=self,
|
||||
stock_item=stock_item,
|
||||
install_into=output
|
||||
)
|
||||
|
||||
if len(build_items) > 0:
|
||||
@ -567,24 +612,45 @@ class Build(MPTTModel):
|
||||
return allocations
|
||||
|
||||
@transaction.atomic
|
||||
def unallocateStock(self, output=None, part=None):
|
||||
def unallocateOutput(self, output, part=None):
|
||||
"""
|
||||
Deletes all stock allocations for this build.
|
||||
|
||||
Args:
|
||||
output: Specify which build output to delete allocations (optional)
|
||||
|
||||
Unallocate all stock which are allocated against the provided "output" (StockItem)
|
||||
"""
|
||||
|
||||
allocations = BuildItem.objects.filter(build=self.pk)
|
||||
|
||||
if output:
|
||||
allocations = allocations.filter(install_into=output.pk)
|
||||
allocations = BuildItem.objects.filter(
|
||||
build=self,
|
||||
install_into=output
|
||||
)
|
||||
|
||||
if part:
|
||||
allocations = allocations.filter(stock_item__part=part)
|
||||
|
||||
# Remove all the allocations
|
||||
allocations.delete()
|
||||
|
||||
@transaction.atomic
|
||||
def unallocateUntracked(self, part=None):
|
||||
"""
|
||||
Unallocate all "untracked" stock
|
||||
"""
|
||||
|
||||
allocations = BuildItem.objects.filter(
|
||||
build=self,
|
||||
install_into=None
|
||||
)
|
||||
|
||||
if part:
|
||||
allocations = allocations.filter(stock_item__part=part)
|
||||
|
||||
allocations.delete()
|
||||
|
||||
@transaction.atomic
|
||||
def unallocateAll(self):
|
||||
"""
|
||||
Deletes all stock allocations for this build.
|
||||
"""
|
||||
|
||||
allocations = BuildItem.objects.filter(build=self)
|
||||
|
||||
allocations.delete()
|
||||
|
||||
@transaction.atomic
|
||||
@ -679,13 +745,13 @@ class Build(MPTTModel):
|
||||
raise ValidationError(_("Build output does not match Build Order"))
|
||||
|
||||
# Unallocate all build items against the output
|
||||
self.unallocateStock(output)
|
||||
self.unallocateOutput(output)
|
||||
|
||||
# Remove the build output from the database
|
||||
output.delete()
|
||||
|
||||
@transaction.atomic
|
||||
def autoAllocate(self, output):
|
||||
def autoAllocate(self):
|
||||
"""
|
||||
Run auto-allocation routine to allocate StockItems to this Build.
|
||||
|
||||
@ -702,7 +768,7 @@ class Build(MPTTModel):
|
||||
See: getAutoAllocations()
|
||||
"""
|
||||
|
||||
allocations = self.getAutoAllocations(output)
|
||||
allocations = self.getAutoAllocations()
|
||||
|
||||
for item in allocations:
|
||||
# Create a new allocation
|
||||
@ -710,11 +776,29 @@ class Build(MPTTModel):
|
||||
build=self,
|
||||
stock_item=item['stock_item'],
|
||||
quantity=item['quantity'],
|
||||
install_into=output,
|
||||
install_into=None
|
||||
)
|
||||
|
||||
build_item.save()
|
||||
|
||||
@transaction.atomic
|
||||
def subtractUntrackedStock(self, user):
|
||||
"""
|
||||
Called when the Build is marked as "complete",
|
||||
this function removes the allocated untracked items from stock.
|
||||
"""
|
||||
|
||||
items = self.allocated_stock.filter(
|
||||
stock_item__part__trackable=False
|
||||
)
|
||||
|
||||
# Remove stock
|
||||
for item in items:
|
||||
item.complete_allocation(user)
|
||||
|
||||
# Delete allocation
|
||||
items.all().delete()
|
||||
|
||||
@transaction.atomic
|
||||
def completeBuildOutput(self, output, user, **kwargs):
|
||||
"""
|
||||
@ -726,6 +810,7 @@ class Build(MPTTModel):
|
||||
|
||||
# Select the location for the build output
|
||||
location = kwargs.get('location', self.destination)
|
||||
status = kwargs.get('status', StockStatus.OK)
|
||||
|
||||
# List the allocated BuildItem objects for the given output
|
||||
allocated_items = output.items_to_install.all()
|
||||
@ -733,9 +818,7 @@ class Build(MPTTModel):
|
||||
for build_item in allocated_items:
|
||||
|
||||
# TODO: This is VERY SLOW as each deletion from the database takes ~1 second to complete
|
||||
# TODO: Use celery / redis to offload the actual object deletion...
|
||||
# REF: https://www.botreetechnologies.com/blog/implementing-celery-using-django-for-background-task-processing
|
||||
# REF: https://code.tutsplus.com/tutorials/using-celery-with-django-for-background-task-processing--cms-28732
|
||||
# TODO: Use the background worker process to handle this task!
|
||||
|
||||
# Complete the allocation of stock for that item
|
||||
build_item.complete_allocation(user)
|
||||
@ -747,6 +830,7 @@ class Build(MPTTModel):
|
||||
output.build = self
|
||||
output.is_building = False
|
||||
output.location = location
|
||||
output.status = status
|
||||
|
||||
output.save()
|
||||
|
||||
@ -779,7 +863,7 @@ class Build(MPTTModel):
|
||||
if output:
|
||||
quantity *= output.quantity
|
||||
else:
|
||||
quantity *= self.remaining
|
||||
quantity *= self.quantity
|
||||
|
||||
return quantity
|
||||
|
||||
@ -807,7 +891,13 @@ class Build(MPTTModel):
|
||||
|
||||
allocations = self.allocatedItems(part, output)
|
||||
|
||||
allocated = allocations.aggregate(q=Coalesce(Sum('quantity'), 0))
|
||||
allocated = allocations.aggregate(
|
||||
q=Coalesce(
|
||||
Sum('quantity'),
|
||||
0,
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
return allocated['q']
|
||||
|
||||
@ -828,19 +918,39 @@ class Build(MPTTModel):
|
||||
|
||||
return self.unallocatedQuantity(part, output) == 0
|
||||
|
||||
def isFullyAllocated(self, output):
|
||||
def isFullyAllocated(self, output, verbose=False):
|
||||
"""
|
||||
Returns True if the particular build output is fully allocated.
|
||||
"""
|
||||
|
||||
for bom_item in self.bom_items:
|
||||
# If output is not specified, we are talking about "untracked" items
|
||||
if output is None:
|
||||
bom_items = self.untracked_bom_items
|
||||
else:
|
||||
bom_items = self.tracked_bom_items
|
||||
|
||||
fully_allocated = True
|
||||
|
||||
for bom_item in bom_items:
|
||||
part = bom_item.sub_part
|
||||
|
||||
if not self.isPartFullyAllocated(part, output):
|
||||
return False
|
||||
fully_allocated = False
|
||||
|
||||
if verbose:
|
||||
print(f"Part {part} is not fully allocated for output {output}")
|
||||
else:
|
||||
break
|
||||
|
||||
# All parts must be fully allocated!
|
||||
return True
|
||||
return fully_allocated
|
||||
|
||||
def areUntrackedPartsFullyAllocated(self):
|
||||
"""
|
||||
Returns True if the un-tracked parts are fully allocated for this BuildOrder
|
||||
"""
|
||||
|
||||
return self.isFullyAllocated(None)
|
||||
|
||||
def allocatedParts(self, output):
|
||||
"""
|
||||
@ -849,7 +959,13 @@ class Build(MPTTModel):
|
||||
|
||||
allocated = []
|
||||
|
||||
for bom_item in self.bom_items:
|
||||
# If output is not specified, we are talking about "untracked" items
|
||||
if output is None:
|
||||
bom_items = self.untracked_bom_items
|
||||
else:
|
||||
bom_items = self.tracked_bom_items
|
||||
|
||||
for bom_item in bom_items:
|
||||
part = bom_item.sub_part
|
||||
|
||||
if self.isPartFullyAllocated(part, output):
|
||||
@ -864,7 +980,13 @@ class Build(MPTTModel):
|
||||
|
||||
unallocated = []
|
||||
|
||||
for bom_item in self.bom_items:
|
||||
# If output is not specified, we are talking about "untracked" items
|
||||
if output is None:
|
||||
bom_items = self.untracked_bom_items
|
||||
else:
|
||||
bom_items = self.tracked_bom_items
|
||||
|
||||
for bom_item in bom_items:
|
||||
part = bom_item.sub_part
|
||||
|
||||
if not self.isPartFullyAllocated(part, output):
|
||||
@ -1014,10 +1136,12 @@ class BuildItem(models.Model):
|
||||
|
||||
errors = {}
|
||||
|
||||
if not self.install_into:
|
||||
raise ValidationError(_('Build item must specify a build output'))
|
||||
|
||||
try:
|
||||
|
||||
# If the 'part' is trackable, then the 'install_into' field must be set!
|
||||
if self.stock_item.part and self.stock_item.part.trackable and not self.install_into:
|
||||
raise ValidationError(_('Build item must specify a build output, as master part is marked as trackable'))
|
||||
|
||||
# Allocated part must be in the BOM for the master part
|
||||
if self.stock_item.part not in self.build.part.getRequiredParts(recursive=False):
|
||||
errors['stock_item'] = [_("Selected stock item not found in BOM for part '{p}'").format(p=self.build.part.full_name)]
|
||||
|
@ -12,48 +12,41 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Incomplete Build Ouputs" %}
|
||||
{% trans "Allocate Stock to Build" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block details %}
|
||||
{% if build.is_complete %}
|
||||
<div class='alert alert-block alert-success'>
|
||||
{% trans "Build order has been completed" %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if build.has_untracked_bom_items %}
|
||||
{% if build.active %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% if build.active %}
|
||||
<button class='btn btn-primary' type='button' id='btn-create-output' title='{% trans "Create new build output" %}'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "Create New Output" %}
|
||||
<button class='btn btn-success' type='button' id='btn-auto-allocate' title='{% trans "Allocate stock to build" %}'>
|
||||
<span class='fas fa-magic'></span> {% trans "Auto Allocate" %}
|
||||
</button>
|
||||
<!--
|
||||
<button class='btn btn-primary' type='button' id='btn-order-parts' title='{% trans "Order required parts" %}'>
|
||||
<span class='fas fa-shopping-cart'></span> {% trans "Order Parts" %}
|
||||
</button>
|
||||
-->
|
||||
<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>
|
||||
{% endif %}
|
||||
<!--
|
||||
<button class='btn btn-primary' type='button' id='btn-order-parts' title='{% trans "Order required parts" %}'>
|
||||
<span class='fas fa-shopping-cart'></span> {% trans "Order Parts" %}
|
||||
</button>
|
||||
-->
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
{% if build.incomplete_outputs %}
|
||||
<div class="panel-group" id="build-output-accordion" role="tablist" aria-multiselectable="true">
|
||||
{% for item in build.incomplete_outputs %}
|
||||
{% include "build/allocation_card.html" with item=item %}
|
||||
{% endfor %}
|
||||
{% if build.areUntrackedPartsFullyAllocated %}
|
||||
<div class='alert alert-block alert-success'>
|
||||
{% trans "Untracked stock has been fully allocated for this Build Order" %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class='alert alert-block alert-info'>
|
||||
<b>{% trans "Create a new build output" %}</b><br>
|
||||
{% trans "No incomplete build outputs remain." %}<br>
|
||||
{% trans "Create a new build output using the button above" %}
|
||||
<div class='alert alert-block alert-danger'>
|
||||
{% trans "Untracked stock has not been fully allocated for this Build Order" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<table class='table table-striped table-condensed' id='allocation-table-untracked'></table>
|
||||
{% else %}
|
||||
<div class='alert alert-block alert-info'>
|
||||
{% trans "This Build Order does not have any associated untracked BOM items" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
@ -66,19 +59,17 @@
|
||||
part: {{ build.part.pk }},
|
||||
};
|
||||
|
||||
{% for item in build.incomplete_outputs %}
|
||||
// Get the build output as a javascript object
|
||||
inventreeGet('{% url 'api-stock-detail' item.pk %}', {},
|
||||
{
|
||||
success: function(response) {
|
||||
loadBuildOutputAllocationTable(buildInfo, response);
|
||||
}
|
||||
}
|
||||
);
|
||||
{% endfor %}
|
||||
{% if build.has_untracked_bom_items %}
|
||||
// Load allocation table for un-tracked parts
|
||||
loadBuildOutputAllocationTable(buildInfo, null);
|
||||
{% endif %}
|
||||
|
||||
function reloadTable() {
|
||||
$('#allocation-table-untracked').bootstrapTable('refresh');
|
||||
}
|
||||
|
||||
{% if build.active %}
|
||||
$("#btn-allocate").on('click', function() {
|
||||
$("#btn-auto-allocate").on('click', function() {
|
||||
launchModalForm(
|
||||
"{% url 'build-auto-allocate' build.id %}",
|
||||
{
|
||||
@ -86,20 +77,12 @@
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
$('#btn-unallocate').on('click', function() {
|
||||
launchModalForm(
|
||||
"{% url 'build-unallocate' build.id %}",
|
||||
{
|
||||
reload: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$('#btn-create-output').click(function() {
|
||||
launchModalForm('{% url "build-output-create" build.id %}',
|
||||
{
|
||||
reload: true,
|
||||
success: reloadTable,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@ -7,23 +7,31 @@
|
||||
<div class="panel-heading" role="tab" id="heading-{{ pk }}">
|
||||
<div class="panel-title">
|
||||
<div class='row'>
|
||||
{% if tracked_items %}
|
||||
<a class='collapsed' aria-expanded='false' role="button" data-toggle="collapse" data-parent="#build-output-accordion" href="#collapse-{{ pk }}" aria-controls="collapse-{{ pk }}">
|
||||
{% endif %}
|
||||
<div class='col-sm-4'>
|
||||
{% if tracked_items %}
|
||||
<span class='fas fa-caret-right'></span>
|
||||
{% endif %}
|
||||
{{ item.part.full_name }}
|
||||
</div>
|
||||
<div class='col-sm-2'>
|
||||
{% if item.serial %}
|
||||
# {{ item.serial }}
|
||||
{% trans "Serial Number" %}: {{ item.serial }}
|
||||
{% else %}
|
||||
{% decimal item.quantity %}
|
||||
{% trans "Quantity" %}: {% decimal item.quantity %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if tracked_items %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class='col-sm-3'>
|
||||
<div>
|
||||
<div id='output-progress-{{ pk }}'>
|
||||
{% if tracked_items %}
|
||||
<span class='fas fa-spin fa-spinner'></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -9,7 +9,7 @@
|
||||
{% inventree_title %} | {% trans "Build Order" %} - {{ build }}
|
||||
{% endblock %}
|
||||
|
||||
{% block pre_content %}
|
||||
{% block header_pre_content %}
|
||||
{% if build.sales_order %}
|
||||
<div class='alert alert-block alert-info'>
|
||||
{% object_link 'so-detail' build.sales_order.id build.sales_order as link %}
|
||||
@ -24,6 +24,31 @@
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block header_post_content %}
|
||||
{% if build.active %}
|
||||
{% if build.can_complete %}
|
||||
<div class='alert alert-block alert-success'>
|
||||
{% trans "Build Order is ready to mark as completed" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if build.incomplete_count > 0 %}
|
||||
<div class='alert alert-block alert-danger'>
|
||||
{% trans "Build Order cannot be completed as outstanding outputs remain" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if build.completed < build.quantity %}
|
||||
<div class='alert alert-block alert-warning'>
|
||||
{% trans "Required build quantity has not yet been completed" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if not build.areUntrackedPartsFullyAllocated %}
|
||||
<div class='alert alert-block alert-warning'>
|
||||
{% trans "Stock has not been fully allocated to this Build Order" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block thumbnail %}
|
||||
<img class="part-thumb"
|
||||
{% if build.part.image %}
|
||||
@ -61,6 +86,11 @@ src="{% static 'img/blank_image.png' %}"
|
||||
</div>
|
||||
<!-- Build actions -->
|
||||
{% if roles.build.change %}
|
||||
{% if build.active %}
|
||||
<button id='build-complete' title='{% trans "Complete Build" %}' class='btn btn-success'>
|
||||
<span class='fas fa-paper-plane'></span>
|
||||
</button>
|
||||
{% endif %}
|
||||
<div class='btn-group'>
|
||||
<button id='build-options' title='{% trans "Build actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
|
||||
<span class='fas fa-tools'></span> <span class='caret'></span>
|
||||
@ -68,12 +98,11 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<ul class='dropdown-menu' role='menu'>
|
||||
<li><a href='#' id='build-edit'><span class='fas fa-edit icon-green'></span> {% trans "Edit Build" %}</a></li>
|
||||
{% if build.is_active %}
|
||||
<li><a href='#' id='build-complete'><span class='fas fa-tools'></span> {% trans "Complete Build" %}</a></li>
|
||||
<li><a href='#' id='build-cancel'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel Build" %}</a></li>
|
||||
{% endif %}
|
||||
{% if build.status == BuildStatus.CANCELLED and roles.build.delete %}
|
||||
<li><a href='#' id='build-delete'><span class='fas fa-trash-alt'></span> {% trans "Delete Build"% }</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
@ -172,6 +201,13 @@ src="{% static 'img/blank_image.png' %}"
|
||||
});
|
||||
|
||||
$("#build-complete").on('click', function() {
|
||||
|
||||
{% if build.incomplete_count > 0 %}
|
||||
showAlertDialog(
|
||||
'{% trans "Incomplete Outputs" %}',
|
||||
'{% trans "Build Order cannot be completed as incomplete build outputs remain" %}'
|
||||
);
|
||||
{% else %}
|
||||
launchModalForm(
|
||||
"{% url 'build-complete' build.id %}",
|
||||
{
|
||||
@ -179,6 +215,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
submit_text: '{% trans "Complete Build" %}',
|
||||
}
|
||||
);
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
$('#print-build-report').click(function() {
|
||||
|
@ -6,19 +6,68 @@
|
||||
{% include "build/navbar.html" with tab='output' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Build Outputs" %}
|
||||
{% endblock %}
|
||||
{% block content_panels %}
|
||||
|
||||
{% block details %}
|
||||
{% if not build.is_complete %}
|
||||
<div class='panel panel-default panel-inventree'>
|
||||
<div class='panel-heading'>
|
||||
<h4>
|
||||
{% trans "Incomplete Build Outputs" %}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{% include "stock_table.html" with read_only=True %}
|
||||
<div class='panel-content'>
|
||||
<div class='btn-group' role='group'>
|
||||
{% if build.active %}
|
||||
<button class='btn btn-primary' type='button' id='btn-create-output' title='{% trans "Create new build output" %}'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "Create New Output" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if build.incomplete_outputs %}
|
||||
<div class="panel-group" id="build-output-accordion" role="tablist" aria-multiselectable="true">
|
||||
{% for item in build.incomplete_outputs %}
|
||||
{% include "build/allocation_card.html" with item=item tracked_items=build.has_tracked_bom_items %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class='alert alert-block alert-info'>
|
||||
<b>{% trans "Create a new build output" %}</b><br>
|
||||
{% trans "No incomplete build outputs remain." %}<br>
|
||||
{% trans "Create a new build output using the button above" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class='panel panel-default panel-inventree'>
|
||||
<div class='panel-heading'>
|
||||
<h4>
|
||||
{% trans "Completed Build Outputs" %}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div class='panel-content'>
|
||||
{% include "stock_table.html" with read_only=True %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
$('#btn-create-output').click(function() {
|
||||
launchModalForm('{% url "build-output-create" build.id %}',
|
||||
{
|
||||
reload: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
loadStockTable($("#stock-table"), {
|
||||
params: {
|
||||
location_detail: true,
|
||||
@ -32,4 +81,23 @@ loadStockTable($("#stock-table"), {
|
||||
url: "{% url 'api-stock-list' %}",
|
||||
});
|
||||
|
||||
var buildInfo = {
|
||||
pk: {{ build.pk }},
|
||||
quantity: {{ build.quantity }},
|
||||
completed: {{ build.completed }},
|
||||
part: {{ build.part.pk }},
|
||||
};
|
||||
|
||||
{% for item in build.incomplete_outputs %}
|
||||
// Get the build output as a javascript object
|
||||
inventreeGet('{% url 'api-stock-detail' item.pk %}', {},
|
||||
{
|
||||
success: function(response) {
|
||||
loadBuildOutputAllocationTable(buildInfo, response);
|
||||
}
|
||||
}
|
||||
);
|
||||
{% endfor %}
|
||||
|
||||
|
||||
{% endblock %}
|
@ -5,11 +5,11 @@
|
||||
|
||||
{% if build.can_complete %}
|
||||
<div class='alert alert-block alert-success'>
|
||||
{% trans "Build can be completed" %}
|
||||
{% trans "Build Order is complete" %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class='alert alert-block alert-danger'>
|
||||
<b>{% trans "Build cannot be completed" %}</b><br>
|
||||
<b>{% trans "Build Order is incomplete" %}</b><br>
|
||||
<ul>
|
||||
{% if build.incomplete_count > 0 %}
|
||||
<li>{% trans "Incompleted build outputs remain" %}</li>
|
||||
@ -17,6 +17,9 @@
|
||||
{% if build.completed < build.quantity %}
|
||||
<li>{% trans "Required build quantity has not been completed" %}</li>
|
||||
{% endif %}
|
||||
{% if not build.areUntrackedPartsFullyAllocated %}
|
||||
<li>{% trans "Required stock has not been fully allocated" %}</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
@ -4,9 +4,10 @@
|
||||
|
||||
{% block pre_form_content %}
|
||||
|
||||
{% if fully_allocated %}
|
||||
<div class='alert alert-block alert-info'>
|
||||
<h4>{% trans "Stock allocation is complete" %}</h4>
|
||||
{% if not build.has_tracked_bom_items %}
|
||||
{% elif fully_allocated %}
|
||||
<div class='alert alert-block alert-success'>
|
||||
{% trans "Stock allocation is complete for this output" %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class='alert alert-block alert-danger'>
|
||||
@ -16,7 +17,7 @@
|
||||
<div class='panel panel-default'>
|
||||
<div class='panel panel-heading'>
|
||||
<a data-toggle='collapse' href='#collapse-unallocated'>
|
||||
{{ unallocated_parts|length }} {% trans "parts have not been fully allocated" %}
|
||||
{{ unallocated_parts|length }} {% trans "tracked parts have not been fully allocated" %}
|
||||
</a>
|
||||
</div>
|
||||
<div class='panel-collapse collapse' id='collapse-unallocated'>
|
||||
@ -41,7 +42,11 @@
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
{% include "hover_image.html" with image=build.part.image hover=True %}
|
||||
{% if output.serialized %}
|
||||
{{ output.part.full_name }} - {% trans "Serial Number" %} {{ output.serial }}
|
||||
{% else %}
|
||||
{% decimal output.quantity %} x {{ output.part.full_name }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -17,17 +17,11 @@
|
||||
</li>
|
||||
|
||||
{% if build.active %}
|
||||
<li class='list-group-item {% if tab == "parts" %}active{% endif %}' title='{% trans "Required Parts" %}'>
|
||||
<a href='{% url "build-parts" build.id %}'>
|
||||
<span class='fas fa-shapes'></span>
|
||||
{% trans "Required Parts" %}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class='list-group-item {% if tab == "allocate" %}active{% endif %}' title='{% trans "In Progress" %}'>
|
||||
|
||||
<li class='list-group-item {% if tab == "allocate" %}active{% endif %}' title='{% trans "Allocate Stock" %}'>
|
||||
<a href='{% url "build-allocate" build.id %}'>
|
||||
<span class='fas fa-tools'></span>
|
||||
{% trans "In Progress" %}
|
||||
{% trans "Allocate Stock" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
@ -1,30 +0,0 @@
|
||||
{% extends "build/build_base.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load status_codes %}
|
||||
|
||||
{% block menubar %}
|
||||
{% include "build/navbar.html" with tab='parts' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Required Parts" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block details %}
|
||||
<table class='table table-striped table-condensed' id='parts-table'></table>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
|
||||
{{ block.super }}
|
||||
|
||||
loadBuildPartsTable($('#parts-table'), {
|
||||
part: {{ build.part.pk }},
|
||||
build: {{ build.pk }},
|
||||
build_quantity: {{ build.quantity }},
|
||||
build_remaining: {{ build.remaining }},
|
||||
});
|
||||
|
||||
{% endblock %}
|
@ -19,6 +19,18 @@ class BuildTest(TestCase):
|
||||
def setUp(self):
|
||||
"""
|
||||
Initialize data to use for these tests.
|
||||
|
||||
The base Part 'assembly' has a BOM consisting of three parts:
|
||||
|
||||
- 5 x sub_part_1
|
||||
- 3 x sub_part_2
|
||||
- 2 x sub_part_3 (trackable)
|
||||
|
||||
We will build 10x 'assembly' parts, in two build outputs:
|
||||
|
||||
- 3 x output_1
|
||||
- 7 x output_2
|
||||
|
||||
"""
|
||||
|
||||
# Create a base "Part"
|
||||
@ -41,17 +53,31 @@ class BuildTest(TestCase):
|
||||
component=True
|
||||
)
|
||||
|
||||
self.sub_part_3 = Part.objects.create(
|
||||
name="Widget C",
|
||||
description="A widget",
|
||||
component=True,
|
||||
trackable=True
|
||||
)
|
||||
|
||||
# Create BOM item links for the parts
|
||||
BomItem.objects.create(
|
||||
part=self.assembly,
|
||||
sub_part=self.sub_part_1,
|
||||
quantity=10
|
||||
quantity=5
|
||||
)
|
||||
|
||||
BomItem.objects.create(
|
||||
part=self.assembly,
|
||||
sub_part=self.sub_part_2,
|
||||
quantity=25
|
||||
quantity=3
|
||||
)
|
||||
|
||||
# sub_part_3 is trackable!
|
||||
BomItem.objects.create(
|
||||
part=self.assembly,
|
||||
sub_part=self.sub_part_3,
|
||||
quantity=2
|
||||
)
|
||||
|
||||
# Create a "Build" object to make 10x objects
|
||||
@ -64,14 +90,14 @@ class BuildTest(TestCase):
|
||||
# Create some build output (StockItem) objects
|
||||
self.output_1 = StockItem.objects.create(
|
||||
part=self.assembly,
|
||||
quantity=5,
|
||||
quantity=3,
|
||||
is_building=True,
|
||||
build=self.build
|
||||
)
|
||||
|
||||
self.output_2 = StockItem.objects.create(
|
||||
part=self.assembly,
|
||||
quantity=5,
|
||||
quantity=7,
|
||||
is_building=True,
|
||||
build=self.build,
|
||||
)
|
||||
@ -82,10 +108,12 @@ class BuildTest(TestCase):
|
||||
|
||||
self.stock_2_1 = StockItem.objects.create(part=self.sub_part_2, quantity=5000)
|
||||
|
||||
self.stock_3_1 = StockItem.objects.create(part=self.sub_part_3, quantity=1000)
|
||||
|
||||
def test_init(self):
|
||||
# Perform some basic tests before we start the ball rolling
|
||||
|
||||
self.assertEqual(StockItem.objects.count(), 5)
|
||||
self.assertEqual(StockItem.objects.count(), 6)
|
||||
|
||||
# Build is PENDING
|
||||
self.assertEqual(self.build.status, status.BuildStatus.PENDING)
|
||||
@ -100,10 +128,10 @@ class BuildTest(TestCase):
|
||||
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1, self.output_1))
|
||||
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2, self.output_2))
|
||||
|
||||
self.assertEqual(self.build.unallocatedQuantity(self.sub_part_1, self.output_1), 50)
|
||||
self.assertEqual(self.build.unallocatedQuantity(self.sub_part_1, self.output_2), 50)
|
||||
self.assertEqual(self.build.unallocatedQuantity(self.sub_part_2, self.output_1), 125)
|
||||
self.assertEqual(self.build.unallocatedQuantity(self.sub_part_2, self.output_2), 125)
|
||||
self.assertEqual(self.build.unallocatedQuantity(self.sub_part_1, self.output_1), 15)
|
||||
self.assertEqual(self.build.unallocatedQuantity(self.sub_part_1, self.output_2), 35)
|
||||
self.assertEqual(self.build.unallocatedQuantity(self.sub_part_2, self.output_1), 9)
|
||||
self.assertEqual(self.build.unallocatedQuantity(self.sub_part_2, self.output_2), 21)
|
||||
|
||||
self.assertFalse(self.build.is_complete)
|
||||
|
||||
@ -144,84 +172,113 @@ class BuildTest(TestCase):
|
||||
quantity=99
|
||||
)
|
||||
|
||||
def allocate_stock(self, q11, q12, q21, output):
|
||||
# Assign stock to this build
|
||||
def allocate_stock(self, output, allocations):
|
||||
"""
|
||||
Allocate stock to this build, against a particular output
|
||||
|
||||
if q11 > 0:
|
||||
Args:
|
||||
output - StockItem object (or None)
|
||||
allocations - Map of {StockItem: quantity}
|
||||
"""
|
||||
|
||||
for item, quantity in allocations.items():
|
||||
BuildItem.objects.create(
|
||||
build=self.build,
|
||||
stock_item=self.stock_1_1,
|
||||
quantity=q11,
|
||||
stock_item=item,
|
||||
quantity=quantity,
|
||||
install_into=output
|
||||
)
|
||||
|
||||
if q12 > 0:
|
||||
BuildItem.objects.create(
|
||||
build=self.build,
|
||||
stock_item=self.stock_1_2,
|
||||
quantity=q12,
|
||||
install_into=output
|
||||
)
|
||||
|
||||
if q21 > 0:
|
||||
BuildItem.objects.create(
|
||||
build=self.build,
|
||||
stock_item=self.stock_2_1,
|
||||
quantity=q21,
|
||||
install_into=output,
|
||||
)
|
||||
|
||||
# Attempt to create another identical BuildItem
|
||||
b = BuildItem(
|
||||
build=self.build,
|
||||
stock_item=self.stock_2_1,
|
||||
quantity=q21
|
||||
)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
b.clean()
|
||||
|
||||
def test_partial_allocation(self):
|
||||
"""
|
||||
Partially allocate against output 1
|
||||
Test partial allocation of stock
|
||||
"""
|
||||
|
||||
self.allocate_stock(50, 50, 200, self.output_1)
|
||||
# Fully allocate tracked stock against build output 1
|
||||
self.allocate_stock(
|
||||
self.output_1,
|
||||
{
|
||||
self.stock_3_1: 6,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(self.build.isFullyAllocated(self.output_1))
|
||||
|
||||
# Partially allocate tracked stock against build output 2
|
||||
self.allocate_stock(
|
||||
self.output_2,
|
||||
{
|
||||
self.stock_3_1: 1,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertFalse(self.build.isFullyAllocated(self.output_2))
|
||||
self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_1, self.output_1))
|
||||
self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_2, self.output_1))
|
||||
|
||||
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1, self.output_2))
|
||||
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2, self.output_2))
|
||||
|
||||
# Check that the part has been allocated
|
||||
self.assertEqual(self.build.allocatedQuantity(self.sub_part_1, self.output_1), 100)
|
||||
# Partially allocate untracked stock against build
|
||||
self.allocate_stock(
|
||||
None,
|
||||
{
|
||||
self.stock_1_1: 1,
|
||||
self.stock_2_1: 1
|
||||
}
|
||||
)
|
||||
|
||||
self.build.unallocateStock(output=self.output_1)
|
||||
self.assertEqual(BuildItem.objects.count(), 0)
|
||||
self.assertFalse(self.build.isFullyAllocated(None, verbose=True))
|
||||
|
||||
# Check that the part has been unallocated
|
||||
self.assertEqual(self.build.allocatedQuantity(self.sub_part_1, self.output_1), 0)
|
||||
unallocated = self.build.unallocatedParts(None)
|
||||
|
||||
self.assertEqual(len(unallocated), 2)
|
||||
|
||||
self.allocate_stock(
|
||||
None,
|
||||
{
|
||||
self.stock_1_2: 100,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertFalse(self.build.isFullyAllocated(None, verbose=True))
|
||||
|
||||
unallocated = self.build.unallocatedParts(None)
|
||||
|
||||
self.assertEqual(len(unallocated), 1)
|
||||
|
||||
self.build.unallocateUntracked()
|
||||
|
||||
unallocated = self.build.unallocatedParts(None)
|
||||
|
||||
self.assertEqual(len(unallocated), 2)
|
||||
|
||||
self.assertFalse(self.build.areUntrackedPartsFullyAllocated())
|
||||
|
||||
# Now we "fully" allocate the untracked untracked items
|
||||
self.allocate_stock(
|
||||
None,
|
||||
{
|
||||
self.stock_1_1: 50,
|
||||
self.stock_2_1: 50,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(self.build.areUntrackedPartsFullyAllocated())
|
||||
|
||||
def test_auto_allocate(self):
|
||||
"""
|
||||
Test auto-allocation functionality against the build outputs
|
||||
Test auto-allocation functionality against the build outputs.
|
||||
|
||||
Note: auto-allocations only work for un-tracked stock!
|
||||
"""
|
||||
|
||||
allocations = self.build.getAutoAllocations(self.output_1)
|
||||
allocations = self.build.getAutoAllocations()
|
||||
|
||||
self.assertEqual(len(allocations), 1)
|
||||
|
||||
self.build.autoAllocate(self.output_1)
|
||||
self.build.autoAllocate()
|
||||
self.assertEqual(BuildItem.objects.count(), 1)
|
||||
|
||||
# Check that one part has been fully allocated to the build output
|
||||
self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_2, self.output_1))
|
||||
# Check that one un-tracked part has been fully allocated to the build
|
||||
self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_2, None))
|
||||
|
||||
# But, the *other* build output has not been allocated against
|
||||
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2, self.output_2))
|
||||
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1, None))
|
||||
|
||||
def test_cancel(self):
|
||||
"""
|
||||
@ -243,9 +300,33 @@ class BuildTest(TestCase):
|
||||
Test completion of a build output
|
||||
"""
|
||||
|
||||
self.allocate_stock(50, 50, 250, self.output_1)
|
||||
self.allocate_stock(50, 50, 250, self.output_2)
|
||||
# Allocate non-tracked parts
|
||||
self.allocate_stock(
|
||||
None,
|
||||
{
|
||||
self.stock_1_1: self.stock_1_1.quantity, # Allocate *all* stock from this item
|
||||
self.stock_1_2: 10,
|
||||
self.stock_2_1: 30
|
||||
}
|
||||
)
|
||||
|
||||
# Allocate tracked parts to output_1
|
||||
self.allocate_stock(
|
||||
self.output_1,
|
||||
{
|
||||
self.stock_3_1: 6
|
||||
}
|
||||
)
|
||||
|
||||
# Allocate tracked parts to output_2
|
||||
self.allocate_stock(
|
||||
self.output_2,
|
||||
{
|
||||
self.stock_3_1: 14
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(self.build.isFullyAllocated(None, verbose=True))
|
||||
self.assertTrue(self.build.isFullyAllocated(self.output_1))
|
||||
self.assertTrue(self.build.isFullyAllocated(self.output_2))
|
||||
|
||||
@ -265,19 +346,16 @@ class BuildTest(TestCase):
|
||||
self.assertEqual(BuildItem.objects.count(), 0)
|
||||
|
||||
# New stock items should have been created!
|
||||
self.assertEqual(StockItem.objects.count(), 4)
|
||||
|
||||
a = StockItem.objects.get(pk=self.stock_1_1.pk)
|
||||
self.assertEqual(StockItem.objects.count(), 7)
|
||||
|
||||
# This stock item has been depleted!
|
||||
with self.assertRaises(StockItem.DoesNotExist):
|
||||
StockItem.objects.get(pk=self.stock_1_2.pk)
|
||||
StockItem.objects.get(pk=self.stock_1_1.pk)
|
||||
|
||||
c = StockItem.objects.get(pk=self.stock_2_1.pk)
|
||||
# This stock item has *not* been depleted
|
||||
x = StockItem.objects.get(pk=self.stock_2_1.pk)
|
||||
|
||||
# Stock should have been subtracted from the original items
|
||||
self.assertEqual(a.quantity, 900)
|
||||
self.assertEqual(c.quantity, 4500)
|
||||
self.assertEqual(x.quantity, 4970)
|
||||
|
||||
# And 10 new stock items created for the build output
|
||||
outputs = StockItem.objects.filter(build=self.build)
|
||||
|
@ -15,7 +15,7 @@ from datetime import datetime, timedelta
|
||||
from .models import Build
|
||||
from stock.models import StockItem
|
||||
|
||||
from InvenTree.status_codes import BuildStatus
|
||||
from InvenTree.status_codes import BuildStatus, StockStatus
|
||||
|
||||
|
||||
class BuildTestSimple(TestCase):
|
||||
@ -335,6 +335,7 @@ class TestBuildViews(TestCase):
|
||||
'confirm_incomplete': 1,
|
||||
'location': 1,
|
||||
'output': self.output.pk,
|
||||
'stock_status': StockStatus.DAMAGED
|
||||
},
|
||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
|
||||
)
|
||||
@ -342,6 +343,7 @@ class TestBuildViews(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
data = json.loads(response.content)
|
||||
|
||||
self.assertTrue(data['form_valid'])
|
||||
|
||||
# Now the build should be able to be completed
|
||||
|
@ -21,7 +21,6 @@ build_detail_urls = [
|
||||
url(r'^notes/', views.BuildNotes.as_view(), name='build-notes'),
|
||||
|
||||
url(r'^children/', views.BuildDetail.as_view(template_name='build/build_children.html'), name='build-children'),
|
||||
url(r'^parts/', views.BuildDetail.as_view(template_name='build/parts.html'), name='build-parts'),
|
||||
url(r'^attachments/', views.BuildDetail.as_view(template_name='build/attachments.html'), name='build-attachments'),
|
||||
url(r'^output/', views.BuildDetail.as_view(template_name='build/build_output.html'), name='build-output'),
|
||||
|
||||
|
@ -18,8 +18,8 @@ from stock.models import StockLocation, StockItem
|
||||
|
||||
from InvenTree.views import AjaxUpdateView, AjaxCreateView, AjaxDeleteView
|
||||
from InvenTree.views import InvenTreeRoleMixin
|
||||
from InvenTree.helpers import str2bool, extract_serial_numbers, normalize
|
||||
from InvenTree.status_codes import BuildStatus
|
||||
from InvenTree.helpers import str2bool, extract_serial_numbers, normalize, isNull
|
||||
from InvenTree.status_codes import BuildStatus, StockStatus
|
||||
|
||||
|
||||
class BuildIndex(InvenTreeRoleMixin, ListView):
|
||||
@ -98,16 +98,6 @@ class BuildAutoAllocate(AjaxUpdateView):
|
||||
|
||||
initials = super().get_initial()
|
||||
|
||||
# Pointing to a particular build output?
|
||||
output = self.get_param('output')
|
||||
|
||||
if output:
|
||||
try:
|
||||
output = StockItem.objects.get(pk=output)
|
||||
initials['output'] = output
|
||||
except (ValueError, StockItem.DoesNotExist):
|
||||
pass
|
||||
|
||||
return initials
|
||||
|
||||
def get_context_data(self, *args, **kwargs):
|
||||
@ -119,18 +109,7 @@ class BuildAutoAllocate(AjaxUpdateView):
|
||||
|
||||
build = self.get_object()
|
||||
|
||||
form = self.get_form()
|
||||
|
||||
output_id = form['output'].value()
|
||||
|
||||
try:
|
||||
output = StockItem.objects.get(pk=output_id)
|
||||
except (ValueError, StockItem.DoesNotExist):
|
||||
output = None
|
||||
|
||||
if output:
|
||||
context['output'] = output
|
||||
context['allocations'] = build.getAutoAllocations(output)
|
||||
context['allocations'] = build.getAutoAllocations()
|
||||
|
||||
context['build'] = build
|
||||
|
||||
@ -140,18 +119,11 @@ class BuildAutoAllocate(AjaxUpdateView):
|
||||
|
||||
form = super().get_form()
|
||||
|
||||
if form['output'].value():
|
||||
# Hide the 'output' field
|
||||
form.fields['output'].widget = HiddenInput()
|
||||
|
||||
return form
|
||||
|
||||
def validate(self, build, form, **kwargs):
|
||||
|
||||
output = form.cleaned_data.get('output', None)
|
||||
|
||||
if not output:
|
||||
form.add_error(None, _('Build output must be specified'))
|
||||
pass
|
||||
|
||||
def save(self, build, form, **kwargs):
|
||||
"""
|
||||
@ -159,9 +131,7 @@ class BuildAutoAllocate(AjaxUpdateView):
|
||||
perform auto-allocations
|
||||
"""
|
||||
|
||||
output = form.cleaned_data.get('output', None)
|
||||
|
||||
build.autoAllocate(output)
|
||||
build.autoAllocate()
|
||||
|
||||
def get_data(self):
|
||||
return {
|
||||
@ -242,7 +212,7 @@ class BuildOutputCreate(AjaxUpdateView):
|
||||
|
||||
# Calculate the required quantity
|
||||
quantity = max(0, build.remaining - build.incomplete_count)
|
||||
initials['quantity'] = quantity
|
||||
initials['output_quantity'] = quantity
|
||||
|
||||
return initials
|
||||
|
||||
@ -365,10 +335,16 @@ class BuildUnallocate(AjaxUpdateView):
|
||||
|
||||
output_id = request.POST.get('output_id', None)
|
||||
|
||||
try:
|
||||
output = StockItem.objects.get(pk=output_id)
|
||||
except (ValueError, StockItem.DoesNotExist):
|
||||
output = None
|
||||
if output_id:
|
||||
|
||||
# If a "null" output is provided, we are trying to unallocate "untracked" stock
|
||||
if isNull(output_id):
|
||||
output = None
|
||||
else:
|
||||
try:
|
||||
output = StockItem.objects.get(pk=output_id)
|
||||
except (ValueError, StockItem.DoesNotExist):
|
||||
output = None
|
||||
|
||||
part_id = request.POST.get('part_id', None)
|
||||
|
||||
@ -383,9 +359,19 @@ class BuildUnallocate(AjaxUpdateView):
|
||||
form.add_error('confirm', _('Confirm unallocation of build stock'))
|
||||
form.add_error(None, _('Check the confirmation box'))
|
||||
else:
|
||||
build.unallocateStock(output=output, part=part)
|
||||
|
||||
valid = True
|
||||
|
||||
# Unallocate the entire build
|
||||
if not output_id:
|
||||
build.unallocateAll()
|
||||
# Unallocate a single output
|
||||
elif output:
|
||||
build.unallocateOutput(output, part=part)
|
||||
# Unallocate "untracked" parts
|
||||
else:
|
||||
build.unallocateUntracked(part=part)
|
||||
|
||||
data = {
|
||||
'form_valid': valid,
|
||||
}
|
||||
@ -410,8 +396,8 @@ class BuildComplete(AjaxUpdateView):
|
||||
|
||||
def validate(self, build, form, **kwargs):
|
||||
|
||||
if not build.can_complete:
|
||||
form.add_error(None, _('Build order cannot be completed'))
|
||||
if build.incomplete_count > 0:
|
||||
form.add_error(None, _('Build order cannot be completed - incomplete outputs remain'))
|
||||
|
||||
def save(self, build, form, **kwargs):
|
||||
"""
|
||||
@ -431,7 +417,7 @@ class BuildOutputComplete(AjaxUpdateView):
|
||||
View to mark a particular build output as Complete.
|
||||
|
||||
- Notifies the user of which parts will be removed from stock.
|
||||
- Removes allocated items from stock
|
||||
- Assignes (tracked) allocated items from stock to the build output
|
||||
- Deletes pending BuildItem objects
|
||||
"""
|
||||
|
||||
@ -463,11 +449,25 @@ class BuildOutputComplete(AjaxUpdateView):
|
||||
return form
|
||||
|
||||
def validate(self, build, form, **kwargs):
|
||||
"""
|
||||
Custom validation steps for the BuildOutputComplete" form
|
||||
"""
|
||||
|
||||
data = form.cleaned_data
|
||||
|
||||
output = data.get('output', None)
|
||||
|
||||
stock_status = data.get('stock_status', StockStatus.OK)
|
||||
|
||||
# Any "invalid" stock status defaults to OK
|
||||
try:
|
||||
stock_status = int(stock_status)
|
||||
except (ValueError):
|
||||
stock_status = StockStatus.OK
|
||||
|
||||
if int(stock_status) not in StockStatus.keys():
|
||||
form.add_error('stock_status', _('Invalid stock status value selected'))
|
||||
|
||||
if output:
|
||||
|
||||
quantity = data.get('quantity', None)
|
||||
@ -559,12 +559,20 @@ class BuildOutputComplete(AjaxUpdateView):
|
||||
|
||||
location = data.get('location', None)
|
||||
output = data.get('output', None)
|
||||
stock_status = data.get('stock_status', StockStatus.OK)
|
||||
|
||||
# Any "invalid" stock status defaults to OK
|
||||
try:
|
||||
stock_status = int(stock_status)
|
||||
except (ValueError):
|
||||
stock_status = StockStatus.OK
|
||||
|
||||
# Complete the build output
|
||||
build.completeBuildOutput(
|
||||
output,
|
||||
self.request.user,
|
||||
location=location,
|
||||
status=stock_status,
|
||||
)
|
||||
|
||||
def get_data(self):
|
||||
@ -632,10 +640,12 @@ class BuildAllocate(InvenTreeRoleMixin, DetailView):
|
||||
|
||||
build = self.get_object()
|
||||
part = build.part
|
||||
bom_items = part.bom_items
|
||||
bom_items = build.bom_items
|
||||
|
||||
context['part'] = part
|
||||
context['bom_items'] = bom_items
|
||||
context['has_tracked_bom_items'] = build.has_tracked_bom_items()
|
||||
context['has_untracked_bom_items'] = build.has_untracked_bom_items()
|
||||
context['BuildStatus'] = BuildStatus
|
||||
|
||||
context['bom_price'] = build.part.get_price_info(build.quantity, buy=False)
|
||||
|
Binary file not shown.
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
BIN
InvenTree/locale/fr/LC_MESSAGES/django.mo
Normal file
BIN
InvenTree/locale/fr/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
7288
InvenTree/locale/fr/LC_MESSAGES/django.po
Normal file
7288
InvenTree/locale/fr/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
BIN
InvenTree/locale/it/LC_MESSAGES/django.mo
Normal file
BIN
InvenTree/locale/it/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
7288
InvenTree/locale/it/LC_MESSAGES/django.po
Normal file
7288
InvenTree/locale/it/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
BIN
InvenTree/locale/ja/LC_MESSAGES/django.mo
Normal file
BIN
InvenTree/locale/ja/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
7288
InvenTree/locale/ja/LC_MESSAGES/django.po
Normal file
7288
InvenTree/locale/ja/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
BIN
InvenTree/locale/pl/LC_MESSAGES/django.mo
Normal file
BIN
InvenTree/locale/pl/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
7290
InvenTree/locale/pl/LC_MESSAGES/django.po
Normal file
7290
InvenTree/locale/pl/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
BIN
InvenTree/locale/ru/LC_MESSAGES/django.mo
Normal file
BIN
InvenTree/locale/ru/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
7290
InvenTree/locale/ru/LC_MESSAGES/django.po
Normal file
7290
InvenTree/locale/ru/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
BIN
InvenTree/locale/zh/LC_MESSAGES/django.mo
Normal file
BIN
InvenTree/locale/zh/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
7287
InvenTree/locale/zh/LC_MESSAGES/django.po
Normal file
7287
InvenTree/locale/zh/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
@ -60,28 +60,43 @@ class CategoryList(generics.ListCreateAPIView):
|
||||
queryset = PartCategory.objects.all()
|
||||
serializer_class = part_serializers.CategorySerializer
|
||||
|
||||
def get_queryset(self):
|
||||
def filter_queryset(self, queryset):
|
||||
"""
|
||||
Custom filtering:
|
||||
- Allow filtering by "null" parent to retrieve top-level part categories
|
||||
"""
|
||||
|
||||
cat_id = self.request.query_params.get('parent', None)
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
queryset = super().get_queryset()
|
||||
params = self.request.query_params
|
||||
|
||||
if cat_id is not None:
|
||||
cat_id = params.get('parent', None)
|
||||
|
||||
cascade = str2bool(params.get('cascade', False))
|
||||
|
||||
# Do not filter by category
|
||||
if cat_id is None:
|
||||
pass
|
||||
# Look for top-level categories
|
||||
elif isNull(cat_id):
|
||||
|
||||
# Look for top-level categories
|
||||
if isNull(cat_id):
|
||||
if not cascade:
|
||||
queryset = queryset.filter(parent=None)
|
||||
|
||||
else:
|
||||
try:
|
||||
cat_id = int(cat_id)
|
||||
queryset = queryset.filter(parent=cat_id)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
else:
|
||||
try:
|
||||
category = PartCategory.objects.get(pk=cat_id)
|
||||
|
||||
if cascade:
|
||||
parents = category.get_descendants(include_self=True)
|
||||
parent_ids = [p.id for p in parents]
|
||||
|
||||
queryset = queryset.filter(parent__in=parent_ids)
|
||||
else:
|
||||
queryset = queryset.filter(parent=category)
|
||||
|
||||
except (ValueError, PartCategory.DoesNotExist):
|
||||
pass
|
||||
|
||||
return queryset
|
||||
|
||||
|
@ -1163,7 +1163,16 @@ class Part(MPTTModel):
|
||||
Return the total amount of this part allocated to build orders
|
||||
"""
|
||||
|
||||
query = self.build_order_allocations().aggregate(total=Coalesce(Sum('quantity'), 0))
|
||||
query = self.build_order_allocations().aggregate(
|
||||
total=Coalesce(
|
||||
Sum(
|
||||
'quantity',
|
||||
output_field=models.DecimalField()
|
||||
),
|
||||
0,
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
return query['total']
|
||||
|
||||
@ -1179,7 +1188,16 @@ class Part(MPTTModel):
|
||||
Return the tutal quantity of this part allocated to sales orders
|
||||
"""
|
||||
|
||||
query = self.sales_order_allocations().aggregate(total=Coalesce(Sum('quantity'), 0))
|
||||
query = self.sales_order_allocations().aggregate(
|
||||
total=Coalesce(
|
||||
Sum(
|
||||
'quantity',
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
0,
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
return query['total']
|
||||
|
||||
@ -1189,10 +1207,12 @@ class Part(MPTTModel):
|
||||
against both build orders and sales orders.
|
||||
"""
|
||||
|
||||
return sum([
|
||||
self.build_order_allocation_count(),
|
||||
self.sales_order_allocation_count(),
|
||||
])
|
||||
return sum(
|
||||
[
|
||||
self.build_order_allocation_count(),
|
||||
self.sales_order_allocation_count(),
|
||||
],
|
||||
)
|
||||
|
||||
def stock_entries(self, include_variants=True, in_stock=None):
|
||||
""" Return all stock entries for this Part.
|
||||
|
@ -4,6 +4,7 @@ JSON serializers for Part app
|
||||
import imghdr
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.db.models.functions import Coalesce
|
||||
from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
|
||||
@ -208,7 +209,8 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
queryset = queryset.annotate(
|
||||
in_stock=Coalesce(
|
||||
SubquerySum('stock_items__quantity', filter=StockItem.IN_STOCK_FILTER),
|
||||
Decimal(0)
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
)
|
||||
|
||||
@ -227,6 +229,7 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
building=Coalesce(
|
||||
SubquerySum('builds__quantity', filter=build_filter),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
@ -240,9 +243,11 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
ordering=Coalesce(
|
||||
SubquerySum('supplier_parts__purchase_order_line_items__quantity', filter=order_filter),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
) - Coalesce(
|
||||
SubquerySum('supplier_parts__purchase_order_line_items__received', filter=order_filter),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
@ -251,6 +256,7 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
suppliers=Coalesce(
|
||||
SubqueryCount('supplier_parts'),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
)
|
||||
|
||||
|
@ -2,6 +2,10 @@
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block menubar %}
|
||||
{% include 'part/category_navbar.html' with tab='parts' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class='panel panel-default panel-inventree'>
|
||||
@ -100,14 +104,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if category and category.children.all|length > 0 %}
|
||||
{% include "part/subcategories.html" with children=category.children.all collapse_id="categories" %}
|
||||
{% elif children|length > 0 %}
|
||||
{% include "part/subcategories.html" with children=children collapse_id="categories" %}
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
{% block category_content %}
|
||||
|
||||
<div id='button-toolbar'>
|
||||
<div class='btn-group'>
|
||||
<button class='btn btn-default' id='part-export' title='{% trans "Export Part Data" %}'>
|
||||
@ -150,6 +150,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block category_tables %}
|
||||
{% endblock category_tables %}
|
||||
@ -162,24 +164,10 @@
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
{% if category %}
|
||||
enableNavbar({
|
||||
label: 'category',
|
||||
toggleId: '#category-menu-toggle',
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
if (inventreeLoadInt("show-part-cats") == 1) {
|
||||
$("#collapse-item-categories").collapse('show');
|
||||
}
|
||||
|
||||
$("#collapse-item-categories").on('shown.bs.collapse', function() {
|
||||
inventreeSave('show-part-cats', 1);
|
||||
});
|
||||
|
||||
$("#collapse-item-categories").on('hidden.bs.collapse', function() {
|
||||
inventreeDel('show-part-cats');
|
||||
});
|
||||
|
||||
$("#cat-create").click(function() {
|
||||
launchModalForm(
|
||||
|
@ -8,17 +8,34 @@
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class='list-group-item {% if tab == "subcategories" %}active{% endif %}' title='{% trans "Subcategories" %}'>
|
||||
{% if category %}
|
||||
<a href='{% url "category-subcategory" category.id %}'>
|
||||
{% else %}
|
||||
<a href='{% url "category-index-subcategory" %}'>
|
||||
{% endif %}
|
||||
<span class='fas fa-sitemap'></span>
|
||||
{% trans "Subcategories" %}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class='list-group-item {% if tab == "parts" %}active{% endif %}' title='{% trans "Parts" %}'>
|
||||
{% if category %}
|
||||
<a href='{% url "category-detail" category.id %}'>
|
||||
{% else %}
|
||||
<a href='{% url "part-index" %}'>
|
||||
{% endif %}
|
||||
<span class='fas fa-shapes'></span>
|
||||
{% trans "Parts" %}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% if category %}
|
||||
<li class='list-group-item {% if tab == "parameters" %}active{% endif %}' title='{% trans "Parameters" %}'>
|
||||
<a href='{% url "category-parametric" category.id %}'>
|
||||
<span class='fas fa-tasks'></span>
|
||||
{% trans "Parameters" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
@ -1,22 +0,0 @@
|
||||
{% extends "collapse.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block collapse_title %}
|
||||
{{ children | length }} {% trans 'Child Categories' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block collapse_content %}
|
||||
<ul class="list-group">
|
||||
{% for child in children %}
|
||||
<li class="list-group-item">
|
||||
<strong><a href="{% url 'category-detail' child.id %}">{{ child.name }}</a></strong>
|
||||
{% if child.description %}
|
||||
<em> - {{ child.description }}</em>
|
||||
{% endif %}
|
||||
{% if child.partcount > 0 %}
|
||||
<span class='badge'>{{ child.partcount }} {% trans 'Part' %}{% if child.partcount > 1 %}s{% endif %}</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
51
InvenTree/part/templates/part/subcategory.html
Normal file
51
InvenTree/part/templates/part/subcategory.html
Normal file
@ -0,0 +1,51 @@
|
||||
{% extends "part/category.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
{% load static %}
|
||||
|
||||
{% block menubar %}
|
||||
{% include 'part/category_navbar.html' with tab='subcategories' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block category_content %}
|
||||
|
||||
<div class='panel panel-default panel-inventree'>
|
||||
|
||||
<div class='panel-heading'>
|
||||
<h4>{% trans "Subcategories" %}</h4>
|
||||
</div>
|
||||
|
||||
<div id='button-toolbar'>
|
||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||
|
||||
<div class='filter-list' id='filter-list-category'>
|
||||
<!-- An empty div in which the filter list will be constructed -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class='table table-striped table-condensed' id='subcategory-table' data-toolbar='#button-toolbar'></table>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
enableNavbar({
|
||||
label: 'category',
|
||||
toggleId: '#category-menu-toggle',
|
||||
});
|
||||
|
||||
loadPartCategoryTable($('#subcategory-table'), {
|
||||
params: {
|
||||
{% if category %}
|
||||
parent: {{ category.pk }}
|
||||
{% else %}
|
||||
parent: 'null'
|
||||
{% endif %}
|
||||
}
|
||||
});
|
||||
|
||||
{% endblock %}
|
@ -37,12 +37,54 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
super().setUp()
|
||||
|
||||
def test_get_categories(self):
|
||||
""" Test that we can retrieve list of part categories """
|
||||
"""
|
||||
Test that we can retrieve list of part categories,
|
||||
with various filtering options.
|
||||
"""
|
||||
|
||||
url = reverse('api-part-category-list')
|
||||
|
||||
# Request *all* part categories
|
||||
response = self.client.get(url, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 8)
|
||||
|
||||
# Request top-level part categories only
|
||||
response = self.client.get(
|
||||
url,
|
||||
{
|
||||
'parent': 'null',
|
||||
},
|
||||
format='json'
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 2)
|
||||
|
||||
# Children of PartCategory<1>, cascade
|
||||
response = self.client.get(
|
||||
url,
|
||||
{
|
||||
'parent': 1,
|
||||
'cascade': 'true',
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 5)
|
||||
|
||||
# Children of PartCategory<1>, do not cascade
|
||||
response = self.client.get(
|
||||
url,
|
||||
{
|
||||
'parent': 1,
|
||||
'cascade': 'false',
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 3)
|
||||
|
||||
def test_add_categories(self):
|
||||
""" Check that we can add categories """
|
||||
data = {
|
||||
|
@ -88,14 +88,26 @@ category_parameter_urls = [
|
||||
url(r'^(?P<pid>\d+)/delete/', views.CategoryParameterTemplateDelete.as_view(), name='category-param-template-delete'),
|
||||
]
|
||||
|
||||
part_category_urls = [
|
||||
url(r'^edit/?', views.CategoryEdit.as_view(), name='category-edit'),
|
||||
url(r'^delete/?', views.CategoryDelete.as_view(), name='category-delete'),
|
||||
category_urls = [
|
||||
|
||||
url(r'^parameters/', include(category_parameter_urls)),
|
||||
# Create a new category
|
||||
url(r'^new/', views.CategoryCreate.as_view(), name='category-create'),
|
||||
|
||||
url(r'^parametric/?', views.CategoryParametric.as_view(), name='category-parametric'),
|
||||
url(r'^.*$', views.CategoryDetail.as_view(), name='category-detail'),
|
||||
# Top level subcategory display
|
||||
url(r'^subcategory/', views.PartIndex.as_view(template_name='part/subcategory.html'), name='category-index-subcategory'),
|
||||
|
||||
# Category detail views
|
||||
url(r'(?P<pk>\d+)/', include([
|
||||
url(r'^edit/', views.CategoryEdit.as_view(), name='category-edit'),
|
||||
url(r'^delete/', views.CategoryDelete.as_view(), name='category-delete'),
|
||||
url(r'^parameters/', include(category_parameter_urls)),
|
||||
|
||||
url(r'^subcategory/', views.CategoryDetail.as_view(template_name='part/subcategory.html'), name='category-subcategory'),
|
||||
url(r'^parametric/', views.CategoryParametric.as_view(), name='category-parametric'),
|
||||
|
||||
# Anything else
|
||||
url(r'^.*$', views.CategoryDetail.as_view(), name='category-detail'),
|
||||
]))
|
||||
]
|
||||
|
||||
part_bom_urls = [
|
||||
@ -106,9 +118,6 @@ part_bom_urls = [
|
||||
# URL list for part web interface
|
||||
part_urls = [
|
||||
|
||||
# Create a new category
|
||||
url(r'^category/new/?', views.CategoryCreate.as_view(), name='category-create'),
|
||||
|
||||
# Create a new part
|
||||
url(r'^new/?', views.PartCreate.as_view(), name='part-create'),
|
||||
|
||||
@ -125,7 +134,7 @@ part_urls = [
|
||||
url(r'^(?P<pk>\d+)/', include(part_detail_urls)),
|
||||
|
||||
# Part category
|
||||
url(r'^category/(?P<pk>\d+)/', include(part_category_urls)),
|
||||
url(r'^category/', include(category_urls)),
|
||||
|
||||
# Part related
|
||||
url(r'^related-parts/', include(part_related_urls)),
|
||||
|
@ -281,28 +281,46 @@ class StockLocationList(generics.ListCreateAPIView):
|
||||
queryset = StockLocation.objects.all()
|
||||
serializer_class = LocationSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
def filter_queryset(self, queryset):
|
||||
"""
|
||||
Custom filtering:
|
||||
- Allow filtering by "null" parent to retrieve top-level stock locations
|
||||
"""
|
||||
|
||||
queryset = super().get_queryset()
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
loc_id = self.request.query_params.get('parent', None)
|
||||
params = self.request.query_params
|
||||
|
||||
if loc_id is not None:
|
||||
loc_id = params.get('parent', None)
|
||||
|
||||
cascade = str2bool(params.get('cascade', False))
|
||||
|
||||
# Look for top-level locations
|
||||
if isNull(loc_id):
|
||||
# Do not filter by location
|
||||
if loc_id is None:
|
||||
pass
|
||||
# Look for top-level locations
|
||||
elif isNull(loc_id):
|
||||
|
||||
# If we allow "cascade" at the top-level, this essentially means *all* locations
|
||||
if not cascade:
|
||||
queryset = queryset.filter(parent=None)
|
||||
|
||||
else:
|
||||
try:
|
||||
loc_id = int(loc_id)
|
||||
queryset = queryset.filter(parent=loc_id)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
else:
|
||||
|
||||
try:
|
||||
location = StockLocation.objects.get(pk=loc_id)
|
||||
|
||||
# All sub-locations to be returned too?
|
||||
if cascade:
|
||||
parents = location.get_descendants(include_self=True)
|
||||
parent_ids = [p.id for p in parents]
|
||||
queryset = queryset.filter(parent__in=parent_ids)
|
||||
|
||||
else:
|
||||
queryset = queryset.filter(parent=location)
|
||||
|
||||
except (ValueError, StockLocation.DoesNotExist):
|
||||
pass
|
||||
|
||||
return queryset
|
||||
|
||||
@ -320,6 +338,11 @@ class StockLocationList(generics.ListCreateAPIView):
|
||||
'description',
|
||||
]
|
||||
|
||||
ordering_fields = [
|
||||
'name',
|
||||
'items',
|
||||
]
|
||||
|
||||
|
||||
class StockList(generics.ListCreateAPIView):
|
||||
""" API endpoint for list view of Stock objects
|
||||
|
@ -634,6 +634,7 @@ class StockItem(MPTTModel):
|
||||
|
||||
self.customer = None
|
||||
self.location = location
|
||||
self.sales_order = None
|
||||
|
||||
self.save()
|
||||
|
||||
|
@ -30,7 +30,7 @@
|
||||
loadStockTable($("#stock-table"), {
|
||||
params: {
|
||||
location_detail: true,
|
||||
part_details: true,
|
||||
part_detail: false,
|
||||
ancestor: {{ item.id }},
|
||||
},
|
||||
name: 'item-childs',
|
||||
|
@ -2,8 +2,15 @@
|
||||
{% load static %}
|
||||
{% load inventree_extras %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block menubar %}
|
||||
{% include "stock/location_navbar.html" with tab="stock" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class='panel panel-default panel-inventree'>
|
||||
|
||||
{% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %}
|
||||
{% if owner_control.value == "True" %}
|
||||
{% authorized_owners location.owner as owners %}
|
||||
@ -120,36 +127,29 @@
|
||||
</div>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if location and location.children.all|length > 0 %}
|
||||
{% include 'stock/location_list.html' with children=location.children.all collapse_id="locations" %}
|
||||
{% elif locations|length > 0 %}
|
||||
{% include 'stock/location_list.html' with children=locations collapse_id="locations" %}
|
||||
{% endif %}
|
||||
{% block location_content %}
|
||||
|
||||
<hr>
|
||||
|
||||
{% include "stock_table.html" %}
|
||||
<div class='panel panel-default panel-inventree'>
|
||||
<div class='panel-heading'>
|
||||
<h4>{% trans "Stock Items" %}</h4>
|
||||
</div>
|
||||
{% include "stock_table.html" %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_load %}
|
||||
{{ block.super }}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
if (inventreeLoadInt("show-part-locs") == 1) {
|
||||
$("#collapse-item-locations").collapse('show');
|
||||
}
|
||||
|
||||
$("#collapse-item-locations").on('shown.bs.collapse', function() {
|
||||
inventreeSave('show-part-locs', 1);
|
||||
});
|
||||
|
||||
$("#collapse-item-locations").on('hidden.bs.collapse', function() {
|
||||
inventreeDel('show-part-locs');
|
||||
enableNavbar({
|
||||
label: 'location',
|
||||
toggleId: '#location-menu-toggle'
|
||||
});
|
||||
|
||||
{% if location %}
|
||||
@ -261,7 +261,7 @@
|
||||
],
|
||||
params: {
|
||||
{% if location %}
|
||||
location: {{ location.id }},
|
||||
location: {{ location.pk }},
|
||||
{% endif %}
|
||||
part_detail: true,
|
||||
location_detail: true,
|
||||
|
@ -1,24 +0,0 @@
|
||||
{% extends "collapse.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% if roles.stock_location.view or roles.stock.view %}
|
||||
{% block collapse_title %}
|
||||
{% trans 'Sub-Locations' %}<span class='badge'>{{ children|length }}</span>
|
||||
{% endblock %}
|
||||
|
||||
{% block collapse_content %}
|
||||
<ul class="list-group">
|
||||
{% for child in children %}
|
||||
<li class="list-group-item"><a href="{% url 'stock-location-detail' child.id %}">{{ child.name }}</a> - <i>{{ child.description }}</i>
|
||||
{% if child.item_count > 0 %}
|
||||
<!-- span class='badge'>{{ child.item_count }} Item{% if child.item_count > 1 %}s{% endif %}</span> -->
|
||||
<span class='badge'>
|
||||
{% comment %}Translators: pluralize with counter{% endcomment %}
|
||||
{% blocktrans count counter=child.item_count %}{{ counter }} Item{% plural %}{{ counter }} Items{% endblocktrans %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
{% endif %}
|
33
InvenTree/stock/templates/stock/location_navbar.html
Normal file
33
InvenTree/stock/templates/stock/location_navbar.html
Normal file
@ -0,0 +1,33 @@
|
||||
{% load i18n %}
|
||||
|
||||
<ul class='list-group'>
|
||||
|
||||
<li class='list-group-item'>
|
||||
<a href='#' id='location-menu-toggle'>
|
||||
<span class='menu-tab-icon fas fa-expand-arrows-alt'></span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class='list-group-item {% if tab == "sublocations" %}active{% endif %}' title='{% trans "Sublocations" %}'>
|
||||
{% if location %}
|
||||
<a href='{% url "stock-location-sublocation" location.id %}'>
|
||||
{% else %}
|
||||
<a href='{% url "stock-sublocations" %}'>
|
||||
{% endif %}
|
||||
<span class='fas fa-sitemap'></span>
|
||||
{% trans "Sublocations" %}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class='list-group-item {% if tab == "stock" %}active{% endif %}' title='{% trans "Stock Items" %}'>
|
||||
{% if location %}
|
||||
<a href='{% url "stock-location-detail" location.id %}'>
|
||||
{% else %}
|
||||
<a href='{% url "stock-index" %}'>
|
||||
{% endif %}
|
||||
<span class='fas fa-boxes'></span>
|
||||
{% trans "Stock Items" %}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
</ul>
|
74
InvenTree/stock/templates/stock/sublocation.html
Normal file
74
InvenTree/stock/templates/stock/sublocation.html
Normal file
@ -0,0 +1,74 @@
|
||||
{% extends "stock/location.html" %}
|
||||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block menubar %}
|
||||
{% include "stock/location_navbar.html" with tab="sublocations" %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block location_content %}
|
||||
|
||||
<div class='panel panel-default panel-inventree'>
|
||||
<div class='panel-heading'>
|
||||
<h4>{% trans "Sublocations" %}</h4>
|
||||
</div>
|
||||
|
||||
<div id='button-toolbar'>
|
||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||
<!-- Printing actions menu -->
|
||||
<div class='btn-group'>
|
||||
<button id='location-print-options' class='btn btn-primary dropdown-toggle' type='button' data-toggle="dropdown" title='{% trans "Printing Actions" %}'>
|
||||
<span class='fas fa-print'></span> <span class='caret'></span>
|
||||
</button>
|
||||
<ul class='dropdown-menu'>
|
||||
<li><a href='#' id='multi-location-print-label' title='{% trans "Print labels" %}'><span class='fas fa-tags'></span> {% trans "Print labels" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class='filter-list' id='filter-list-location'>
|
||||
<!-- An empty div in which the filter list will be constructed -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='sublocation-table'></table>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
loadStockLocationTable($('#sublocation-table'), {
|
||||
params: {
|
||||
{% if location %}
|
||||
parent: {{ location.pk }},
|
||||
{% else %}
|
||||
parent: 'null',
|
||||
{% endif %}
|
||||
}
|
||||
});
|
||||
|
||||
linkButtonsToSelection(
|
||||
$('#sublocation-table'),
|
||||
[
|
||||
'#location-print-options',
|
||||
]
|
||||
);
|
||||
|
||||
$('#multi-location-print-label').click(function() {
|
||||
|
||||
var selections = $('#sublocation-table').bootstrapTable('getSelections');
|
||||
|
||||
var locations = [];
|
||||
|
||||
selections.forEach(function(loc) {
|
||||
locations.push(loc.pk);
|
||||
});
|
||||
|
||||
printStockLocationLabels(locations);
|
||||
})
|
||||
|
||||
{% endblock %}
|
@ -6,14 +6,21 @@ from django.conf.urls import url, include
|
||||
|
||||
from . import views
|
||||
|
||||
# URL list for web interface
|
||||
stock_location_detail_urls = [
|
||||
url(r'^edit/?', views.StockLocationEdit.as_view(), name='stock-location-edit'),
|
||||
url(r'^delete/?', views.StockLocationDelete.as_view(), name='stock-location-delete'),
|
||||
url(r'^qr_code/?', views.StockLocationQRCode.as_view(), name='stock-location-qr'),
|
||||
location_urls = [
|
||||
|
||||
url(r'^new/', views.StockLocationCreate.as_view(), name='stock-location-create'),
|
||||
|
||||
url(r'^(?P<pk>\d+)/', include([
|
||||
url(r'^edit/?', views.StockLocationEdit.as_view(), name='stock-location-edit'),
|
||||
url(r'^delete/?', views.StockLocationDelete.as_view(), name='stock-location-delete'),
|
||||
url(r'^qr_code/?', views.StockLocationQRCode.as_view(), name='stock-location-qr'),
|
||||
|
||||
url(r'sublocation/', views.StockLocationDetail.as_view(template_name='stock/sublocation.html'), name='stock-location-sublocation'),
|
||||
|
||||
# Anything else
|
||||
url('^.*$', views.StockLocationDetail.as_view(), name='stock-location-detail'),
|
||||
])),
|
||||
|
||||
# Anything else
|
||||
url('^.*$', views.StockLocationDetail.as_view(), name='stock-location-detail'),
|
||||
]
|
||||
|
||||
stock_item_detail_urls = [
|
||||
@ -49,9 +56,7 @@ stock_tracking_urls = [
|
||||
|
||||
stock_urls = [
|
||||
# Stock location
|
||||
url(r'^location/(?P<pk>\d+)/', include(stock_location_detail_urls)),
|
||||
|
||||
url(r'^location/new/', views.StockLocationCreate.as_view(), name='stock-location-create'),
|
||||
url(r'^location/', include(location_urls)),
|
||||
|
||||
url(r'^item/new/?', views.StockItemCreate.as_view(), name='stock-item-create'),
|
||||
|
||||
@ -81,5 +86,7 @@ stock_urls = [
|
||||
# Individual stock items
|
||||
url(r'^item/(?P<pk>\d+)/', include(stock_item_detail_urls)),
|
||||
|
||||
url(r'^sublocations/', views.StockIndex.as_view(template_name='stock/sublocation.html'), name='stock-sublocations'),
|
||||
|
||||
url(r'^.*$', views.StockIndex.as_view(), name='stock-index'),
|
||||
]
|
||||
|
@ -32,12 +32,17 @@ function newBuildOrder(options={}) {
|
||||
}
|
||||
|
||||
|
||||
function makeBuildOutputActionButtons(output, buildInfo) {
|
||||
function makeBuildOutputActionButtons(output, buildInfo, lines) {
|
||||
/* Generate action buttons for a build output.
|
||||
*/
|
||||
|
||||
var buildId = buildInfo.pk;
|
||||
var outputId = output.pk;
|
||||
|
||||
if (output) {
|
||||
outputId = output.pk;
|
||||
} else {
|
||||
outputId = 'untracked';
|
||||
}
|
||||
|
||||
var panel = `#allocation-panel-${outputId}`;
|
||||
|
||||
@ -50,35 +55,42 @@ function makeBuildOutputActionButtons(output, buildInfo) {
|
||||
|
||||
var html = `<div class='btn-group float-right' role='group'>`;
|
||||
|
||||
// Add a button to "auto allocate" against the build
|
||||
html += makeIconButton(
|
||||
'fa-magic icon-blue', 'button-output-auto', outputId,
|
||||
'{% trans "Auto-allocate stock items to this output" %}',
|
||||
);
|
||||
// "Auto" allocation only works for untracked stock items
|
||||
if (!output && lines > 0) {
|
||||
html += makeIconButton(
|
||||
'fa-magic icon-blue', 'button-output-auto', outputId,
|
||||
'{% trans "Auto-allocate stock items to this output" %}',
|
||||
);
|
||||
}
|
||||
|
||||
// Add a button to "complete" the particular build output
|
||||
html += makeIconButton(
|
||||
'fa-check icon-green', 'button-output-complete', outputId,
|
||||
'{% trans "Complete build output" %}',
|
||||
{
|
||||
//disabled: true
|
||||
}
|
||||
);
|
||||
if (lines > 0) {
|
||||
// Add a button to "cancel" the particular build output (unallocate)
|
||||
html += makeIconButton(
|
||||
'fa-minus-circle icon-red', 'button-output-unallocate', outputId,
|
||||
'{% trans "Unallocate stock from build output" %}',
|
||||
);
|
||||
}
|
||||
|
||||
// Add a button to "cancel" the particular build output (unallocate)
|
||||
html += makeIconButton(
|
||||
'fa-minus-circle icon-red', 'button-output-unallocate', outputId,
|
||||
'{% trans "Unallocate stock from build output" %}',
|
||||
);
|
||||
|
||||
// Add a button to "delete" the particular build output
|
||||
html += makeIconButton(
|
||||
'fa-trash-alt icon-red', 'button-output-delete', outputId,
|
||||
'{% trans "Delete build output" %}',
|
||||
);
|
||||
if (output) {
|
||||
|
||||
// Add a button to "destroy" the particular build output (mark as damaged, scrap)
|
||||
// TODO
|
||||
// Add a button to "complete" the particular build output
|
||||
html += makeIconButton(
|
||||
'fa-check icon-green', 'button-output-complete', outputId,
|
||||
'{% trans "Complete build output" %}',
|
||||
{
|
||||
//disabled: true
|
||||
}
|
||||
);
|
||||
|
||||
// Add a button to "delete" the particular build output
|
||||
html += makeIconButton(
|
||||
'fa-trash-alt icon-red', 'button-output-delete', outputId,
|
||||
'{% trans "Delete build output" %}',
|
||||
);
|
||||
|
||||
// TODO - Add a button to "destroy" the particular build output (mark as damaged, scrap)
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
||||
@ -90,7 +102,6 @@ function makeBuildOutputActionButtons(output, buildInfo) {
|
||||
launchModalForm(`/build/${buildId}/auto-allocate/`,
|
||||
{
|
||||
data: {
|
||||
output: outputId,
|
||||
},
|
||||
success: reloadTable,
|
||||
}
|
||||
@ -98,11 +109,14 @@ function makeBuildOutputActionButtons(output, buildInfo) {
|
||||
});
|
||||
|
||||
$(panel).find(`#button-output-complete-${outputId}`).click(function() {
|
||||
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
launchModalForm(
|
||||
`/build/${buildId}/complete-output/`,
|
||||
{
|
||||
data: {
|
||||
output: outputId,
|
||||
output: pk,
|
||||
},
|
||||
reload: true,
|
||||
}
|
||||
@ -110,24 +124,30 @@ function makeBuildOutputActionButtons(output, buildInfo) {
|
||||
});
|
||||
|
||||
$(panel).find(`#button-output-unallocate-${outputId}`).click(function() {
|
||||
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
launchModalForm(
|
||||
`/build/${buildId}/unallocate/`,
|
||||
{
|
||||
success: reloadTable,
|
||||
data: {
|
||||
output: outputId,
|
||||
output: pk,
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$(panel).find(`#button-output-delete-${outputId}`).click(function() {
|
||||
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
launchModalForm(
|
||||
`/build/${buildId}/delete-output/`,
|
||||
{
|
||||
reload: true,
|
||||
data: {
|
||||
output: outputId
|
||||
output: pk
|
||||
}
|
||||
}
|
||||
);
|
||||
@ -152,13 +172,21 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
|
||||
var outputId = null;
|
||||
|
||||
outputId = output.pk;
|
||||
if (output) {
|
||||
outputId = output.pk;
|
||||
} else {
|
||||
outputId = 'untracked';
|
||||
}
|
||||
|
||||
var table = options.table;
|
||||
|
||||
if (options.table == null) {
|
||||
table = `#allocation-table-${outputId}`;
|
||||
}
|
||||
|
||||
// If an "output" is specified, then only "trackable" parts are allocated
|
||||
// Otherwise, only "untrackable" parts are allowed
|
||||
var trackable = ! !output;
|
||||
|
||||
function reloadTable() {
|
||||
// Reload the entire build allocation table
|
||||
@ -168,7 +196,13 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
function requiredQuantity(row) {
|
||||
// Return the requied quantity for a given row
|
||||
|
||||
return row.quantity * output.quantity;
|
||||
if (output) {
|
||||
// "Tracked" parts are calculated against individual build outputs
|
||||
return row.quantity * output.quantity;
|
||||
} else {
|
||||
// "Untracked" parts are specified against the build itself
|
||||
return row.quantity * buildInfo.quantity;
|
||||
}
|
||||
}
|
||||
|
||||
function sumAllocations(row) {
|
||||
@ -300,6 +334,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
queryParams: {
|
||||
part: partId,
|
||||
sub_part_detail: true,
|
||||
sub_part_trackable: trackable,
|
||||
},
|
||||
formatNoMatches: function() {
|
||||
return '{% trans "No BOM items found" %}';
|
||||
@ -310,11 +345,19 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
onLoadSuccess: function(tableData) {
|
||||
// Once the BOM data are loaded, request allocation data for this build output
|
||||
|
||||
var params = {
|
||||
build: buildId,
|
||||
}
|
||||
|
||||
if (output) {
|
||||
params.sub_part_trackable = true;
|
||||
params.output = outputId;
|
||||
} else {
|
||||
params.sub_part_trackable = false;
|
||||
}
|
||||
|
||||
inventreeGet('/api/build/item/',
|
||||
{
|
||||
build: buildId,
|
||||
output: outputId,
|
||||
},
|
||||
params,
|
||||
{
|
||||
success: function(data) {
|
||||
// Iterate through the returned data, and group by the part they point to
|
||||
@ -355,8 +398,16 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
// Calculate the total allocated quantity
|
||||
var allocatedQuantity = sumAllocations(tableRow);
|
||||
|
||||
var requiredQuantity = 0;
|
||||
|
||||
if (output) {
|
||||
requiredQuantity = tableRow.quantity * output.quantity;
|
||||
} else {
|
||||
requiredQuantity = tableRow.quantity * buildInfo.quantity;
|
||||
}
|
||||
|
||||
// Is this line item fully allocated?
|
||||
if (allocatedQuantity >= (tableRow.quantity * output.quantity)) {
|
||||
if (allocatedQuantity >= requiredQuantity) {
|
||||
allocatedLines += 1;
|
||||
}
|
||||
|
||||
@ -367,16 +418,21 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
// Update the total progress for this build output
|
||||
var buildProgress = $(`#allocation-panel-${outputId}`).find($(`#output-progress-${outputId}`));
|
||||
|
||||
var progress = makeProgressBar(
|
||||
allocatedLines,
|
||||
totalLines
|
||||
);
|
||||
if (totalLines > 0) {
|
||||
|
||||
buildProgress.html(progress);
|
||||
var progress = makeProgressBar(
|
||||
allocatedLines,
|
||||
totalLines
|
||||
);
|
||||
|
||||
buildProgress.html(progress);
|
||||
} else {
|
||||
buildProgress.html('');
|
||||
}
|
||||
|
||||
// Update the available actions for this build output
|
||||
|
||||
makeBuildOutputActionButtons(output, buildInfo);
|
||||
makeBuildOutputActionButtons(output, buildInfo, totalLines);
|
||||
}
|
||||
}
|
||||
);
|
||||
@ -600,6 +656,9 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
},
|
||||
]
|
||||
});
|
||||
|
||||
// Initialize the action buttons
|
||||
makeBuildOutputActionButtons(output, buildInfo, 0);
|
||||
}
|
||||
|
||||
|
||||
@ -654,7 +713,7 @@ function loadBuildTable(table, options) {
|
||||
field: 'reference',
|
||||
title: '{% trans "Build" %}',
|
||||
sortable: true,
|
||||
switchable: false,
|
||||
switchable: true,
|
||||
formatter: function(value, row, index, field) {
|
||||
|
||||
var prefix = "{% settings_value 'BUILDORDER_REFERENCE_PREFIX' %}";
|
||||
@ -675,6 +734,7 @@ function loadBuildTable(table, options) {
|
||||
{
|
||||
field: 'title',
|
||||
title: '{% trans "Description" %}',
|
||||
switchable: true,
|
||||
},
|
||||
{
|
||||
field: 'part',
|
||||
@ -725,7 +785,7 @@ function loadBuildTable(table, options) {
|
||||
},
|
||||
{
|
||||
field: 'completion_date',
|
||||
title: '{% trans "Completed" %}',
|
||||
title: '{% trans "Completion Date" %}',
|
||||
sortable: true,
|
||||
},
|
||||
],
|
||||
|
@ -1,4 +1,5 @@
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
/* Part API functions
|
||||
* Requires api.js to be loaded first
|
||||
@ -506,6 +507,82 @@ function loadPartTable(table, url, options={}) {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function loadPartCategoryTable(table, options) {
|
||||
/* Display a table of part categories */
|
||||
|
||||
var params = options.params || {};
|
||||
|
||||
var filterListElement = options.filterList || '#filter-list-category';
|
||||
|
||||
var filters = {};
|
||||
|
||||
var filterKey = options.filterKey || options.name || 'category';
|
||||
|
||||
if (!options.disableFilters) {
|
||||
filters = loadTableFilters(filterKey);
|
||||
}
|
||||
|
||||
var original = {};
|
||||
|
||||
for (var key in params) {
|
||||
original[key] = params[key];
|
||||
filters[key] = params[key];
|
||||
}
|
||||
|
||||
setupFilterList(filterKey, table, filterListElement);
|
||||
|
||||
table.inventreeTable({
|
||||
method: 'get',
|
||||
url: options.url || '{% url "api-part-category-list" %}',
|
||||
queryParams: filters,
|
||||
sidePagination: 'server',
|
||||
name: 'category',
|
||||
original: original,
|
||||
showColumns: true,
|
||||
columns: [
|
||||
{
|
||||
checkbox: true,
|
||||
title: '{% trans "Select" %}',
|
||||
searchable: false,
|
||||
switchable: false,
|
||||
visible: false,
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
title: '{% trans "Name" %}',
|
||||
switchable: true,
|
||||
sortable: true,
|
||||
formatter: function(value, row) {
|
||||
return renderLink(
|
||||
value,
|
||||
`/part/category/${row.pk}/`
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
title: '{% trans "Description" %}',
|
||||
switchable: true,
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
field: 'pathstring',
|
||||
title: '{% trans "Path" %}',
|
||||
switchable: true,
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
field: 'parts',
|
||||
title: '{% trans "Parts" %}',
|
||||
switchable: true,
|
||||
sortable: false,
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function yesNoLabel(value) {
|
||||
if (value) {
|
||||
return `<span class='label label-green'>{% trans "YES" %}</span>`;
|
||||
|
@ -897,6 +897,83 @@ function loadStockTable(table, options) {
|
||||
});
|
||||
}
|
||||
|
||||
function loadStockLocationTable(table, options) {
|
||||
/* Display a table of stock locations */
|
||||
|
||||
var params = options.params || {};
|
||||
|
||||
var filterListElement = options.filterList || '#filter-list-location';
|
||||
|
||||
var filters = {};
|
||||
|
||||
var filterKey = options.filterKey || options.name || 'location';
|
||||
|
||||
if (!options.disableFilters) {
|
||||
filters = loadTableFilters(filterKey);
|
||||
}
|
||||
|
||||
var original = {};
|
||||
|
||||
for (var key in params) {
|
||||
original[key] = params[key];
|
||||
}
|
||||
|
||||
setupFilterList(filterKey, table, filterListElement);
|
||||
|
||||
for (var key in params) {
|
||||
filters[key] = params[key];
|
||||
}
|
||||
|
||||
table.inventreeTable({
|
||||
method: 'get',
|
||||
url: options.url || '{% url "api-location-list" %}',
|
||||
queryParams: filters,
|
||||
sidePagination: 'server',
|
||||
name: 'location',
|
||||
original: original,
|
||||
showColumns: true,
|
||||
columns: [
|
||||
{
|
||||
checkbox: true,
|
||||
title: '{% trans "Select" %}',
|
||||
searchable: false,
|
||||
switchable: false,
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
title: '{% trans "Name" %}',
|
||||
switchable: true,
|
||||
sortable: true,
|
||||
formatter: function(value, row) {
|
||||
return renderLink(
|
||||
value,
|
||||
`/stock/location/${row.pk}/`
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
title: '{% trans "Description" %}',
|
||||
switchable: true,
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
field: 'pathstring',
|
||||
title: '{% trans "Path" %}',
|
||||
switchable: true,
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
field: 'items',
|
||||
title: '{% trans "Stock Items" %}',
|
||||
switchable: true,
|
||||
sortable: false,
|
||||
sortName: 'item_count',
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
function loadStockTrackingTable(table, options) {
|
||||
|
||||
var cols = [
|
||||
|
@ -62,6 +62,28 @@ function getAvailableTableFilters(tableKey) {
|
||||
};
|
||||
}
|
||||
|
||||
// Filters for "stock location" table
|
||||
if (tableKey == "location") {
|
||||
return {
|
||||
cascade: {
|
||||
type: 'bool',
|
||||
title: '{% trans "Include sublocations" %}',
|
||||
description: '{% trans "Include locations" %}',
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Filters for "part category" table
|
||||
if (tableKey == "category") {
|
||||
return {
|
||||
cascade: {
|
||||
type: 'bool',
|
||||
title: '{% trans "Include subcategories" %}',
|
||||
description: '{% trans "Include subcategories" %}',
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Filters for the "customer stock" table (really a subset of "stock")
|
||||
if (tableKey == "customerstock") {
|
||||
return {
|
||||
|
@ -32,6 +32,7 @@
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- Printing actions menu -->
|
||||
<div class='btn-group'>
|
||||
<button id='stock-print-options' class='btn btn-primary dropdown-toggle' type='button' data-toggle="dropdown" title='{% trans "Printing Actions" %}'>
|
||||
<span class='fas fa-print'></span> <span class='caret'></span>
|
||||
|
@ -9,8 +9,12 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% block header_panel %}
|
||||
<div class='panel panel-default panel-inventree'>
|
||||
|
||||
{% block header_pre_content %}
|
||||
{% endblock %}
|
||||
|
||||
<div class='row'>
|
||||
<div class='col-sm-6'>
|
||||
<div class='media-left'>
|
||||
@ -30,8 +34,14 @@
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block header_post_content %}
|
||||
{% endblock %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content_panels %}
|
||||
<div class='panel panel-default panel-inventree'>
|
||||
<div class='panel-heading'>
|
||||
<h4>
|
||||
@ -41,12 +51,15 @@
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{% block details_panel %}
|
||||
<div class='panel-content'>
|
||||
{% block details %}
|
||||
<!-- Particular page detail views go here -->
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
@ -57,6 +57,7 @@ class RuleSet(models.Model):
|
||||
'auth_user',
|
||||
'auth_permission',
|
||||
'authtoken_token',
|
||||
'authtoken_tokenproxy',
|
||||
'users_ruleset',
|
||||
],
|
||||
'part_category': [
|
||||
@ -199,7 +200,8 @@ class RuleSet(models.Model):
|
||||
if check_user_role(user, role, permission):
|
||||
return True
|
||||
|
||||
print("failed permission check for", table, permission)
|
||||
# Print message instead of throwing an error
|
||||
print("Failed permission check for", table, permission)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
|
@ -1,11 +1,11 @@
|
||||
invoke>=1.4.0 # Invoke build tool
|
||||
wheel>=0.34.2 # Wheel
|
||||
Django==3.0.7 # Django package
|
||||
Django==3.2 # Django package
|
||||
pillow==8.1.1 # Image manipulation
|
||||
djangorestframework==3.11.2 # DRF framework
|
||||
djangorestframework==3.12.4 # DRF framework
|
||||
django-dbbackup==3.3.0 # Database backup / restore functionality
|
||||
django-cors-headers==3.2.0 # CORS headers extension for DRF
|
||||
django_filter==2.2.0 # Extended filtering options
|
||||
django-filter==2.4.0 # Extended filtering options
|
||||
django-mptt==0.11.0 # Modified Preorder Tree Traversal
|
||||
django-sql-utils==0.5.0 # Advanced query annotation / aggregation
|
||||
django-markdownx==3.0.1 # Markdown form fields
|
||||
@ -13,7 +13,7 @@ django-markdownify==0.8.0 # Markdown rendering
|
||||
coreapi==2.3.0 # API documentation
|
||||
pygments==2.7.4 # Syntax highlighting
|
||||
tablib==0.13.0 # Import / export data files
|
||||
django-crispy-forms==1.8.1 # Form helpers
|
||||
django-crispy-forms==1.11.2 # Form helpers
|
||||
django-import-export==2.0.0 # Data import / export for admin interface
|
||||
django-cleanup==5.1.0 # Manage deletion of old / unused uploaded files
|
||||
flake8==3.8.3 # PEP checking
|
||||
|
4
tasks.py
4
tasks.py
@ -174,7 +174,7 @@ def update(c):
|
||||
"""
|
||||
pass
|
||||
|
||||
@task
|
||||
@task(post=[static])
|
||||
def translate(c):
|
||||
"""
|
||||
Regenerate translation files.
|
||||
@ -184,7 +184,7 @@ def translate(c):
|
||||
"""
|
||||
|
||||
# Translate applicable .py / .html / .js files
|
||||
manage(c, "makemessages -e py -e html -e js")
|
||||
manage(c, "makemessages --all -e py,html,js")
|
||||
manage(c, "compilemessages")
|
||||
|
||||
path = os.path.join('InvenTree', 'script', 'translation_stats.py')
|
||||
|
Loading…
Reference in New Issue
Block a user