mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-17 04:25:42 +00:00
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:
@ -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)
|
||||
|
@ -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([
|
||||
|
28
InvenTree/build/migrations/0043_buildline.py
Normal file
28
InvenTree/build/migrations/0043_buildline.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
97
InvenTree/build/migrations/0044_auto_20230528_1410.py
Normal file
97
InvenTree/build/migrations/0044_auto_20230528_1410.py
Normal 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,
|
||||
),
|
||||
]
|
19
InvenTree/build/migrations/0045_builditem_build_line.py
Normal file
19
InvenTree/build/migrations/0045_builditem_build_line.py
Normal 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'),
|
||||
),
|
||||
]
|
95
InvenTree/build/migrations/0046_auto_20230606_1033.py
Normal file
95
InvenTree/build/migrations/0046_auto_20230606_1033.py
Normal 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,
|
||||
)
|
||||
]
|
26
InvenTree/build/migrations/0047_auto_20230606_1058.py
Normal file
26
InvenTree/build/migrations/0047_auto_20230606_1058.py
Normal 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',
|
||||
),
|
||||
]
|
@ -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(
|
||||
|
@ -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."""
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user