Build Order Updates (#4855)

* Add new BuildLine model

- Represents an instance of a BOM item against a BuildOrder

* Create BuildLine instances automatically

When a new Build is created, automatically generate new BuildLine items

* Improve logic for handling exchange rate backends

* logic fixes

* Adds API endpoints

Add list and detail API endpoints for new BuildLine model

* update users/models.py

- Add new model to roles definition

* bulk-create on auto_allocate

Save database hits by performing a bulk-create

* Add skeleton data migration

* Create BuildLines for existing orders

* Working on building out BuildLine table

* Adds link for "BuildLine" to "BuildItem"

- A "BuildItem" will now be tracked against a BuildLine
- Not tracked directly against a build
- Not tracked directly against a BomItem
- Add schema migration
- Add data migration to update links

* Adjust migration 0045

- bom_item and build fields are about to be removed
- Set them to "nullable" so the data doesn't get removed

* Remove old fields from BuildItem model

- build fk
- bom_item fk
- A lot of other required changes too

* Update BuildLine.bom_item field

- Delete the BuildLine if the BomItem is removed
- This is closer to current behaviour

* Cleanup for Build model

- tracked_bom_items -> tracked_line_items
- untracked_bom_items -> tracked_bom_items
- remove build.can_complete
- move bom_item specific methods to the BuildLine model
- Cleanup / consolidation

* front-end work

- Update javascript
- Cleanup HTML templates

* Add serializer annotation and filtering

- Annotate 'allocated' quantity
- Filter by allocated / trackable / optional / consumable

* Make table sortable

* Add buttons

* Add callback for building new stock

* Fix Part annotation

* Adds callback to order parts

* Allocation works again

* template cleanup

* Fix allocate / unallocate actions

- Also turns out "unallocate" is not a word..

* auto-allocate works again

* Fix call to build.is_over_allocated

* Refactoring updates

* Bump API version

* Cleaner implementation of allocation sub-table

* Fix rendering in build output table

* Improvements to StockItem list API

- Refactor very old code
- Add option to include test results to queryset

* Add TODO for later me

* Fix for serializers.py

* Working on cleaner implementation of build output table

* Add function to determine if a single output is fully allocated

* Updates to build.js

- Button callbacks
- Table rendering

* Revert previous changes to build.serializers.py

* Fix for forms.js

* Rearrange code in build.js

* Rebuild "allocated lines" for output table

* Fix allocation calculation

* Show or hide column for tracked parts

* Improve debug messages

* Refactor "loadBuildLineTable"

- Allow it to also be used as output sub-table

* Refactor "completed tests" column

* Remove old javascript

- Cleans up a *lot* of crusty old code

* Annotate the available stock quantity to BuildLine serializer

- Similar pattern to BomItem serializer
- Needs refactoring in the future

* Update available column

* Fix build allocation table

- Bug fix
- Make pretty

* linting fixes

* Allow sorting by available stock

* Tweak for "required tests" column

* Bug fix for completing a build output

* Fix for consumable stock

* Fix for trim_allocated_stock

* Fix for creating new build

* Migration fix

- Ensure initial django_q migrations are applied
- Why on earth is this failing now?

* Catch exception

* Update for exception handling

* Update migrations

- Ensure inventreesetting is added

* Catch all exceptions when getting default currency code

* Bug fix for currency exchange rates update

* Working on unit tests

* Unit test fixes

* More work on unit tests

* Use bulk_create in unit test

* Update required quantity when a BuildOrder is saved

* Tweak overage display in BOM table

* Fix icon in BOM table

* Fix spelling error

* More unit test fixes

* Build reports

- Add line_items
- Update docs
- Cleanup

* Reimplement is_partially_allocated method

* Update docs about overage

* Unit testing for data migration

* Add "required_for_build_orders" annotation

- Makes API query *much* faster now
- remove old "required_parts_to_complete_build" method
- Cleanup part API filter code

* Adjust order of fixture loading

* Fix unit test

* Prevent "schedule_pricing_update" in unit tests

- Should cut down on DB hits significantly

* Unit test updates

* Improvements for unit test

- Don't hard-code pk values
- postgresql no likey

* Better unit test
This commit is contained in:
Oliver 2023-06-13 20:18:32 +10:00 committed by GitHub
parent 98bddd32d0
commit 6ba777d363
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 2193 additions and 1903 deletions

View File

@ -2,11 +2,14 @@
# InvenTree API version
INVENTREE_API_VERSION = 119
INVENTREE_API_VERSION = 120
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v120 -> 2023-06-07 : https://github.com/inventree/InvenTree/pull/4855
- Major overhaul of the build order API
- Adds new BuildLine model
v119 -> 2023-06-01 : https://github.com/inventree/InvenTree/pull/4898
- Add Metadata to: Part test templates, Part parameters, Part category parameter templates, BOM item substitute, Part relateds, Stock item test result

View File

@ -126,19 +126,22 @@ class InvenTreeConfig(AppConfig):
update = False
try:
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
backend = ExchangeBackend.objects.filter(name='InvenTreeExchange')
last_update = backend.last_update
if backend.exists():
backend = backend.first()
if last_update is None:
# Never been updated
logger.info("Exchange backend has never been updated")
update = True
last_update = backend.last_update
# Backend currency has changed?
if base_currency != backend.base_currency:
logger.info(f"Base currency changed from {backend.base_currency} to {base_currency}")
update = True
if last_update is None:
# Never been updated
logger.info("Exchange backend has never been updated")
update = True
# Backend currency has changed?
if base_currency != backend.base_currency:
logger.info(f"Base currency changed from {backend.base_currency} to {base_currency}")
update = True
except (ExchangeBackend.DoesNotExist):
logger.info("Exchange backend not found - updating")

View File

@ -223,8 +223,7 @@ main {
}
.sub-table {
margin-left: 45px;
margin-right: 45px;
margin-left: 60px;
}
.detail-icon .glyphicon {

View File

@ -497,7 +497,7 @@ def check_for_updates():
def update_exchange_rates():
"""Update currency exchange rates."""
try:
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from djmoney.contrib.exchange.models import Rate
from common.settings import currency_code_default, currency_codes
from InvenTree.exchange import InvenTreeExchange
@ -509,22 +509,9 @@ def update_exchange_rates():
# Other error?
return
# Test to see if the database is ready yet
try:
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
except ExchangeBackend.DoesNotExist:
pass
except Exception: # pragma: no cover
# Some other error
logger.warning("update_exchange_rates: Database not ready")
return
backend = InvenTreeExchange()
logger.info(f"Updating exchange rates from {backend.url}")
base = currency_code_default()
logger.info(f"Using base currency '{base}'")
logger.info(f"Updating exchange rates using base currency '{base}'")
try:
backend.update_rates(base_currency=base)

View File

@ -14,18 +14,18 @@ class URLTest(TestCase):
# Need fixture data in the database
fixtures = [
'settings',
'build',
'company',
'manufacturer_part',
'price_breaks',
'supplier_part',
'order',
'sales_order',
'bom',
'category',
'params',
'part_pricebreaks',
'part',
'bom',
'build',
'test_templates',
'location',
'stock_tests',

View File

@ -525,8 +525,10 @@ class SettingsView(TemplateView):
# When were the rates last updated?
try:
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
ctx["rates_updated"] = backend.last_update
backend = ExchangeBackend.objects.filter(name='InvenTreeExchange')
if backend.exists():
backend = backend.first()
ctx["rates_updated"] = backend.last_update
except Exception:
ctx["rates_updated"] = None

View File

@ -6,7 +6,7 @@ from import_export.admin import ImportExportModelAdmin
from import_export.fields import Field
from import_export import widgets
from build.models import Build, BuildItem
from build.models import Build, BuildLine, BuildItem
from InvenTree.admin import InvenTreeResource
import part.models
@ -87,18 +87,33 @@ class BuildItemAdmin(admin.ModelAdmin):
"""Class for managing the BuildItem model via the admin interface"""
list_display = (
'build',
'stock_item',
'quantity'
)
autocomplete_fields = [
'build',
'bom_item',
'build_line',
'stock_item',
'install_into',
]
class BuildLineAdmin(admin.ModelAdmin):
"""Class for managing the BuildLine model via the admin interface"""
list_display = (
'build',
'bom_item',
'quantity',
)
search_fields = [
'build__title',
'build__reference',
'bom_item__sub_part__name',
]
admin.site.register(Build, BuildAdmin)
admin.site.register(BuildItem, BuildItemAdmin)
admin.site.register(BuildLine, BuildLineAdmin)

View File

@ -1,5 +1,6 @@
"""JSON API for the Build app."""
from django.db.models import F
from django.urls import include, path, re_path
from django.utils.translation import gettext_lazy as _
from django.contrib.auth.models import User
@ -17,7 +18,7 @@ from InvenTree.mixins import CreateAPI, RetrieveUpdateDestroyAPI, ListCreateAPI
import build.admin
import build.serializers
from build.models import Build, BuildItem, BuildOrderAttachment
from build.models import Build, BuildLine, BuildItem, BuildOrderAttachment
import part.models
from users.models import Owner
from InvenTree.filters import SEARCH_ORDER_FILTER_ALIAS
@ -251,6 +252,88 @@ class BuildUnallocate(CreateAPI):
return ctx
class BuildLineFilter(rest_filters.FilterSet):
"""Custom filterset for the BuildLine API endpoint."""
class Meta:
"""Meta information for the BuildLineFilter class."""
model = BuildLine
fields = [
'build',
'bom_item',
]
# Fields on related models
consumable = rest_filters.BooleanFilter(label=_('Consumable'), field_name='bom_item__consumable')
optional = rest_filters.BooleanFilter(label=_('Optional'), field_name='bom_item__optional')
tracked = rest_filters.BooleanFilter(label=_('Tracked'), field_name='bom_item__sub_part__trackable')
allocated = rest_filters.BooleanFilter(label=_('Allocated'), method='filter_allocated')
def filter_allocated(self, queryset, name, value):
"""Filter by whether each BuildLine is fully allocated"""
if str2bool(value):
return queryset.filter(allocated__gte=F('quantity'))
else:
return queryset.filter(allocated__lt=F('quantity'))
class BuildLineEndpoint:
"""Mixin class for BuildLine API endpoints."""
queryset = BuildLine.objects.all()
serializer_class = build.serializers.BuildLineSerializer
def get_queryset(self):
"""Override queryset to select-related and annotate"""
queryset = super().get_queryset()
queryset = queryset.select_related(
'build', 'bom_item',
)
queryset = build.serializers.BuildLineSerializer.annotate_queryset(queryset)
return queryset
class BuildLineList(BuildLineEndpoint, ListCreateAPI):
"""API endpoint for accessing a list of BuildLine objects"""
filterset_class = BuildLineFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS
ordering_fields = [
'part',
'allocated',
'reference',
'quantity',
'consumable',
'optional',
'unit_quantity',
'available_stock',
]
ordering_field_aliases = {
'part': 'bom_item__sub_part__name',
'reference': 'bom_item__reference',
'unit_quantity': 'bom_item__quantity',
'consumable': 'bom_item__consumable',
'optional': 'bom_item__optional',
}
search_fields = [
'bom_item__sub_part__name',
'bom_item__reference',
]
class BuildLineDetail(BuildLineEndpoint, RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a BuildLine object."""
pass
class BuildOrderContextMixin:
"""Mixin class which adds build order as serializer context variable."""
@ -373,9 +456,8 @@ class BuildItemFilter(rest_filters.FilterSet):
"""Metaclass option"""
model = BuildItem
fields = [
'build',
'build_line',
'stock_item',
'bom_item',
'install_into',
]
@ -384,6 +466,11 @@ class BuildItemFilter(rest_filters.FilterSet):
field_name='stock_item__part',
)
build = rest_filters.ModelChoiceFilter(
queryset=build.models.Build.objects.all(),
field_name='build_line__build',
)
tracked = rest_filters.BooleanFilter(label='Tracked', method='filter_tracked')
def filter_tracked(self, queryset, name, value):
@ -409,10 +496,9 @@ class BuildItemList(ListCreateAPI):
try:
params = self.request.query_params
kwargs['part_detail'] = str2bool(params.get('part_detail', False))
kwargs['build_detail'] = str2bool(params.get('build_detail', False))
kwargs['location_detail'] = str2bool(params.get('location_detail', False))
kwargs['stock_detail'] = str2bool(params.get('stock_detail', True))
for key in ['part_detail', 'location_detail', 'stock_detail', 'build_detail']:
if key in params:
kwargs[key] = str2bool(params.get(key, False))
except AttributeError:
pass
@ -423,9 +509,8 @@ class BuildItemList(ListCreateAPI):
queryset = BuildItem.objects.all()
queryset = queryset.select_related(
'bom_item',
'bom_item__sub_part',
'build',
'build_line',
'build_line__build',
'install_into',
'stock_item',
'stock_item__location',
@ -435,7 +520,7 @@ class BuildItemList(ListCreateAPI):
return queryset
def filter_queryset(self, queryset):
"""Customm query filtering for the BuildItem list."""
"""Custom query filtering for the BuildItem list."""
queryset = super().filter_queryset(queryset)
params = self.request.query_params
@ -487,6 +572,12 @@ build_api_urls = [
re_path(r'^.*$', BuildAttachmentList.as_view(), name='api-build-attachment-list'),
])),
# Build lines
re_path(r'^line/', include([
path(r'<int:pk>/', BuildLineDetail.as_view(), name='api-build-line-detail'),
re_path(r'^.*$', BuildLineList.as_view(), name='api-build-line-list'),
])),
# Build Items
re_path(r'^item/', include([
path(r'<int:pk>/', include([

View File

@ -0,0 +1,28 @@
# Generated by Django 3.2.19 on 2023-05-19 06:04
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('part', '0109_auto_20230517_1048'),
('build', '0042_alter_build_notes'),
]
operations = [
migrations.CreateModel(
name='BuildLine',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.DecimalField(decimal_places=5, default=1, help_text='Required quantity for build order', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Quantity')),
('bom_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='build_lines', to='part.bomitem')),
('build', models.ForeignKey(help_text='Build object', on_delete=django.db.models.deletion.CASCADE, related_name='build_lines', to='build.build')),
],
options={
'unique_together': {('build', 'bom_item')},
},
),
]

View File

@ -0,0 +1,97 @@
# Generated by Django 3.2.19 on 2023-05-28 14:10
from django.db import migrations
def get_bom_items_for_part(part, Part, BomItem):
""" Return a list of all BOM items for a given part.
Note that we cannot use the ORM here (as we are inside a data migration),
so we *copy* the logic from the Part class.
This is a snapshot of the Part.get_bom_items() method as of 2023-05-29
"""
bom_items = set()
# Get all BOM items which directly reference the part
for bom_item in BomItem.objects.filter(part=part):
bom_items.add(bom_item)
# Get all BOM items which are inherited by the part
parents = Part.objects.filter(
tree_id=part.tree_id,
level__lt=part.level,
lft__lt=part.lft,
rght__gt=part.rght
)
for bom_item in BomItem.objects.filter(part__in=parents, inherited=True):
bom_items.add(bom_item)
return list(bom_items)
def add_lines_to_builds(apps, schema_editor):
"""Create BuildOrderLine objects for existing build orders"""
# Get database models
Build = apps.get_model("build", "Build")
BuildLine = apps.get_model("build", "BuildLine")
Part = apps.get_model("part", "Part")
BomItem = apps.get_model("part", "BomItem")
build_lines = []
builds = Build.objects.all()
if builds.count() > 0:
print(f"Creating BuildOrderLine objects for {builds.count()} existing builds")
for build in builds:
# Create a BuildOrderLine for each BuildItem
bom_items = get_bom_items_for_part(build.part, Part, BomItem)
for item in bom_items:
build_lines.append(
BuildLine(
build=build,
bom_item=item,
quantity=item.quantity * build.quantity,
)
)
if len(build_lines) > 0:
# Construct the new BuildLine objects
BuildLine.objects.bulk_create(build_lines)
print(f"Created {len(build_lines)} BuildOrderLine objects for existing builds")
def remove_build_lines(apps, schema_editor):
"""Remove BuildOrderLine objects from the database"""
# Get database models
BuildLine = apps.get_model("build", "BuildLine")
n = BuildLine.objects.all().count()
BuildLine.objects.all().delete()
if n > 0:
print(f"Removed {n} BuildOrderLine objects")
class Migration(migrations.Migration):
dependencies = [
('build', '0043_buildline'),
]
operations = [
migrations.RunPython(
add_lines_to_builds,
reverse_code=remove_build_lines,
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 3.2.19 on 2023-06-06 10:30
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('build', '0044_auto_20230528_1410'),
]
operations = [
migrations.AddField(
model_name='builditem',
name='build_line',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='allocations', to='build.buildline'),
),
]

View File

@ -0,0 +1,95 @@
# Generated by Django 3.2.19 on 2023-06-06 10:33
import logging
from django.db import migrations
logger = logging.getLogger('inventree')
def add_build_line_links(apps, schema_editor):
"""Data migration to add links between BuildLine and BuildItem objects.
Associated model types:
Build: A "Build Order"
BomItem: An individual line in the BOM for Build.part
BuildItem: An individual stock allocation against the Build Order
BuildLine: (new model) an individual line in the Build Order
Goals:
- Find all BuildItem objects which are associated with a Build
- Link them against the relevant BuildLine object
- The BuildLine objects should have been created in 0044_auto_20230528_1410.py
"""
BuildItem = apps.get_model("build", "BuildItem")
BuildLine = apps.get_model("build", "BuildLine")
# Find any existing BuildItem objects
build_items = BuildItem.objects.all()
n_missing = 0
for item in build_items:
# Find the relevant BuildLine object
line = BuildLine.objects.filter(
build=item.build,
bom_item=item.bom_item
).first()
if line is None:
logger.warning(f"BuildLine does not exist for BuildItem {item.pk}")
n_missing += 1
if item.build is None or item.bom_item is None:
continue
# Create one!
line = BuildLine.objects.create(
build=item.build,
bom_item=item.bom_item,
quantity=item.bom_item.quantity * item.build.quantity
)
# Link the BuildItem to the BuildLine
# In the next data migration, we remove the 'build' and 'bom_item' fields from BuildItem
item.build_line = line
item.save()
if build_items.count() > 0:
logger.info(f"add_build_line_links: Updated {build_items.count()} BuildItem objects (added {n_missing})")
def reverse_build_links(apps, schema_editor):
"""Reverse data migration from add_build_line_links
Basically, iterate through each BuildItem and update the links based on the BuildLine
"""
BuildItem = apps.get_model("build", "BuildItem")
items = BuildItem.objects.all()
for item in items:
item.build = item.build_line.build
item.bom_item = item.build_line.bom_item
item.save()
if items.count() > 0:
logger.info(f"reverse_build_links: Updated {items.count()} BuildItem objects")
class Migration(migrations.Migration):
dependencies = [
('build', '0045_builditem_build_line'),
]
operations = [
migrations.RunPython(
add_build_line_links,
reverse_code=reverse_build_links,
)
]

View File

@ -0,0 +1,26 @@
# Generated by Django 3.2.19 on 2023-06-06 10:58
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('stock', '0101_stockitemtestresult_metadata'),
('build', '0046_auto_20230606_1033'),
]
operations = [
migrations.AlterUniqueTogether(
name='builditem',
unique_together={('build_line', 'stock_item', 'install_into')},
),
migrations.RemoveField(
model_name='builditem',
name='bom_item',
),
migrations.RemoveField(
model_name='builditem',
name='build',
),
]

View File

@ -1,7 +1,7 @@
"""Build database model definitions."""
import decimal
import logging
import os
from datetime import datetime
@ -40,6 +40,9 @@ import stock.models
import users.models
logger = logging.getLogger('inventree')
class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, InvenTree.models.MetadataMixin, InvenTree.models.ReferenceIndexingMixin):
"""A Build object organises the creation of new StockItem objects from other existing StockItem objects.
@ -334,33 +337,24 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
return self.status in BuildStatusGroups.ACTIVE_CODES
@property
def bom_items(self):
"""Returns the BOM items for the part referenced by this BuildOrder."""
return self.part.get_bom_items()
def tracked_line_items(self):
"""Returns the "trackable" BOM lines for this BuildOrder."""
@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 self.build_lines.filter(bom_item__sub_part__trackable=True)
return items
def has_tracked_bom_items(self):
def has_tracked_line_items(self):
"""Returns True if this BuildOrder has trackable BomItems."""
return self.tracked_bom_items.count() > 0
return self.tracked_line_items.count() > 0
@property
def untracked_bom_items(self):
def untracked_line_items(self):
"""Returns the "non trackable" BOM items for this BuildOrder."""
items = self.bom_items
items = items.filter(sub_part__trackable=False)
return items
return self.build_lines.filter(bom_item__sub_part__trackable=False)
def has_untracked_bom_items(self):
def has_untracked_line_items(self):
"""Returns True if this BuildOrder has non trackable BomItems."""
return self.untracked_bom_items.count() > 0
return self.has_untracked_line_items.count() > 0
@property
def remaining(self):
@ -422,6 +416,11 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
return quantity
def is_partially_allocated(self):
"""Test is this build order has any stock allocated against it"""
return self.allocated_stock.count() > 0
@property
def incomplete_outputs(self):
"""Return all the "incomplete" build outputs."""
@ -478,21 +477,22 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
@property
def can_complete(self):
"""Returns True if this build can be "completed".
"""Returns True if this BuildOrder is ready to be completed
- Must not have any outstanding build outputs
- 'completed' value must meet (or exceed) the 'quantity' value
- Completed count must meet the required quantity
- Untracked parts must be allocated
"""
if self.incomplete_count > 0:
return False
if self.remaining > 0:
return False
if not self.are_untracked_parts_allocated():
if not self.is_fully_allocated(tracked=False):
return False
# No issues!
return True
@transaction.atomic
@ -511,7 +511,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
# Ensure that there are no longer any BuildItem objects
# which point to this Build Order
self.allocated_stock.all().delete()
self.allocated_stock.delete()
# Register an event
trigger_event('build.completed', id=self.pk)
@ -566,13 +566,14 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
remove_allocated_stock = kwargs.get('remove_allocated_stock', False)
remove_incomplete_outputs = kwargs.get('remove_incomplete_outputs', False)
# Handle stock allocations
for build_item in self.allocated_stock.all():
# Find all BuildItem objects associated with this Build
items = self.allocated_stock
if remove_allocated_stock:
build_item.complete_allocation(user)
if remove_allocated_stock:
for item in items:
item.complete_allocation(user)
build_item.delete()
items.delete()
# Remove incomplete outputs (if required)
if remove_incomplete_outputs:
@ -591,20 +592,19 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
trigger_event('build.cancelled', id=self.pk)
@transaction.atomic
def unallocateStock(self, bom_item=None, output=None):
"""Unallocate stock from this Build.
def deallocate_stock(self, build_line=None, output=None):
"""Deallocate stock from this Build.
Args:
bom_item: Specify a particular BomItem to unallocate stock against
output: Specify a particular StockItem (output) to unallocate stock against
build_line: Specify a particular BuildLine instance to un-allocate stock against
output: Specify a particular StockItem (output) to un-allocate stock against
"""
allocations = BuildItem.objects.filter(
build=self,
allocations = self.allocated_stock.filter(
install_into=output
)
if bom_item:
allocations = allocations.filter(bom_item=bom_item)
if build_line:
allocations = allocations.filter(build_line=build_line)
allocations.delete()
@ -737,7 +737,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
"""Remove a build output from the database.
Executes:
- Unallocate any build items against the output
- Deallocate any build items against the output
- Delete the output StockItem
"""
if not output:
@ -749,8 +749,8 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
if output.build != self:
raise ValidationError(_("Build output does not match Build Order"))
# Unallocate all build items against the output
self.unallocateStock(output=output)
# Deallocate all build items against the output
self.deallocate_stock(output=output)
# Remove the build output from the database
output.delete()
@ -758,36 +758,47 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
@transaction.atomic
def trim_allocated_stock(self):
"""Called after save to reduce allocated stock if the build order is now overallocated."""
allocations = BuildItem.objects.filter(build=self)
# Only need to worry about untracked stock here
for bom_item in self.untracked_bom_items:
reduce_by = self.allocated_quantity(bom_item) - self.required_quantity(bom_item)
if reduce_by <= 0:
continue # all OK
for build_line in self.untracked_line_items:
reduce_by = build_line.allocated_quantity() - build_line.quantity
if reduce_by <= 0:
continue
# Find BuildItem objects to trim
for item in BuildItem.objects.filter(build_line=build_line):
# find builditem(s) to trim
for a in allocations.filter(bom_item=bom_item):
# Previous item completed the job
if reduce_by == 0:
if reduce_by <= 0:
break
# Easy case - this item can just be reduced.
if a.quantity > reduce_by:
a.quantity -= reduce_by
a.save()
if item.quantity > reduce_by:
item.quantity -= reduce_by
item.save()
break
# Harder case, this item needs to be deleted, and any remainder
# taken from the next items in the list.
reduce_by -= a.quantity
a.delete()
reduce_by -= item.quantity
item.delete()
@property
def allocated_stock(self):
"""Returns a QuerySet object of all BuildItem objects which point back to this Build"""
return BuildItem.objects.filter(
build_line__build=self
)
@transaction.atomic
def subtract_allocated_stock(self, user):
"""Called when the Build is marked as "complete", this function removes the allocated untracked items from stock."""
# Find all BuildItem objects which point to this build
items = self.allocated_stock.filter(
stock_item__part__trackable=False
build_line__bom_item__sub_part__trackable=False
)
# Remove stock
@ -934,8 +945,13 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
else:
return 3
# Get a list of all 'untracked' BOM items
for bom_item in self.untracked_bom_items:
new_items = []
# Auto-allocation is only possible for "untracked" line items
for line_item in self.untracked_line_items.all():
# Find the referenced BomItem
bom_item = line_item.bom_item
if bom_item.consumable:
# Do not auto-allocate stock to consumable BOM items
@ -947,7 +963,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
variant_parts = bom_item.sub_part.get_descendants(include_self=False)
unallocated_quantity = self.unallocated_quantity(bom_item)
unallocated_quantity = line_item.unallocated_quantity()
if unallocated_quantity <= 0:
# This BomItem is fully allocated, we can continue
@ -998,18 +1014,22 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
# or all items are "interchangeable" and we don't care where we take stock from
for stock_item in available_stock:
# Skip inactive parts
if not stock_item.part.active:
continue
# How much of the stock item is "available" for allocation?
quantity = min(unallocated_quantity, stock_item.unallocated_quantity())
if quantity > 0:
try:
BuildItem.objects.create(
build=self,
bom_item=bom_item,
new_items.append(BuildItem(
build_line=line_item,
stock_item=stock_item,
quantity=quantity,
)
))
# Subtract the required quantity
unallocated_quantity -= quantity
@ -1022,163 +1042,83 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
# We have now fully-allocated this BomItem - no need to continue!
break
def required_quantity(self, bom_item, output=None):
"""Get the quantity of a part required to complete the particular build output.
# Bulk-create the new BuildItem objects
BuildItem.objects.bulk_create(new_items)
def unallocated_lines(self, tracked=None):
"""Returns a list of BuildLine objects which have not been fully allocated."""
lines = self.build_lines.all()
if tracked is True:
lines = lines.filter(bom_item__sub_part__trackable=True)
elif tracked is False:
lines = lines.filter(bom_item__sub_part__trackable=False)
unallocated_lines = []
for line in lines:
if not line.is_fully_allocated():
unallocated_lines.append(line)
return unallocated_lines
def is_fully_allocated(self, tracked=None):
"""Test if the BuildOrder has been fully allocated.
This is *true* if *all* associated BuildLine items have sufficient allocation
Arguments:
tracked: If True, only consider tracked BuildLine items. If False, only consider untracked BuildLine items.
Returns:
True if the BuildOrder has been fully allocated, otherwise False
"""
lines = self.unallocated_lines(tracked=tracked)
return len(lines) == 0
def is_output_fully_allocated(self, output):
"""Determine if the specified output (StockItem) has been fully allocated for this build
Args:
bom_item: The Part object
output: The particular build output (StockItem)
output: StockItem object
To determine if the output has been fully allocated,
we need to test all "trackable" BuildLine objects
"""
quantity = bom_item.quantity
if output:
quantity *= output.quantity
else:
quantity *= self.quantity
return quantity
def allocated_bom_items(self, bom_item, output=None):
"""Return all BuildItem objects which allocate stock of <bom_item> to <output>.
Note that the bom_item may allow variants, or direct substitutes,
making things difficult.
Args:
bom_item: The BomItem object
output: Build output (StockItem).
"""
allocations = BuildItem.objects.filter(
build=self,
bom_item=bom_item,
install_into=output,
)
return allocations
def allocated_quantity(self, bom_item, output=None):
"""Return the total quantity of given part allocated to a given build output."""
allocations = self.allocated_bom_items(bom_item, output)
allocated = allocations.aggregate(
q=Coalesce(
Sum('quantity'),
0,
output_field=models.DecimalField(),
for line in self.build_lines.filter(bom_item__sub_part__trackable=True):
# Grab all BuildItem objects which point to this output
allocations = BuildItem.objects.filter(
build_line=line,
install_into=output,
)
)
return allocated['q']
allocated = allocations.aggregate(
q=Coalesce(Sum('quantity'), 0, output_field=models.DecimalField())
)
def unallocated_quantity(self, bom_item, output=None):
"""Return the total unallocated (remaining) quantity of a part against a particular output."""
required = self.required_quantity(bom_item, output)
allocated = self.allocated_quantity(bom_item, output)
return max(required - allocated, 0)
def is_bom_item_allocated(self, bom_item, output=None):
"""Test if the supplied BomItem has been fully allocated"""
if bom_item.consumable:
# Consumable BOM items do not need to be allocated
return True
return self.unallocated_quantity(bom_item, output) == 0
def is_fully_allocated(self, output):
"""Returns True if the particular build output is fully allocated."""
# 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:
if not self.is_bom_item_allocated(bom_item, output):
# The amount allocated against an output must at least equal the BOM quantity
if allocated['q'] < line.bom_item.quantity:
return False
# All parts must be fully allocated!
# At this stage, we can assume that the output is fully allocated
return True
def is_partially_allocated(self, output):
"""Returns True if the particular build output is (at least) partially allocated."""
# 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
def is_overallocated(self):
"""Test if the BuildOrder has been over-allocated.
for bom_item in bom_items:
Returns:
True if any BuildLine has been over-allocated.
"""
if self.allocated_quantity(bom_item, output) > 0:
for line in self.build_lines.all():
if line.is_overallocated():
return True
return False
def are_untracked_parts_allocated(self):
"""Returns True if the un-tracked parts are fully allocated for this BuildOrder."""
return self.is_fully_allocated(None)
def has_overallocated_parts(self, output=None):
"""Check if parts have been 'over-allocated' against the specified output.
Note: If output=None, test un-tracked parts
"""
bom_items = self.tracked_bom_items if output else self.untracked_bom_items
for bom_item in bom_items:
if self.allocated_quantity(bom_item, output) > self.required_quantity(bom_item, output):
return True
return False
def unallocated_bom_items(self, output):
"""Return a list of bom items which have *not* been fully allocated against a particular output."""
unallocated = []
# 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:
if not self.is_bom_item_allocated(bom_item, output):
unallocated.append(bom_item)
return unallocated
@property
def required_parts(self):
"""Returns a list of parts required to build this part (BOM)."""
parts = []
for item in self.bom_items:
parts.append(item.sub_part)
return parts
@property
def required_parts_to_complete_build(self):
"""Returns a list of parts required to complete the full build.
TODO: 2022-01-06 : This method needs to be improved, it is very inefficient in terms of DB hits!
"""
parts = []
for bom_item in self.bom_items:
# Get remaining quantity needed
required_quantity_to_complete_build = self.remaining * bom_item.quantity - self.allocated_quantity(bom_item)
# Compare to net stock
if bom_item.sub_part.net_stock < required_quantity_to_complete_build:
parts.append(bom_item.sub_part)
return parts
@property
def is_active(self):
"""Is this build active?
@ -1194,6 +1134,52 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
"""Returns True if the build status is COMPLETE."""
return self.status == BuildStatus.COMPLETE
@transaction.atomic
def create_build_line_items(self, prevent_duplicates=True):
"""Create BuildLine objects for each BOM line in this BuildOrder."""
lines = []
bom_items = self.part.get_bom_items()
logger.info(f"Creating BuildLine objects for BuildOrder {self.pk} ({len(bom_items)} items))")
# Iterate through each part required to build the parent part
for bom_item in bom_items:
if prevent_duplicates:
if BuildLine.objects.filter(build=self, bom_item=bom_item).exists():
logger.info(f"BuildLine already exists for BuildOrder {self.pk} and BomItem {bom_item.pk}")
continue
# Calculate required quantity
quantity = bom_item.get_required_quantity(self.quantity)
lines.append(
BuildLine(
build=self,
bom_item=bom_item,
quantity=quantity
)
)
BuildLine.objects.bulk_create(lines)
logger.info(f"Created {len(lines)} BuildLine objects for BuildOrder")
@transaction.atomic
def update_build_line_items(self):
"""Rebuild required quantity field for each BuildLine object"""
lines_to_update = []
for line in self.build_lines.all():
line.quantity = line.bom_item.get_required_quantity(self.quantity)
lines_to_update.append(line)
BuildLine.objects.bulk_update(lines_to_update, ['quantity'])
logger.info(f"Updated {len(lines_to_update)} BuildLine objects for BuildOrder")
@receiver(post_save, sender=Build, dispatch_uid='build_post_save_log')
def after_save_build(sender, instance: Build, created: bool, **kwargs):
@ -1204,14 +1190,23 @@ def after_save_build(sender, instance: Build, created: bool, **kwargs):
from . import tasks as build_tasks
if created:
# A new Build has just been created
if instance:
# Run checks on required parts
InvenTree.tasks.offload_task(build_tasks.check_build_stock, instance)
if created:
# A new Build has just been created
# Notify the responsible users that the build order has been created
InvenTree.helpers_model.notify_responsible(instance, sender, exclude=instance.issued_by)
# Generate initial BuildLine objects for the Build
instance.create_build_line_items()
# Run checks on required parts
InvenTree.tasks.offload_task(build_tasks.check_build_stock, instance)
# Notify the responsible users that the build order has been created
InvenTree.helpers_model.notify_responsible(instance, sender, exclude=instance.issued_by)
else:
# Update BuildLine objects if the Build quantity has changed
instance.update_build_line_items()
class BuildOrderAttachment(InvenTree.models.InvenTreeAttachment):
@ -1224,6 +1219,87 @@ class BuildOrderAttachment(InvenTree.models.InvenTreeAttachment):
build = models.ForeignKey(Build, on_delete=models.CASCADE, related_name='attachments')
class BuildLine(models.Model):
"""A BuildLine object links a BOMItem to a Build.
When a new Build is created, the BuildLine objects are created automatically.
- A BuildLine entry is created for each BOM item associated with the part
- The quantity is set to the quantity required to build the part (including overage)
- BuildItem objects are associated with a particular BuildLine
Once a build has been created, BuildLines can (optionally) be removed from the Build
Attributes:
build: Link to a Build object
bom_item: Link to a BomItem object
quantity: Number of units required for the Build
"""
class Meta:
"""Model meta options"""
unique_together = [
('build', 'bom_item'),
]
@staticmethod
def get_api_url():
"""Return the API URL used to access this model"""
return reverse('api-build-line-list')
build = models.ForeignKey(
Build, on_delete=models.CASCADE,
related_name='build_lines', help_text=_('Build object')
)
bom_item = models.ForeignKey(
part.models.BomItem,
on_delete=models.CASCADE,
related_name='build_lines',
)
quantity = models.DecimalField(
decimal_places=5,
max_digits=15,
default=1,
validators=[MinValueValidator(0)],
verbose_name=_('Quantity'),
help_text=_('Required quantity for build order'),
)
@property
def part(self):
"""Return the sub_part reference from the link bom_item"""
return self.bom_item.sub_part
def allocated_quantity(self):
"""Calculate the total allocated quantity for this BuildLine"""
# Queryset containing all BuildItem objects allocated against this BuildLine
allocations = self.allocations.all()
allocated = allocations.aggregate(
q=Coalesce(Sum('quantity'), 0, output_field=models.DecimalField())
)
return allocated['q']
def unallocated_quantity(self):
"""Return the unallocated quantity for this BuildLine"""
return max(self.quantity - self.allocated_quantity(), 0)
def is_fully_allocated(self):
"""Return True if this BuildLine is fully allocated"""
if self.bom_item.consumable:
return True
return self.allocated_quantity() >= self.quantity
def is_overallocated(self):
"""Return True if this BuildLine is over-allocated"""
return self.allocated_quantity() > self.quantity
class BuildItem(InvenTree.models.MetadataMixin, models.Model):
"""A BuildItem links multiple StockItem objects to a Build.
@ -1231,16 +1307,16 @@ class BuildItem(InvenTree.models.MetadataMixin, models.Model):
Attributes:
build: Link to a Build object
bom_item: Link to a BomItem object (may or may not point to the same part as the build)
build_line: Link to a BuildLine object (this is a "line item" within a build)
stock_item: Link to a StockItem object
quantity: Number of units allocated
install_into: Destination stock item (or None)
"""
class Meta:
"""Serializer metaclass"""
"""Model meta options"""
unique_together = [
('build', 'stock_item', 'install_into'),
('build_line', 'stock_item', 'install_into'),
]
@staticmethod
@ -1303,8 +1379,10 @@ class BuildItem(InvenTree.models.MetadataMixin, models.Model):
'quantity': _('Quantity must be 1 for serialized stock')
})
except (stock.models.StockItem.DoesNotExist, part.models.Part.DoesNotExist):
pass
except stock.models.StockItem.DoesNotExist:
raise ValidationError("Stock item must be specified")
except part.models.Part.DoesNotExist:
raise ValidationError("Part must be specified")
"""
Attempt to find the "BomItem" which links this BuildItem to the build.
@ -1312,7 +1390,7 @@ class BuildItem(InvenTree.models.MetadataMixin, models.Model):
- If a BomItem is already set, and it is valid, then we are ok!
"""
bom_item_valid = False
valid = False
if self.bom_item and self.build:
"""
@ -1327,39 +1405,51 @@ class BuildItem(InvenTree.models.MetadataMixin, models.Model):
"""
if self.build.part == self.bom_item.part:
bom_item_valid = self.bom_item.is_stock_item_valid(self.stock_item)
valid = self.bom_item.is_stock_item_valid(self.stock_item)
elif self.bom_item.inherited:
if self.build.part in self.bom_item.part.get_descendants(include_self=False):
bom_item_valid = self.bom_item.is_stock_item_valid(self.stock_item)
valid = self.bom_item.is_stock_item_valid(self.stock_item)
# If the existing BomItem is *not* valid, try to find a match
if not bom_item_valid:
if not valid:
if self.build and self.stock_item:
ancestors = self.stock_item.part.get_ancestors(include_self=True, ascending=True)
for idx, ancestor in enumerate(ancestors):
try:
bom_item = part.models.BomItem.objects.get(part=self.build.part, sub_part=ancestor)
except part.models.BomItem.DoesNotExist:
continue
build_line = BuildLine.objects.filter(
build=self.build,
bom_item__part=ancestor,
)
# A matching BOM item has been found!
if idx == 0 or bom_item.allow_variants:
bom_item_valid = True
self.bom_item = bom_item
break
if build_line.exists():
line = build_line.first()
if idx == 0 or line.bom_item.allow_variants:
valid = True
self.build_line = line
break
# BomItem did not exist or could not be validated.
# Search for a new one
if not bom_item_valid:
if not valid:
raise ValidationError({
'stock_item': _("Selected stock item not found in BOM")
'stock_item': _("Selected stock item does not match BOM line")
})
@property
def build(self):
"""Return the BuildOrder associated with this BuildItem"""
return self.build_line.build if self.build_line else None
@property
def bom_item(self):
"""Return the BomItem associated with this BuildItem"""
return self.build_line.bom_item if self.build_line else None
@transaction.atomic
def complete_allocation(self, user, notes=''):
"""Complete the allocation of this BuildItem into the output stock item.
@ -1431,21 +1521,10 @@ class BuildItem(InvenTree.models.MetadataMixin, models.Model):
else:
return InvenTree.helpers.getBlankThumbnail()
build = models.ForeignKey(
Build,
on_delete=models.CASCADE,
related_name='allocated_stock',
verbose_name=_('Build'),
help_text=_('Build to allocate parts')
)
# Internal model which links part <-> sub_part
# We need to track this separately, to allow for "variant' stock
bom_item = models.ForeignKey(
part.models.BomItem,
on_delete=models.CASCADE,
related_name='allocate_build_items',
blank=True, null=True,
build_line = models.ForeignKey(
BuildLine,
on_delete=models.SET_NULL, null=True,
related_name='allocations',
)
stock_item = models.ForeignKey(

View File

@ -4,8 +4,11 @@ from django.db import transaction
from django.core.exceptions import ValidationError as DjangoValidationError
from django.utils.translation import gettext_lazy as _
from django.db.models import Case, When, Value
from django.db import models
from django.db.models import ExpressionWrapper, F, FloatField
from django.db.models import Case, Sum, When, Value
from django.db.models import BooleanField
from django.db.models.functions import Coalesce
from rest_framework import serializers
from rest_framework.serializers import ValidationError
@ -20,11 +23,11 @@ from InvenTree.status_codes import StockStatus
from stock.models import generate_batch_code, StockItem, StockLocation
from stock.serializers import StockItemSerializerBrief, LocationSerializer
from part.models import BomItem
from part.serializers import PartSerializer, PartBriefSerializer
import part.filters
from part.serializers import BomItemSerializer, PartSerializer, PartBriefSerializer
from users.serializers import OwnerSerializer
from .models import Build, BuildItem, BuildOrderAttachment
from .models import Build, BuildLine, BuildItem, BuildOrderAttachment
class BuildSerializer(InvenTreeModelSerializer):
@ -170,7 +173,7 @@ class BuildOutputSerializer(serializers.Serializer):
if to_complete:
# The build output must have all tracked parts allocated
if not build.is_fully_allocated(output):
if not build.is_output_fully_allocated(output):
# Check if the user has specified that incomplete allocations are ok
accept_incomplete = InvenTree.helpers.str2bool(self.context['request'].data.get('accept_incomplete_allocation', False))
@ -562,7 +565,7 @@ class BuildCancelSerializer(serializers.Serializer):
build = self.context['build']
return {
'has_allocated_stock': build.is_partially_allocated(None),
'has_allocated_stock': build.is_partially_allocated(),
'incomplete_outputs': build.incomplete_count,
'completed_outputs': build.complete_count,
}
@ -621,8 +624,8 @@ class BuildCompleteSerializer(serializers.Serializer):
build = self.context['build']
return {
'overallocated': build.has_overallocated_parts(),
'allocated': build.are_untracked_parts_allocated(),
'overallocated': build.is_overallocated(),
'allocated': build.is_fully_allocated(),
'remaining': build.remaining,
'incomplete': build.incomplete_count,
}
@ -639,7 +642,7 @@ class BuildCompleteSerializer(serializers.Serializer):
"""Check if the 'accept_overallocated' field is required"""
build = self.context['build']
if build.has_overallocated_parts(output=None) and value == OverallocationChoice.REJECT:
if build.is_overallocated() and value == OverallocationChoice.REJECT:
raise ValidationError(_('Some stock items have been overallocated'))
return value
@ -655,7 +658,7 @@ class BuildCompleteSerializer(serializers.Serializer):
"""Check if the 'accept_unallocated' field is required"""
build = self.context['build']
if not build.are_untracked_parts_allocated() and not value:
if not build.is_fully_allocated() and not value:
raise ValidationError(_('Required stock has not been fully allocated'))
return value
@ -706,12 +709,12 @@ class BuildUnallocationSerializer(serializers.Serializer):
- bom_item: Filter against a particular BOM line item
"""
bom_item = serializers.PrimaryKeyRelatedField(
queryset=BomItem.objects.all(),
build_line = serializers.PrimaryKeyRelatedField(
queryset=BuildLine.objects.all(),
many=False,
allow_null=True,
required=False,
label=_('BOM Item'),
label=_('Build Line'),
)
output = serializers.PrimaryKeyRelatedField(
@ -742,8 +745,8 @@ class BuildUnallocationSerializer(serializers.Serializer):
data = self.validated_data
build.unallocateStock(
bom_item=data['bom_item'],
build.deallocate_stock(
build_line=data['build_line'],
output=data['output']
)
@ -754,34 +757,34 @@ class BuildAllocationItemSerializer(serializers.Serializer):
class Meta:
"""Serializer metaclass"""
fields = [
'bom_item',
'build_item',
'stock_item',
'quantity',
'output',
]
bom_item = serializers.PrimaryKeyRelatedField(
queryset=BomItem.objects.all(),
build_line = serializers.PrimaryKeyRelatedField(
queryset=BuildLine.objects.all(),
many=False,
allow_null=False,
required=True,
label=_('BOM Item'),
label=_('Build Line Item'),
)
def validate_bom_item(self, bom_item):
def validate_build_line(self, build_line):
"""Check if the parts match"""
build = self.context['build']
# BomItem should point to the same 'part' as the parent build
if build.part != bom_item.part:
if build.part != build_line.bom_item.part:
# If not, it may be marked as "inherited" from a parent part
if bom_item.inherited and build.part in bom_item.part.get_descendants(include_self=False):
if build_line.bom_item.inherited and build.part in build_line.bom_item.part.get_descendants(include_self=False):
pass
else:
raise ValidationError(_("bom_item.part must point to the same part as the build order"))
return bom_item
return build_line
stock_item = serializers.PrimaryKeyRelatedField(
queryset=StockItem.objects.all(),
@ -824,8 +827,7 @@ class BuildAllocationItemSerializer(serializers.Serializer):
"""Perform data validation for this item"""
super().validate(data)
build = self.context['build']
bom_item = data['bom_item']
build_line = data['build_line']
stock_item = data['stock_item']
quantity = data['quantity']
output = data.get('output', None)
@ -847,20 +849,20 @@ class BuildAllocationItemSerializer(serializers.Serializer):
})
# Output *must* be set for trackable parts
if output is None and bom_item.sub_part.trackable:
if output is None and build_line.bom_item.sub_part.trackable:
raise ValidationError({
'output': _('Build output must be specified for allocation of tracked parts'),
})
# Output *cannot* be set for un-tracked parts
if output is not None and not bom_item.sub_part.trackable:
if output is not None and not build_line.bom_item.sub_part.trackable:
raise ValidationError({
'output': _('Build output cannot be specified for allocation of untracked parts'),
})
# Check if this allocation would be unique
if BuildItem.objects.filter(build=build, stock_item=stock_item, install_into=output).exists():
if BuildItem.objects.filter(build_line=build_line, stock_item=stock_item, install_into=output).exists():
raise ValidationError(_('This stock item has already been allocated to this build output'))
return data
@ -894,24 +896,21 @@ class BuildAllocationSerializer(serializers.Serializer):
items = data.get('items', [])
build = self.context['build']
with transaction.atomic():
for item in items:
bom_item = item['bom_item']
build_line = item['build_line']
stock_item = item['stock_item']
quantity = item['quantity']
output = item.get('output', None)
# Ignore allocation for consumable BOM items
if bom_item.consumable:
if build_line.bom_item.consumable:
continue
try:
# Create a new BuildItem to allocate stock
BuildItem.objects.create(
build=build,
bom_item=bom_item,
build_line=build_line,
stock_item=stock_item,
quantity=quantity,
install_into=output
@ -993,43 +992,37 @@ class BuildItemSerializer(InvenTreeModelSerializer):
model = BuildItem
fields = [
'pk',
'bom_part',
'build',
'build_detail',
'build_line',
'install_into',
'location',
'location_detail',
'part',
'part_detail',
'stock_item',
'quantity',
'location_detail',
'part_detail',
'stock_item_detail',
'quantity'
'build_detail',
]
bom_part = serializers.IntegerField(source='bom_item.sub_part.pk', read_only=True)
part = serializers.IntegerField(source='stock_item.part.pk', read_only=True)
location = serializers.IntegerField(source='stock_item.location.pk', read_only=True)
# Annotated fields
build = serializers.PrimaryKeyRelatedField(source='build_line.build', many=False, read_only=True)
# Extra (optional) detail fields
part_detail = PartSerializer(source='stock_item.part', many=False, read_only=True)
build_detail = BuildSerializer(source='build', many=False, read_only=True)
part_detail = PartBriefSerializer(source='stock_item.part', many=False, read_only=True)
stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True)
location_detail = LocationSerializer(source='stock_item.location', read_only=True)
build_detail = BuildSerializer(source='build_line.build', many=False, read_only=True)
quantity = InvenTreeDecimalField()
def __init__(self, *args, **kwargs):
"""Determine which extra details fields should be included"""
build_detail = kwargs.pop('build_detail', False)
part_detail = kwargs.pop('part_detail', False)
location_detail = kwargs.pop('location_detail', False)
part_detail = kwargs.pop('part_detail', True)
location_detail = kwargs.pop('location_detail', True)
stock_detail = kwargs.pop('stock_detail', False)
build_detail = kwargs.pop('build_detail', False)
super().__init__(*args, **kwargs)
if not build_detail:
self.fields.pop('build_detail')
if not part_detail:
self.fields.pop('part_detail')
@ -1039,6 +1032,144 @@ class BuildItemSerializer(InvenTreeModelSerializer):
if not stock_detail:
self.fields.pop('stock_item_detail')
if not build_detail:
self.fields.pop('build_detail')
class BuildLineSerializer(InvenTreeModelSerializer):
"""Serializer for a BuildItem object."""
class Meta:
"""Serializer metaclass"""
model = BuildLine
fields = [
'pk',
'build',
'bom_item',
'bom_item_detail',
'part_detail',
'quantity',
'allocations',
# Annotated fields
'allocated',
'on_order',
'available_stock',
'available_substitute_stock',
'available_variant_stock',
]
read_only_fields = [
'build',
'bom_item',
'allocations',
]
quantity = serializers.FloatField()
# Foreign key fields
bom_item_detail = BomItemSerializer(source='bom_item', many=False, read_only=True)
part_detail = PartSerializer(source='bom_item.sub_part', many=False, read_only=True)
allocations = BuildItemSerializer(many=True, read_only=True)
# Annotated (calculated) fields
allocated = serializers.FloatField(read_only=True)
on_order = serializers.FloatField(read_only=True)
available_stock = serializers.FloatField(read_only=True)
available_substitute_stock = serializers.FloatField(read_only=True)
available_variant_stock = serializers.FloatField(read_only=True)
@staticmethod
def annotate_queryset(queryset):
"""Add extra annotations to the queryset:
- allocated: Total stock quantity allocated against this build line
- available: Total stock available for allocation against this build line
- on_order: Total stock on order for this build line
"""
# Pre-fetch related fields
queryset = queryset.prefetch_related(
'bom_item__sub_part__stock_items',
'bom_item__sub_part__stock_items__allocations',
'bom_item__sub_part__stock_items__sales_order_allocations',
'bom_item__substitutes',
'bom_item__substitutes__part__stock_items',
'bom_item__substitutes__part__stock_items__allocations',
'bom_item__substitutes__part__stock_items__sales_order_allocations',
)
# Annotate the "allocated" quantity
# Difficulty: Easy
queryset = queryset.annotate(
allocated=Coalesce(
Sum('allocations__quantity'), 0,
output_field=models.DecimalField()
),
)
ref = 'bom_item__sub_part__'
# Annotate the "on_order" quantity
# Difficulty: Medium
queryset = queryset.annotate(
on_order=part.filters.annotate_on_order_quantity(reference=ref),
)
# Annotate the "available" quantity
# TODO: In the future, this should be refactored.
# TODO: Note that part.serializers.BomItemSerializer also has a similar annotation
queryset = queryset.alias(
total_stock=part.filters.annotate_total_stock(reference=ref),
allocated_to_sales_orders=part.filters.annotate_sales_order_allocations(reference=ref),
allocated_to_build_orders=part.filters.annotate_build_order_allocations(reference=ref),
)
# Calculate 'available_stock' based on previously annotated fields
queryset = queryset.annotate(
available_stock=ExpressionWrapper(
F('total_stock') - F('allocated_to_sales_orders') - F('allocated_to_build_orders'),
output_field=models.DecimalField(),
)
)
ref = 'bom_item__substitutes__part__'
# Extract similar information for any 'substitute' parts
queryset = queryset.alias(
substitute_stock=part.filters.annotate_total_stock(reference=ref),
substitute_build_allocations=part.filters.annotate_build_order_allocations(reference=ref),
substitute_sales_allocations=part.filters.annotate_sales_order_allocations(reference=ref)
)
# Calculate 'available_substitute_stock' field
queryset = queryset.annotate(
available_substitute_stock=ExpressionWrapper(
F('substitute_stock') - F('substitute_build_allocations') - F('substitute_sales_allocations'),
output_field=models.DecimalField(),
)
)
# Annotate the queryset with 'available variant stock' information
variant_stock_query = part.filters.variant_stock_query(reference='bom_item__sub_part__')
queryset = queryset.alias(
variant_stock_total=part.filters.annotate_variant_quantity(variant_stock_query, reference='quantity'),
variant_bo_allocations=part.filters.annotate_variant_quantity(variant_stock_query, reference='sales_order_allocations__quantity'),
variant_so_allocations=part.filters.annotate_variant_quantity(variant_stock_query, reference='allocations__quantity'),
)
queryset = queryset.annotate(
available_variant_stock=ExpressionWrapper(
F('variant_stock_total') - F('variant_bo_allocations') - F('variant_so_allocations'),
output_field=FloatField(),
)
)
return queryset
class BuildAttachmentSerializer(InvenTreeAttachmentSerializer):
"""Serializer for a BuildAttachment."""

View File

@ -174,7 +174,7 @@ src="{% static 'img/blank_image.png' %}"
{% else %}
<span class='fa fa-times-circle icon-red'></span>
{% endif %}
<td>{% trans "Completed" %}</td>
<td>{% trans "Completed Outputs" %}</td>
<td>{% progress_bar build.completed build.quantity id='build-completed' max_width='150px' %}</td>
</tr>
{% if build.parent %}

View File

@ -64,10 +64,10 @@
</tr>
<tr>
<td><span class='fas fa-check-circle'></span></td>
<td>{% trans "Completed" %}</td>
<td>{% trans "Completed Outputs" %}</td>
<td>{% progress_bar build.completed build.quantity id='build-completed-2' max_width='150px' %}</td>
</tr>
{% if build.active and has_untracked_bom_items %}
{% if build.active %}
<tr>
<td><span class='fas fa-list'></span></td>
<td>{% trans "Allocated Parts" %}</td>
@ -179,9 +179,9 @@
<h4>{% trans "Allocate Stock to Build" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% if roles.build.add and build.active and has_untracked_bom_items %}
<button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Unallocate stock" %}'>
<span class='fas fa-minus-circle'></span> {% trans "Unallocate Stock" %}
{% if roles.build.add and build.active %}
<button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Deallocate stock" %}'>
<span class='fas fa-minus-circle'></span> {% trans "Deallocate Stock" %}
</button>
<button class='btn btn-primary' type='button' id='btn-auto-allocate' title='{% trans "Automatically allocate stock to build" %}'>
<span class='fas fa-magic'></span> {% trans "Auto Allocate" %}
@ -199,9 +199,8 @@
</div>
</div>
<div class='panel-content'>
{% if has_untracked_bom_items %}
{% if build.active %}
{% if build.are_untracked_parts_allocated %}
{% if build.is_fully_allocated %}
<div class='alert alert-block alert-success'>
{% trans "Untracked stock has been fully allocated for this Build Order" %}
</div>
@ -211,22 +210,17 @@
</div>
{% endif %}
{% endif %}
<div id='unallocated-toolbar'>
<div id='build-lines-toolbar'>
<div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group'>
<button id='allocate-selected-items' class='btn btn-success' title='{% trans "Allocate selected items" %}'>
<span class='fas fa-sign-in-alt'></span>
</button>
{% include "filter_list.html" with id='builditems' %}
{% include "filter_list.html" with id='buildlines' %}
</div>
</div>
</div>
<table class='table table-striped table-condensed' id='allocation-table-untracked' data-toolbar='#unallocated-toolbar'></table>
{% else %}
<div class='alert alert-block alert-info'>
{% trans "This Build Order does not have any associated untracked BOM items" %}
</div>
{% endif %}
<table class='table table-striped table-condensed' id='build-lines-table' data-toolbar='#build-lines-toolbar'></table>
</div>
</div>
@ -427,38 +421,15 @@ onPanelLoad('outputs', function() {
{% endif %}
});
{% if build.active and has_untracked_bom_items %}
function loadUntrackedStockTable() {
var build_info = {
pk: {{ build.pk }},
part: {{ build.part.pk }},
quantity: {{ build.quantity }},
{% if build.take_from %}
source_location: {{ build.take_from.pk }},
{% endif %}
tracked_parts: false,
};
$('#allocation-table-untracked').bootstrapTable('destroy');
// Load allocation table for un-tracked parts
loadBuildOutputAllocationTable(
build_info,
null,
{
search: true,
}
);
}
onPanelLoad('allocate', function() {
loadUntrackedStockTable();
// Load the table of line items for this build order
loadBuildLineTable(
"#build-lines-table",
{{ build.pk }},
{}
);
});
{% endif %}
$('#btn-create-output').click(function() {
createBuildOutput(
@ -480,66 +451,62 @@ $("#btn-auto-allocate").on('click', function() {
{% if build.take_from %}
location: {{ build.take_from.pk }},
{% endif %}
onSuccess: loadUntrackedStockTable,
onSuccess: function() {
$('#build-lines-table').bootstrapTable('refresh');
},
}
);
});
$("#btn-allocate").on('click', function() {
function allocateSelectedLines() {
var bom_items = $("#allocation-table-untracked").bootstrapTable("getData");
let data = getTableData('#build-lines-table');
var incomplete_bom_items = [];
let unallocated_lines = [];
bom_items.forEach(function(bom_item) {
if (bom_item.required > bom_item.allocated) {
incomplete_bom_items.push(bom_item);
data.forEach(function(line) {
if (line.allocated < line.quantity) {
unallocated_lines.push(line);
}
});
if (incomplete_bom_items.length == 0) {
if (unallocated_lines.length == 0) {
showAlertDialog(
'{% trans "Allocation Complete" %}',
'{% trans "All untracked stock items have been allocated" %}',
'{% trans "All lines have been fully allocated" %}',
);
} else {
allocateStockToBuild(
{{ build.pk }},
{{ build.part.pk }},
incomplete_bom_items,
unallocated_lines,
{
{% if build.take_from %}
source_location: {{ build.take_from.pk }},
{% endif %}
success: loadUntrackedStockTable,
success: function() {
$('#build-lines-table').bootstrapTable('refresh');
},
}
);
}
});
}
$('#btn-unallocate').on('click', function() {
unallocateStock({{ build.id }}, {
deallocateStock({{ build.id }}, {
table: '#allocation-table-untracked',
onSuccess: loadUntrackedStockTable,
onSuccess: function() {
$('#build-lines-table').bootstrapTable('refresh');
},
});
});
$('#allocate-selected-items').click(function() {
allocateSelectedLines();
});
var bom_items = getTableData('#allocation-table-untracked');
allocateStockToBuild(
{{ build.pk }},
{{ build.part.pk }},
bom_items,
{
{% if build.take_from %}
source_location: {{ build.take_from.pk }},
{% endif %}
success: loadUntrackedStockTable,
}
);
$("#btn-allocate").on('click', function() {
allocateSelectedLines();
});
{% endif %}

View File

@ -4,18 +4,16 @@
{% trans "Build Order Details" as text %}
{% include "sidebar_item.html" with label='details' text=text icon="fa-info-circle" %}
{% if build.active %}
{% if build.is_active %}
{% trans "Allocate Stock" as text %}
{% include "sidebar_item.html" with label='allocate' text=text icon="fa-tasks" %}
{% endif %}
{% trans "Consumed Stock" as text %}
{% include "sidebar_item.html" with label='consumed' text=text icon="fa-list" %}
{% if build.is_active %}
{% trans "Incomplete Outputs" as text %}
{% include "sidebar_item.html" with label='outputs' text=text icon="fa-tools" %}
{% endif %}
{% trans "Completed Outputs" as text %}
{% include "sidebar_item.html" with label='completed' text=text icon="fa-boxes" %}
{% trans "Consumed Stock" as text %}
{% include "sidebar_item.html" with label='consumed' text=text icon="fa-list" %}
{% trans "Child Build Orders" as text %}
{% include "sidebar_item.html" with label='children' text=text icon="fa-sitemap" %}
{% trans "Attachments" as text %}

View File

@ -582,6 +582,9 @@ class BuildAllocationTest(BuildAPITest):
self.build = Build.objects.get(pk=1)
# Regenerate BuildLine objects
self.build.create_build_line_items()
# Record number of build items which exist at the start of each test
self.n = BuildItem.objects.count()
@ -593,7 +596,7 @@ class BuildAllocationTest(BuildAPITest):
self.assertEqual(self.build.part.bom_items.count(), 4)
# No items yet allocated to this build
self.assertEqual(self.build.allocated_stock.count(), 0)
self.assertEqual(BuildItem.objects.filter(build_line__build=self.build).count(), 0)
def test_get(self):
"""A GET request to the endpoint should return an error."""
@ -634,7 +637,7 @@ class BuildAllocationTest(BuildAPITest):
{
"items": [
{
"bom_item": 1, # M2x4 LPHS
"build_line": 1, # M2x4 LPHS
"stock_item": 2, # 5,000 screws available
}
]
@ -658,7 +661,7 @@ class BuildAllocationTest(BuildAPITest):
expected_code=400
).data
self.assertIn("This field is required", str(data["items"][0]["bom_item"]))
self.assertIn("This field is required", str(data["items"][0]["build_line"]))
# Missing stock_item
data = self.post(
@ -666,7 +669,7 @@ class BuildAllocationTest(BuildAPITest):
{
"items": [
{
"bom_item": 1,
"build_line": 1,
"quantity": 5000,
}
]
@ -681,12 +684,25 @@ class BuildAllocationTest(BuildAPITest):
def test_invalid_bom_item(self):
"""Test by passing an invalid BOM item."""
# Find the right (in this case, wrong) BuildLine instance
si = StockItem.objects.get(pk=11)
lines = self.build.build_lines.all()
wrong_line = None
for line in lines:
if line.bom_item.sub_part.pk != si.pk:
wrong_line = line
break
data = self.post(
self.url,
{
"items": [
{
"bom_item": 5,
"build_line": wrong_line.pk,
"stock_item": 11,
"quantity": 500,
}
@ -695,19 +711,31 @@ class BuildAllocationTest(BuildAPITest):
expected_code=400
).data
self.assertIn('must point to the same part', str(data))
self.assertIn('Selected stock item does not match BOM line', str(data))
def test_valid_data(self):
"""Test with valid data.
This should result in creation of a new BuildItem object
"""
# Find the correct BuildLine
si = StockItem.objects.get(pk=2)
right_line = None
for line in self.build.build_lines.all():
if line.bom_item.sub_part.pk == si.part.pk:
right_line = line
break
self.post(
self.url,
{
"items": [
{
"bom_item": 1,
"build_line": right_line.pk,
"stock_item": 2,
"quantity": 5000,
}
@ -749,16 +777,22 @@ class BuildOverallocationTest(BuildAPITest):
cls.state = {}
cls.allocation = {}
for i, bi in enumerate(cls.build.part.bom_items.all()):
rq = cls.build.required_quantity(bi, None) + i + 1
si = StockItem.objects.filter(part=bi.sub_part, quantity__gte=rq).first()
items_to_create = []
cls.state[bi.sub_part] = (si, si.quantity, rq)
BuildItem.objects.create(
build=cls.build,
for idx, build_line in enumerate(cls.build.build_lines.all()):
required = build_line.quantity + idx + 1
sub_part = build_line.bom_item.sub_part
si = StockItem.objects.filter(part=sub_part, quantity__gte=required).first()
cls.state[sub_part] = (si, si.quantity, required)
items_to_create.append(BuildItem(
build_line=build_line,
stock_item=si,
quantity=rq,
)
quantity=required,
))
BuildItem.objects.bulk_create(items_to_create)
# create and complete outputs
cls.build.create_build_output(cls.build.quantity)
@ -822,9 +856,10 @@ class BuildOverallocationTest(BuildAPITest):
self.assertTrue(self.build.is_complete)
# Check stock items have reduced only by bom requirement (overallocation trimmed)
for bi in self.build.part.bom_items.all():
si, oq, _ = self.state[bi.sub_part]
rq = self.build.required_quantity(bi, None)
for line in self.build.build_lines.all():
si, oq, _ = self.state[line.bom_item.sub_part]
rq = line.quantity
si.refresh_from_db()
self.assertEqual(si.quantity, oq - rq)

View File

@ -13,7 +13,7 @@ from InvenTree import status_codes as status
import common.models
import build.tasks
from build.models import Build, BuildItem, generate_next_build_reference
from build.models import Build, BuildItem, BuildLine, generate_next_build_reference
from part.models import Part, BomItem, BomItemSubstitute
from stock.models import StockItem
from users.models import Owner
@ -107,6 +107,11 @@ class BuildTestBase(TestCase):
issued_by=get_user_model().objects.get(pk=1),
)
# Create some BuildLine items we can use later on
cls.line_1 = BuildLine.objects.get(build=cls.build, bom_item=cls.bom_item_1)
cls.line_2 = BuildLine.objects.get(build=cls.build, bom_item=cls.bom_item_2)
cls.line_3 = BuildLine.objects.get(build=cls.build, bom_item=cls.bom_item_3)
# Create some build output (StockItem) objects
cls.output_1 = StockItem.objects.create(
part=cls.assembly,
@ -248,13 +253,10 @@ class BuildTest(BuildTestBase):
for output in self.build.get_build_outputs().all():
self.assertFalse(self.build.is_fully_allocated(output))
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_1, self.output_1))
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2, self.output_2))
self.assertFalse(self.line_1.is_fully_allocated())
self.assertFalse(self.line_2.is_overallocated())
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1, self.output_1), 15)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1, self.output_2), 35)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2, self.output_1), 9)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2, self.output_2), 21)
self.assertEqual(self.line_1.allocated_quantity(), 0)
self.assertFalse(self.build.is_complete)
@ -264,25 +266,25 @@ class BuildTest(BuildTestBase):
stock = StockItem.objects.create(part=self.assembly, quantity=99)
# Create a BuiltItem which points to an invalid StockItem
b = BuildItem(stock_item=stock, build=self.build, quantity=10)
b = BuildItem(stock_item=stock, build_line=self.line_2, quantity=10)
with self.assertRaises(ValidationError):
b.save()
# Create a BuildItem which has too much stock assigned
b = BuildItem(stock_item=self.stock_1_1, build=self.build, quantity=9999999)
b = BuildItem(stock_item=self.stock_1_1, build_line=self.line_1, quantity=9999999)
with self.assertRaises(ValidationError):
b.clean()
# Negative stock? Not on my watch!
b = BuildItem(stock_item=self.stock_1_1, build=self.build, quantity=-99)
b = BuildItem(stock_item=self.stock_1_1, build_line=self.line_1, quantity=-99)
with self.assertRaises(ValidationError):
b.clean()
# Ok, what about we make one that does *not* fail?
b = BuildItem(stock_item=self.stock_1_2, build=self.build, install_into=self.output_1, quantity=10)
b = BuildItem(stock_item=self.stock_1_2, build_line=self.line_1, install_into=self.output_1, quantity=10)
b.save()
def test_duplicate_bom_line(self):
@ -302,13 +304,24 @@ class BuildTest(BuildTestBase):
allocations: Map of {StockItem: quantity}
"""
items_to_create = []
for item, quantity in allocations.items():
BuildItem.objects.create(
# Find an appropriate BuildLine to allocate against
line = BuildLine.objects.filter(
build=self.build,
bom_item__sub_part=item.part
).first()
items_to_create.append(BuildItem(
build_line=line,
stock_item=item,
quantity=quantity,
install_into=output
)
))
BuildItem.objects.bulk_create(items_to_create)
def test_partial_allocation(self):
"""Test partial allocation of stock"""
@ -321,7 +334,7 @@ class BuildTest(BuildTestBase):
}
)
self.assertTrue(self.build.is_fully_allocated(self.output_1))
self.assertTrue(self.build.is_output_fully_allocated(self.output_1))
# Partially allocate tracked stock against build output 2
self.allocate_stock(
@ -331,7 +344,7 @@ class BuildTest(BuildTestBase):
}
)
self.assertFalse(self.build.is_fully_allocated(self.output_2))
self.assertFalse(self.build.is_output_fully_allocated(self.output_2))
# Partially allocate untracked stock against build
self.allocate_stock(
@ -342,11 +355,12 @@ class BuildTest(BuildTestBase):
}
)
self.assertFalse(self.build.is_fully_allocated(None))
self.assertFalse(self.build.is_output_fully_allocated(None))
unallocated = self.build.unallocated_bom_items(None)
# Find lines which are *not* fully allocated
unallocated = self.build.unallocated_lines()
self.assertEqual(len(unallocated), 2)
self.assertEqual(len(unallocated), 3)
self.allocate_stock(
None,
@ -357,17 +371,17 @@ class BuildTest(BuildTestBase):
self.assertFalse(self.build.is_fully_allocated(None))
unallocated = self.build.unallocated_bom_items(None)
self.assertEqual(len(unallocated), 1)
self.build.unallocateStock()
unallocated = self.build.unallocated_bom_items(None)
unallocated = self.build.unallocated_lines()
self.assertEqual(len(unallocated), 2)
self.assertFalse(self.build.are_untracked_parts_allocated())
self.build.deallocate_stock()
unallocated = self.build.unallocated_lines(None)
self.assertEqual(len(unallocated), 3)
self.assertFalse(self.build.is_fully_allocated(tracked=False))
self.stock_2_1.quantity = 500
self.stock_2_1.save()
@ -381,7 +395,7 @@ class BuildTest(BuildTestBase):
}
)
self.assertTrue(self.build.are_untracked_parts_allocated())
self.assertTrue(self.build.is_fully_allocated(tracked=False))
def test_overallocation_and_trim(self):
"""Test overallocation of stock and trim function"""
@ -425,10 +439,10 @@ class BuildTest(BuildTestBase):
}
)
self.assertTrue(self.build.has_overallocated_parts(None))
self.assertTrue(self.build.is_overallocated())
self.build.trim_allocated_stock()
self.assertFalse(self.build.has_overallocated_parts(None))
self.assertFalse(self.build.is_overallocated())
self.build.complete_build_output(self.output_1, None)
self.build.complete_build_output(self.output_2, None)
@ -587,7 +601,7 @@ class BuildTest(BuildTestBase):
"""Unit tests for the metadata field."""
# Make sure a BuildItem exists before trying to run this test
b = BuildItem(stock_item=self.stock_1_2, build=self.build, install_into=self.output_1, quantity=10)
b = BuildItem(stock_item=self.stock_1_2, build_line=self.line_1, install_into=self.output_1, quantity=10)
b.save()
for model in [Build, BuildItem]:
@ -644,7 +658,7 @@ class AutoAllocationTests(BuildTestBase):
# No build item allocations have been made against the build
self.assertEqual(self.build.allocated_stock.count(), 0)
self.assertFalse(self.build.are_untracked_parts_allocated())
self.assertFalse(self.build.is_fully_allocated(tracked=False))
# Stock is not interchangeable, nothing will happen
self.build.auto_allocate_stock(
@ -652,15 +666,15 @@ class AutoAllocationTests(BuildTestBase):
substitutes=False,
)
self.assertFalse(self.build.are_untracked_parts_allocated())
self.assertFalse(self.build.is_fully_allocated(tracked=False))
self.assertEqual(self.build.allocated_stock.count(), 0)
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_1))
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2))
self.assertFalse(self.line_1.is_fully_allocated())
self.assertFalse(self.line_2.is_fully_allocated())
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 50)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 30)
self.assertEqual(self.line_1.unallocated_quantity(), 50)
self.assertEqual(self.line_2.unallocated_quantity(), 30)
# This time we expect stock to be allocated!
self.build.auto_allocate_stock(
@ -669,15 +683,15 @@ class AutoAllocationTests(BuildTestBase):
optional_items=True,
)
self.assertFalse(self.build.are_untracked_parts_allocated())
self.assertFalse(self.build.is_fully_allocated(tracked=False))
self.assertEqual(self.build.allocated_stock.count(), 7)
self.assertTrue(self.build.is_bom_item_allocated(self.bom_item_1))
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2))
self.assertTrue(self.line_1.is_fully_allocated())
self.assertFalse(self.line_2.is_fully_allocated())
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 5)
self.assertEqual(self.line_1.unallocated_quantity(), 0)
self.assertEqual(self.line_2.unallocated_quantity(), 5)
# This time, allow substitute parts to be used!
self.build.auto_allocate_stock(
@ -685,12 +699,11 @@ class AutoAllocationTests(BuildTestBase):
substitutes=True,
)
# self.assertEqual(self.build.allocated_stock.count(), 8)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 5.0)
self.assertEqual(self.line_1.unallocated_quantity(), 0)
self.assertEqual(self.line_2.unallocated_quantity(), 5)
self.assertTrue(self.build.is_bom_item_allocated(self.bom_item_1))
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2))
self.assertTrue(self.line_1.is_fully_allocated())
self.assertFalse(self.line_2.is_fully_allocated())
def test_fully_auto(self):
"""We should be able to auto-allocate against a build in a single go"""
@ -701,7 +714,7 @@ class AutoAllocationTests(BuildTestBase):
optional_items=True,
)
self.assertTrue(self.build.are_untracked_parts_allocated())
self.assertTrue(self.build.is_fully_allocated(tracked=False))
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 0)
self.assertEqual(self.line_1.unallocated_quantity(), 0)
self.assertEqual(self.line_2.unallocated_quantity(), 0)

View File

@ -158,3 +158,139 @@ class TestReferencePatternMigration(MigratorTestCase):
pattern = Setting.objects.get(key='BUILDORDER_REFERENCE_PATTERN')
self.assertEqual(pattern.value, 'BuildOrder-{ref:04d}')
class TestBuildLineCreation(MigratorTestCase):
"""Test that build lines are correctly created for existing builds.
Ref: https://github.com/inventree/InvenTree/pull/4855
This PR added the 'BuildLine' model, which acts as a link between a Build and a BomItem.
- Migration 0044 creates BuildLine objects for existing builds.
- Migration 0046 links any existing BuildItem objects to corresponding BuildLine
"""
migrate_from = ('build', '0041_alter_build_title')
migrate_to = ('build', '0047_auto_20230606_1058')
def prepare(self):
"""Create data to work with"""
# Model references
Part = self.old_state.apps.get_model('part', 'part')
BomItem = self.old_state.apps.get_model('part', 'bomitem')
Build = self.old_state.apps.get_model('build', 'build')
BuildItem = self.old_state.apps.get_model('build', 'builditem')
StockItem = self.old_state.apps.get_model('stock', 'stockitem')
# The "BuildLine" model does not exist yet
with self.assertRaises(LookupError):
self.old_state.apps.get_model('build', 'buildline')
# Create a part
assembly = Part.objects.create(
name='Assembly',
description='An assembly',
assembly=True,
level=0, lft=0, rght=0, tree_id=0,
)
# Create components
for idx in range(1, 11):
part = Part.objects.create(
name=f"Part {idx}",
description=f"Part {idx}",
level=0, lft=0, rght=0, tree_id=0,
)
# Create plentiful stock
StockItem.objects.create(
part=part,
quantity=1000,
level=0, lft=0, rght=0, tree_id=0,
)
# Create a BOM item
BomItem.objects.create(
part=assembly,
sub_part=part,
quantity=idx,
reference=f"REF-{idx}",
)
# Create some builds
for idx in range(1, 4):
build = Build.objects.create(
part=assembly,
title=f"Build {idx}",
quantity=idx * 10,
reference=f"REF-{idx}",
level=0, lft=0, rght=0, tree_id=0,
)
# Allocate stock to the build
for bom_item in BomItem.objects.all():
stock_item = StockItem.objects.get(part=bom_item.sub_part)
BuildItem.objects.create(
build=build,
bom_item=bom_item,
stock_item=stock_item,
quantity=bom_item.quantity,
)
def test_build_line_creation(self):
"""Test that the BuildLine objects have been created correctly"""
Build = self.new_state.apps.get_model('build', 'build')
BomItem = self.new_state.apps.get_model('part', 'bomitem')
BuildLine = self.new_state.apps.get_model('build', 'buildline')
BuildItem = self.new_state.apps.get_model('build', 'builditem')
StockItem = self.new_state.apps.get_model('stock', 'stockitem')
# There should be 3x builds
self.assertEqual(Build.objects.count(), 3)
# 10x BOMItem objects
self.assertEqual(BomItem.objects.count(), 10)
# 10x StockItem objects
self.assertEqual(StockItem.objects.count(), 10)
# And 30x BuildLine items (1 for each BomItem for each Build)
self.assertEqual(BuildLine.objects.count(), 30)
# And 30x BuildItem objects (1 for each BomItem for each Build)
self.assertEqual(BuildItem.objects.count(), 30)
# Check that each BuildItem has been linked to a BuildLine
for item in BuildItem.objects.all():
self.assertIsNotNone(item.build_line)
self.assertEqual(
item.stock_item.part,
item.build_line.bom_item.sub_part,
)
item = BuildItem.objects.first()
# Check that the "build" field has been removed
with self.assertRaises(AttributeError):
item.build
# Check that the "bom_item" field has been removed
with self.assertRaises(AttributeError):
item.bom_item
# Check that each BuildLine is correctly configured
for line in BuildLine.objects.all():
# Check that the quantity is correct
self.assertEqual(
line.quantity,
line.build.quantity * line.bom_item.quantity,
)
# Check that the linked parts are correct
self.assertEqual(
line.build.part,
line.bom_item.part,
)

View File

@ -39,7 +39,5 @@ class BuildDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
part = build.part
ctx['part'] = part
ctx['has_tracked_bom_items'] = build.has_tracked_bom_items()
ctx['has_untracked_bom_items'] = build.has_untracked_bom_items()
return ctx

View File

@ -122,8 +122,13 @@ class CurrencyExchangeView(APIView):
# Information on last update
try:
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
updated = backend.last_update
backend = ExchangeBackend.objects.filter(name='InvenTreeExchange')
if backend.exists():
backend = backend.first()
updated = backend.last_update
else:
updated = None
except Exception:
updated = None

View File

@ -1,9 +1,13 @@
"""User-configurable settings for the common app."""
import logging
from django.conf import settings
from moneyed import CURRENCIES
logger = logging.getLogger('inventree')
def currency_code_default():
"""Returns the default currency code (or USD if not specified)"""

View File

@ -8,6 +8,7 @@ import common.settings
class Migration(migrations.Migration):
dependencies = [
('common', '0004_inventreesetting'),
('company', '0024_unique_name_email_constraint'),
]

View File

@ -8,6 +8,7 @@ import djmoney.models.fields
class Migration(migrations.Migration):
dependencies = [
('common', '0004_inventreesetting'),
('company', '0038_manufacturerpartparameter'),
]

View File

@ -8,6 +8,7 @@ import djmoney.models.validators
class Migration(migrations.Migration):
dependencies = [
('common', '0004_inventreesetting'),
('company', '0050_alter_company_website'),
]

View File

@ -8,6 +8,7 @@ import common.settings
class Migration(migrations.Migration):
dependencies = [
('common', '0004_inventreesetting'),
('order', '0037_auto_20201110_0911'),
]

View File

@ -833,10 +833,10 @@ class SalesOrder(TotalPriceMixin, Order):
return True
def is_over_allocated(self):
def is_overallocated(self):
"""Return true if any lines in the order are over-allocated."""
for line in self.lines.all():
if line.is_over_allocated():
if line.is_overallocated():
return True
return False
@ -1358,7 +1358,7 @@ class SalesOrderLineItem(OrderLineItem):
return self.allocated_quantity() >= self.quantity
def is_over_allocated(self):
def is_overallocated(self):
"""Return True if this line item is over allocated."""
return self.allocated_quantity() > self.quantity

View File

@ -102,7 +102,7 @@ class SalesOrderTest(TestCase):
self.assertEqual(self.line.allocated_quantity(), 0)
self.assertEqual(self.line.fulfilled_quantity(), 0)
self.assertFalse(self.line.is_fully_allocated())
self.assertFalse(self.line.is_over_allocated())
self.assertFalse(self.line.is_overallocated())
self.assertTrue(self.order.is_pending)
self.assertFalse(self.order.is_fully_allocated())

View File

@ -350,7 +350,10 @@ class PartTestTemplateDetail(RetrieveUpdateDestroyAPI):
class PartTestTemplateList(ListCreateAPI):
"""API endpoint for listing (and creating) a PartTestTemplate."""
"""API endpoint for listing (and creating) a PartTestTemplate.
TODO: Add filterset class for this view
"""
queryset = PartTestTemplate.objects.all()
serializer_class = part_serializers.PartTestTemplateSerializer
@ -945,6 +948,28 @@ class PartFilter(rest_filters.FilterSet):
else:
return queryset.filter(last_stocktake=None)
stock_to_build = rest_filters.BooleanFilter(label='Required for Build Order', method='filter_stock_to_build')
def filter_stock_to_build(self, queryset, name, value):
"""Filter the queryset based on whether part stock is required for a pending BuildOrder"""
if str2bool(value):
# Return parts which are required for a build order, but have not yet been allocated
return queryset.filter(required_for_build_orders__gt=F('allocated_to_build_orders'))
else:
# Return parts which are not required for a build order, or have already been allocated
return queryset.filter(required_for_build_orders__lte=F('allocated_to_build_orders'))
depleted_stock = rest_filters.BooleanFilter(label='Depleted Stock', method='filter_depleted_stock')
def filter_deployed_stock(self, queryset, name, value):
"""Filter the queryset based on whether the part is fully depleted of stock"""
if str2bool(value):
return queryset.filter(Q(in_stock=0) & ~Q(stock_item_count=0))
else:
return queryset.exclude(Q(in_stock=0) & ~Q(stock_item_count=0))
is_template = rest_filters.BooleanFilter()
assembly = rest_filters.BooleanFilter()
@ -1181,32 +1206,6 @@ class PartList(PartMixin, APIDownloadMixin, ListCreateAPI):
except (ValueError, PartCategory.DoesNotExist):
pass
# Filer by 'depleted_stock' status -> has no stock and stock items
depleted_stock = params.get('depleted_stock', None)
if depleted_stock is not None:
depleted_stock = str2bool(depleted_stock)
if depleted_stock:
queryset = queryset.filter(Q(in_stock=0) & ~Q(stock_item_count=0))
# Filter by "parts which need stock to complete build"
stock_to_build = params.get('stock_to_build', None)
# TODO: This is super expensive, database query wise...
# TODO: Need to figure out a cheaper way of making this filter query
if stock_to_build is not None:
# Get active builds
builds = Build.objects.filter(status__in=BuildStatusGroups.ACTIVE_CODES)
# Store parts with builds needing stock
parts_needed_to_complete_builds = []
# Filter required parts
for build in builds:
parts_needed_to_complete_builds += [part.pk for part in build.required_parts_to_complete_build]
queryset = queryset.filter(pk__in=parts_needed_to_complete_builds)
queryset = self.filter_parameteric_data(queryset)
return queryset

View File

@ -99,6 +99,28 @@ def annotate_total_stock(reference: str = ''):
)
def annotate_build_order_requirements(reference: str = ''):
"""Annotate the total quantity of each part required for build orders.
- Only interested in 'active' build orders
- We are looking for any BuildLine items which required this part (bom_item.sub_part)
- We are interested in the 'quantity' of each BuildLine item
"""
# Active build orders only
build_filter = Q(build__status__in=BuildStatusGroups.ACTIVE_CODES)
return Coalesce(
SubquerySum(
f'{reference}used_in__build_lines__quantity',
filter=build_filter,
),
Decimal(0),
output_field=models.DecimalField(),
)
def annotate_build_order_allocations(reference: str = ''):
"""Annotate the total quantity of each part allocated to build orders:
@ -112,7 +134,7 @@ def annotate_build_order_allocations(reference: str = ''):
"""
# Build filter only returns 'active' build orders
build_filter = Q(build__status__in=BuildStatusGroups.ACTIVE_CODES)
build_filter = Q(build_line__build__status__in=BuildStatusGroups.ACTIVE_CODES)
return Coalesce(
SubquerySum(

View File

@ -100,7 +100,7 @@
salable: true
purchaseable: false
category: 7
active: False
active: True
IPN: BOB
revision: A2
tree_id: 0

View File

@ -8,6 +8,7 @@ import common.settings
class Migration(migrations.Migration):
dependencies = [
('common', '0004_inventreesetting'),
('part', '0054_auto_20201109_1246'),
]

View File

@ -10,6 +10,7 @@ import re
from datetime import datetime, timedelta
from decimal import Decimal, InvalidOperation
from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.core.validators import MinLengthValidator, MinValueValidator
@ -1747,7 +1748,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel)
return pricing
def schedule_pricing_update(self, create: bool = False):
def schedule_pricing_update(self, create: bool = False, test: bool = False):
"""Helper function to schedule a pricing update.
Importantly, catches any errors which may occur during deletion of related objects,
@ -1757,6 +1758,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel)
Arguments:
create: Whether or not a new PartPricing object should be created if it does not already exist
test: Whether or not the pricing update is allowed during unit tests
"""
try:
@ -1768,7 +1770,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel)
pricing = self.pricing
if create or pricing.pk:
pricing.schedule_for_update()
pricing.schedule_for_update(test=test)
except IntegrityError:
# If this part instance has been deleted,
# some post-delete or post-save signals may still be fired
@ -2360,11 +2362,15 @@ class PartPricing(common.models.MetaMixin):
return result
def schedule_for_update(self, counter: int = 0):
def schedule_for_update(self, counter: int = 0, test: bool = False):
"""Schedule this pricing to be updated"""
import InvenTree.ready
# If we are running within CI, only schedule the update if the test flag is set
if settings.TESTING and not test:
return
# If importing data, skip pricing update
if InvenTree.ready.isImportingData():
return
@ -3720,7 +3726,7 @@ class BomItem(DataImportMixin, MetadataMixin, models.Model):
Includes:
- The referenced sub_part
- Any directly specvified substitute parts
- Any directly specified substitute parts
- If allow_variants is True, all variants of sub_part
"""
# Set of parts we will allow
@ -3741,11 +3747,6 @@ class BomItem(DataImportMixin, MetadataMixin, models.Model):
valid_parts = []
for p in parts:
# Inactive parts cannot be 'auto allocated'
if not p.active:
continue
# Trackable status must be the same as the sub_part
if p.trackable != self.sub_part.trackable:
continue
@ -3990,10 +3991,10 @@ class BomItem(DataImportMixin, MetadataMixin, models.Model):
# Base quantity requirement
base_quantity = self.quantity * build_quantity
# Overage requiremet
ovrg_quantity = self.get_overage_quantity(base_quantity)
# Overage requirement
overage_quantity = self.get_overage_quantity(base_quantity)
required = float(base_quantity) + float(ovrg_quantity)
required = float(base_quantity) + float(overage_quantity)
return required

View File

@ -410,8 +410,6 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize
partial = True
fields = [
'active',
'allocated_to_build_orders',
'allocated_to_sales_orders',
'assembly',
'barcode_hash',
'category',
@ -423,9 +421,6 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize
'description',
'full_name',
'image',
'in_stock',
'ordering',
'building',
'IPN',
'is_template',
'keywords',
@ -441,20 +436,28 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize
'revision',
'salable',
'starred',
'stock_item_count',
'suppliers',
'thumbnail',
'total_in_stock',
'trackable',
'unallocated_stock',
'units',
'variant_of',
'variant_stock',
'virtual',
'pricing_min',
'pricing_max',
'responsible',
# Annotated fields
'allocated_to_build_orders',
'allocated_to_sales_orders',
'building',
'in_stock',
'ordering',
'required_for_build_orders',
'stock_item_count',
'suppliers',
'total_in_stock',
'unallocated_stock',
'variant_stock',
# Fields only used for Part creation
'duplicate',
'initial_stock',
@ -553,6 +556,9 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize
),
)
# TODO: This could do with some refactoring
# TODO: Note that BomItemSerializer and BuildLineSerializer have very similar code
queryset = queryset.annotate(
ordering=part.filters.annotate_on_order_quantity(),
in_stock=part.filters.annotate_total_stock(),
@ -578,6 +584,11 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize
)
)
# Annotate with the total 'required for builds' quantity
queryset = queryset.annotate(
required_for_build_orders=part.filters.annotate_build_order_requirements(),
)
return queryset
def get_starred(self, part):
@ -587,17 +598,18 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize
# Extra detail for the category
category_detail = CategorySerializer(source='category', many=False, read_only=True)
# Calculated fields
# Annotated fields
allocated_to_build_orders = serializers.FloatField(read_only=True)
allocated_to_sales_orders = serializers.FloatField(read_only=True)
unallocated_stock = serializers.FloatField(read_only=True)
building = serializers.FloatField(read_only=True)
in_stock = serializers.FloatField(read_only=True)
variant_stock = serializers.FloatField(read_only=True)
total_in_stock = serializers.FloatField(read_only=True)
ordering = serializers.FloatField(read_only=True)
required_for_build_orders = serializers.IntegerField(read_only=True)
stock_item_count = serializers.IntegerField(read_only=True)
suppliers = serializers.IntegerField(read_only=True)
total_in_stock = serializers.FloatField(read_only=True)
unallocated_stock = serializers.FloatField(read_only=True)
variant_stock = serializers.FloatField(read_only=True)
image = InvenTree.serializers.InvenTreeImageSerializerField(required=False, allow_null=True)
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)

View File

@ -1997,10 +1997,14 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
bom_item = BomItem.objects.get(pk=6)
line = build.models.BuildLine.objects.get(
bom_item=bom_item,
build=bo,
)
# Allocate multiple stock items against this build order
build.models.BuildItem.objects.create(
build=bo,
bom_item=bom_item,
build_line=line,
stock_item=StockItem.objects.get(pk=1000),
quantity=10,
)
@ -2021,8 +2025,7 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
# Allocate further stock against the build
build.models.BuildItem.objects.create(
build=bo,
bom_item=bom_item,
build_line=line,
stock_item=StockItem.objects.get(pk=1001),
quantity=10,
)

View File

@ -144,7 +144,15 @@ class CategoryTest(TestCase):
self.assertEqual(self.electronics.partcount(), 3)
self.assertEqual(self.mechanical.partcount(), 9)
self.assertEqual(self.mechanical.partcount(active=True), 9)
# Mark one part as inactive and retry
part = Part.objects.get(pk=1)
part.active = False
part.save()
self.assertEqual(self.mechanical.partcount(active=True), 8)
self.assertEqual(self.mechanical.partcount(False), 7)
self.assertEqual(self.electronics.item_count, self.electronics.partcount())

View File

@ -444,11 +444,6 @@ class PartPricingTests(InvenTreeTestCase):
# Check that PartPricing objects have been created
self.assertEqual(part.models.PartPricing.objects.count(), 101)
# Check that background-tasks have been created
from django_q.models import OrmQ
self.assertEqual(OrmQ.objects.count(), 101)
def test_delete_part_with_stock_items(self):
"""Test deleting a part instance with stock items.
@ -473,6 +468,9 @@ class PartPricingTests(InvenTreeTestCase):
purchase_price=Money(10, 'USD')
)
# Manually schedule a pricing update (does not happen automatically in testing)
p.schedule_pricing_update(create=True, test=True)
# Check that a PartPricing object exists
self.assertTrue(part.models.PartPricing.objects.filter(part=p).exists())
@ -483,5 +481,5 @@ class PartPricingTests(InvenTreeTestCase):
self.assertFalse(part.models.PartPricing.objects.filter(part=p).exists())
# Try to update pricing (should fail gracefully as the Part has been deleted)
p.schedule_pricing_update(create=False)
p.schedule_pricing_update(create=False, test=True)
self.assertFalse(part.models.PartPricing.objects.filter(part=p).exists())

View File

@ -95,10 +95,14 @@ def allow_table_event(table_name):
We *do not* want events to be fired for some tables!
"""
# Prevent table events during the data import process
if isImportingData():
# Prevent table events during the data import process
return False # pragma: no cover
# Prevent table events when in testing mode (saves a lot of time)
if settings.TESTING:
return False
table_name = table_name.lower().strip()
# Ignore any tables which start with these prefixes

View File

@ -392,6 +392,8 @@ class BuildReport(ReportTemplateBase):
return {
'build': my_build,
'part': my_build.part,
'build_outputs': my_build.build_outputs.all(),
'line_items': my_build.build_lines.all(),
'bom_items': my_build.part.get_bom_items(),
'reference': my_build.reference,
'quantity': my_build.quantity,

View File

@ -21,7 +21,7 @@ import stock.serializers as StockSerializers
from build.models import Build
from build.serializers import BuildSerializer
from company.models import Company, SupplierPart
from company.serializers import CompanySerializer, SupplierPartSerializer
from company.serializers import CompanySerializer
from generic.states import StatusView
from InvenTree.api import (APIDownloadMixin, AttachmentMixin,
ListCreateDestroyAPIView, MetadataView)
@ -553,6 +553,28 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView):
queryset = StockItem.objects.all()
filterset_class = StockFilter
def get_serializer(self, *args, **kwargs):
"""Set context before returning serializer.
Extra detail may be provided to the serializer via query parameters:
- part_detail: Include detail about the StockItem's part
- location_detail: Include detail about the StockItem's location
- supplier_part_detail: Include detail about the StockItem's supplier_part
- tests: Include detail about the StockItem's test results
"""
try:
params = self.request.query_params
for key in ['part_detail', 'location_detail', 'supplier_part_detail', 'tests']:
kwargs[key] = str2bool(params.get(key, False))
except AttributeError:
pass
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
def get_serializer_context(self):
"""Extend serializer context."""
ctx = super().get_serializer_context()
@ -743,8 +765,6 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView):
"""
queryset = self.filter_queryset(self.get_queryset())
params = request.query_params
page = self.paginate_queryset(queryset)
if page is not None:
@ -754,78 +774,6 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView):
data = serializer.data
# Keep track of which related models we need to query
location_ids = set()
part_ids = set()
supplier_part_ids = set()
# Iterate through each StockItem and grab some data
for item in data:
loc = item['location']
if loc:
location_ids.add(loc)
part = item['part']
if part:
part_ids.add(part)
sp = item['supplier_part']
if sp:
supplier_part_ids.add(sp)
# Do we wish to include Part detail?
if str2bool(params.get('part_detail', False)):
# Fetch only the required Part objects from the database
parts = Part.objects.filter(pk__in=part_ids).prefetch_related(
'category',
)
part_map = {}
for part in parts:
part_map[part.pk] = PartBriefSerializer(part).data
# Now update each StockItem with the related Part data
for stock_item in data:
part_id = stock_item['part']
stock_item['part_detail'] = part_map.get(part_id, None)
# Do we wish to include SupplierPart detail?
if str2bool(params.get('supplier_part_detail', False)):
supplier_parts = SupplierPart.objects.filter(pk__in=supplier_part_ids)
supplier_part_map = {}
for part in supplier_parts:
supplier_part_map[part.pk] = SupplierPartSerializer(part).data
for stock_item in data:
part_id = stock_item['supplier_part']
stock_item['supplier_part_detail'] = supplier_part_map.get(part_id, None)
# Do we wish to include StockLocation detail?
if str2bool(params.get('location_detail', False)):
# Fetch only the required StockLocation objects from the database
locations = StockLocation.objects.filter(pk__in=location_ids).prefetch_related(
'parent',
'children',
)
location_map = {}
# Serialize each StockLocation object
for location in locations:
location_map[location.pk] = StockSerializers.LocationBriefSerializer(location).data
# Now update each StockItem with the related StockLocation data
for stock_item in data:
loc_id = stock_item['location']
stock_item['location_detail'] = location_map.get(loc_id, None)
"""
Determine the response type based on the request.
a) For HTTP requests (e.g. via the browsable API) return a DRF response
@ -852,6 +800,7 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView):
'part',
'part__category',
'location',
'test_results',
'tags',
)

View File

@ -8,6 +8,7 @@ import common.settings
class Migration(migrations.Migration):
dependencies = [
('common', '0004_inventreesetting'),
('stock', '0052_stockitem_is_building'),
]

View File

@ -4,6 +4,22 @@ import InvenTree.fields
from django.db import migrations
import djmoney.models.fields
from django.db.migrations.recorder import MigrationRecorder
def show_migrations(apps, schema_editor):
"""Show the latest migrations from each app"""
for app in apps.get_app_configs():
label = app.label
migrations = MigrationRecorder.Migration.objects.filter(app=app).order_by('-applied')[:5]
print(f"{label} migrations:")
for m in migrations:
print(f" - {m.name}")
class Migration(migrations.Migration):
@ -11,7 +27,13 @@ class Migration(migrations.Migration):
('stock', '0064_auto_20210621_1724'),
]
operations = [
operations = []
xoperations = [
migrations.RunPython(
code=show_migrations,
reverse_code=migrations.RunPython.noop
),
migrations.AlterField(
model_name='stockitem',
name='purchase_price',

View File

@ -44,6 +44,50 @@ class LocationBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
]
class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for the StockItemTestResult model."""
class Meta:
"""Metaclass options."""
model = StockItemTestResult
fields = [
'pk',
'stock_item',
'key',
'test',
'result',
'value',
'attachment',
'notes',
'user',
'user_detail',
'date'
]
read_only_fields = [
'pk',
'user',
'date',
]
def __init__(self, *args, **kwargs):
"""Add detail fields."""
user_detail = kwargs.pop('user_detail', False)
super().__init__(*args, **kwargs)
if user_detail is not True:
self.fields.pop('user_detail')
user_detail = InvenTree.serializers.UserSerializer(source='user', read_only=True)
key = serializers.CharField(read_only=True)
attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField(required=False)
class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer):
"""Brief serializers for a StockItem."""
@ -126,6 +170,7 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
'purchase_price',
'purchase_price_currency',
'use_pack_size',
'tests',
'tags',
]
@ -234,11 +279,11 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
status_text = serializers.CharField(source='get_status_display', read_only=True)
# Optional detail fields, which can be appended via query parameters
supplier_part_detail = SupplierPartSerializer(source='supplier_part', many=False, read_only=True)
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
location_detail = LocationBriefSerializer(source='location', many=False, read_only=True)
tests = StockItemTestResultSerializer(source='test_results', many=True, read_only=True)
quantity = InvenTreeDecimalField()
@ -266,18 +311,22 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
part_detail = kwargs.pop('part_detail', False)
location_detail = kwargs.pop('location_detail', False)
supplier_part_detail = kwargs.pop('supplier_part_detail', False)
tests = kwargs.pop('tests', False)
super(StockItemSerializer, self).__init__(*args, **kwargs)
if part_detail is not True:
if not part_detail:
self.fields.pop('part_detail')
if location_detail is not True:
if not location_detail:
self.fields.pop('location_detail')
if supplier_part_detail is not True:
if not supplier_part_detail:
self.fields.pop('supplier_part_detail')
if not tests:
self.fields.pop('tests')
class SerializeStockItemSerializer(serializers.Serializer):
"""A DRF serializer for "serializing" a StockItem.
@ -653,50 +702,6 @@ class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSer
])
class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for the StockItemTestResult model."""
class Meta:
"""Metaclass options."""
model = StockItemTestResult
fields = [
'pk',
'stock_item',
'key',
'test',
'result',
'value',
'attachment',
'notes',
'user',
'user_detail',
'date'
]
read_only_fields = [
'pk',
'user',
'date',
]
def __init__(self, *args, **kwargs):
"""Add detail fields."""
user_detail = kwargs.pop('user_detail', False)
super().__init__(*args, **kwargs)
if user_detail is not True:
self.fields.pop('user_detail')
user_detail = InvenTree.serializers.UserSerializer(source='user', read_only=True)
key = serializers.CharField(read_only=True)
attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField(required=False)
class StockTrackingSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for StockItemTracking model."""

View File

@ -972,7 +972,7 @@ function loadBomTable(table, options={}) {
}
if (row.overage) {
text += `<small> (${row.overage}) </small>`;
text += `<small> (+${row.overage})</small>`;
}
return text;
@ -1161,6 +1161,8 @@ function loadBomTable(table, options={}) {
}
}
text = renderLink(text, url);
if (row.on_order && row.on_order > 0) {
text += makeIconBadge(
'fa-shopping-cart',
@ -1168,7 +1170,7 @@ function loadBomTable(table, options={}) {
);
}
return renderLink(text, url);
return text;
}
});

File diff suppressed because it is too large Load Diff

View File

@ -1727,12 +1727,12 @@ function loadSalesOrderLineItemTable(table, options={}) {
options.params = options.params || {};
if (!options.order) {
console.error('function called without order ID');
console.error('loadSalesOrderLineItemTable called without order ID');
return;
}
if (!options.status) {
console.error('function called without order status');
console.error('loadSalesOrderLineItemTable called without order status');
return;
}

View File

@ -480,8 +480,8 @@ function getBuildTableFilters() {
}
// Return a dictionary of filters for the "build item" table
function getBuildItemTableFilters() {
// Return a dictionary of filters for the "build lines" table
function getBuildLineTableFilters() {
return {
allocated: {
type: 'bool',
@ -491,6 +491,10 @@ function getBuildItemTableFilters() {
type: 'bool',
title: '{% trans "Available" %}',
},
tracked: {
type: 'bool',
title: '{% trans "Tracked" %}',
},
consumable: {
type: 'bool',
title: '{% trans "Consumable" %}',
@ -771,8 +775,8 @@ function getAvailableTableFilters(tableKey) {
return getBOMTableFilters();
case 'build':
return getBuildTableFilters();
case 'builditems':
return getBuildItemTableFilters();
case 'buildlines':
return getBuildLineTableFilters();
case 'location':
return getStockLocationFilters();
case 'parameters':

View File

@ -131,6 +131,7 @@ class RuleSet(models.Model):
'part_bomitemsubstitute',
'build_build',
'build_builditem',
'build_buildline',
'build_buildorderattachment',
'stock_stockitem',
'stock_stocklocation',

View File

@ -82,9 +82,9 @@ Stock can be manually allocated to the build as required, using the *Allocate st
Stock allocations can be manually adjusted or deleted using the action buttons available in each row of the allocation table.
### Unallocate Stock
### Deallocate Stock
The *Unallocate Stock* button can be used to remove all allocations of untracked stock items against the build order.
The *Deallocate Stock* button can be used to remove all allocations of untracked stock items against the build order.
## Automatic Stock Allocation

View File

@ -23,12 +23,6 @@ A BOM for a particular assembly is comprised of a number (zero or more) of BOM "
| Optional | A boolean field which indicates if this BOM Line Item is "optional" |
| Note | Optional note field for additional information
!!! missing "Overage"
While the overage field exists, it is currently non-functional and has no effect on BOM operation
!!! missing "Optional"
The Optional field is currently for indication only - it does not serve a functional purpose (yet)
### Consumable BOM Line Items
If a BOM line item is marked as *consumable*, this means that while the part and quantity information is tracked in the BOM, this line item does not get allocated to a [Build Order](./build.md). This may be useful for certain items that the user does not wish to track through the build process, as they may be low value, in abundant stock, or otherwise complicated to track.

View File

@ -26,7 +26,7 @@ To navigate to the Build Order display, select *Build* from the main navigation
{% include "img.html" %}
{% endwith %}
#### Tree Vieww
#### Tree View
*Tree View* also provides a tabulated view of Build Orders. Orders are displayed in a hierarchical manner, showing any parent / child relationships between different build orders.

View File

@ -18,8 +18,11 @@ In addition to the default report context variables, the following context varia
| --- | --- |
| build | The build object the report is being generated against |
| part | The [Part](./context_variables.md#part) object that the build references |
| line_items | A shortcut for [build.line_items](#build) |
| bom_items | A shortcut for [build.bom_items](#build) |
| build_outputs | A shortcut for [build.build_outputs](#build) |
| reference | The build order reference string |
| quantity | Build order quantity |
| quantity | Build order quantity (number of assemblies being built) |
#### build
@ -29,7 +32,9 @@ The following variables are accessed by build.variable
| --- | --- |
| active | Boolean that tells if the build is active |
| batch | Batch code transferred to build parts (optional) |
| bom_items | A query set with all BOM items for the build |
| line_items | A query set with all the build line items associated with the build |
| bom_items | A query set with all BOM items for the part being assembled |
| build_outputs | A queryset containing all build output ([Stock Item](../stock/stock.md)) objects associated with this build |
| can_complete | Boolean that tells if the build can be completed. Means: All material allocated and all parts have been build. |
| are_untracked_parts_allocated | Boolean that tells if all bom_items have allocated stock_items. |
| creation_date | Date where the build has been created |
@ -42,7 +47,8 @@ The following variables are accessed by build.variable
| notes | Text notes |
| parent | Reference to a parent build object if this is a sub build |
| part | The [Part](./context_variables.md#part) to be built (from component BOM items) |
| quantity | Build order quantity |
| quantity | Build order quantity (total number of assembly outputs) |
| completed | The number out outputs which have been completed |
| reference | Build order reference (required, must be unique) |
| required_parts | A query set with all parts that are required for the build |
| responsible | Owner responsible for completing the build. This can be a user or a group. Depending on that further context variables differ |
@ -59,19 +65,38 @@ The following variables are accessed by build.variable
As usual items in a query sets can be selected by adding a .n to the set e.g. build.required_parts.0
will result in the first part of the list. Each query set has again its own context variables.
#### line_items
The `line_items` variable is a list of all build line items associated with the selected build. The following attributes are available for each individual line_item instance:
| Attribute | Description |
| --- | --- |
| .build | A reference back to the parent build order |
| .bom_item | A reference to the BOMItem which defines this line item |
| .quantity | The required quantity which is to be allocated against this line item |
| .part | A shortcut for .bom_item.sub_part |
| .allocations | A list of BuildItem objects which allocate stock items against this line item |
| .allocated_quantity | The total stock quantity which has been allocated against this line |
| .unallocated_quantity | The remaining quantity to allocate |
| .is_fully_allocated | Boolean value, returns True if the line item has sufficient stock allocated against it |
| .is_overallocated | Boolean value, returns True if the line item has more allocated stock than is required |
#### bom_items
| Variable | Description |
| Attribute | Description |
| --- | --- |
| .reference | The reference designators of the components |
| .quantity | The number of components |
| .quantity | The number of components required to build |
| .overage | The extra amount required to assembly |
| .consumable | Boolean field, True if this is a "consumable" part which is not tracked through builds |
| .sub_part | The part at this position |
| .substitutes.all | A query set with all allowed substitutes for that part |
| .note | Extra text field which can contain additional information |
#### allocated_stock.all
| Variable | Description |
| Attribute | Description |
| --- | --- |
| .bom_item | The bom item where this part belongs to |
| .stock_item | The allocated [StockItem](./context_variables.md#stockitem) |