2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-17 04:25:42 +00:00

[Feature] Scrap Build Outputs (#4800)

* Update docs for status codes

* Adds API endpoint for scrapping individual build outputs

* Support 'buildorder' reference in stock tracking history

* Add page for build output documentation

* Build docs

* Add example build order process to docs

* remove debug statement

* JS lint cleanup

* Add migration file for stock status

* Add unit tests for build output scrapping

* Increment API version

* bug fix
This commit is contained in:
Oliver
2023-05-13 22:19:35 +10:00
committed by GitHub
parent 634daa2161
commit b2ceac2c4a
39 changed files with 794 additions and 254 deletions

View File

@ -276,6 +276,19 @@ class BuildOutputCreate(BuildOrderContextMixin, CreateAPI):
serializer_class = build.serializers.BuildOutputCreateSerializer
class BuildOutputScrap(BuildOrderContextMixin, CreateAPI):
"""API endpoint for scrapping build output(s)."""
queryset = Build.objects.none()
serializer_class = build.serializers.BuildOutputScrapSerializer
def get_serializer_context(self):
"""Add extra context information to the endpoint serializer."""
ctx = super().get_serializer_context()
ctx['to_complete'] = False
return ctx
class BuildOutputComplete(BuildOrderContextMixin, CreateAPI):
"""API endpoint for completing build outputs."""
@ -489,6 +502,7 @@ build_api_urls = [
re_path(r'^complete/', BuildOutputComplete.as_view(), name='api-build-output-complete'),
re_path(r'^create-output/', BuildOutputCreate.as_view(), name='api-build-output-create'),
re_path(r'^delete-outputs/', BuildOutputDelete.as_view(), name='api-build-output-delete'),
re_path(r'^scrap-outputs/', BuildOutputScrap.as_view(), name='api-build-output-scrap'),
re_path(r'^finish/', BuildFinish.as_view(), name='api-build-finish'),
re_path(r'^cancel/', BuildCancel.as_view(), name='api-build-cancel'),
re_path(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),

View File

@ -620,6 +620,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
location: Override location
auto_allocate: Automatically allocate stock with matching serial numbers
"""
user = kwargs.get('user', None)
batch = kwargs.get('batch', self.batch)
location = kwargs.get('location', self.destination)
serials = kwargs.get('serials', None)
@ -630,6 +631,24 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
or multiple outputs (with quantity = 1)
"""
def _add_tracking_entry(output, user):
"""Helper function to add a tracking entry to the newly created output"""
deltas = {
'quantity': float(output.quantity),
'buildorder': self.pk,
}
if output.batch:
deltas['batch'] = output.batch
if output.serial:
deltas['serial'] = output.serial
if output.location:
deltas['location'] = output.location.pk
output.add_tracking_entry(StockHistoryCode.BUILD_OUTPUT_CREATED, user, deltas)
multiple = False
# Serial numbers are provided? We need to split!
@ -663,6 +682,8 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
is_building=True,
)
_add_tracking_entry(output, user)
if auto_allocate and serial is not None:
# Get a list of BomItem objects which point to "trackable" parts
@ -695,7 +716,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
else:
"""Create a single build output of the given quantity."""
stock.models.StockItem.objects.create(
output = stock.models.StockItem.objects.create(
quantity=quantity,
location=location,
part=self.part,
@ -704,6 +725,8 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
is_building=True
)
_add_tracking_entry(output, user)
if self.status == BuildStatus.PENDING:
self.status = BuildStatus.PRODUCTION
self.save()
@ -773,6 +796,50 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
# Delete allocation
items.all().delete()
@transaction.atomic
def scrap_build_output(self, output, location, **kwargs):
"""Mark a particular build output as scrapped / rejected
- Mark the output as "complete"
- *Do Not* update the "completed" count for this order
- Set the item status to "scrapped"
- Add a transaction entry to the stock item history
"""
if not output:
raise ValidationError(_("No build output specified"))
user = kwargs.get('user', None)
notes = kwargs.get('notes', '')
discard_allocations = kwargs.get('discard_allocations', False)
# Update build output item
output.is_building = False
output.status = StockStatus.REJECTED
output.location = location
output.save(add_note=False)
allocated_items = output.items_to_install.all()
# Complete or discard allocations
for build_item in allocated_items:
if not discard_allocations:
build_item.complete_allocation(user)
# Delete allocations
allocated_items.delete()
output.add_tracking_entry(
StockHistoryCode.BUILD_OUTPUT_REJECTED,
user,
notes=notes,
deltas={
'location': location.pk,
'status': StockStatus.REJECTED,
'buildorder': self.pk,
}
)
@transaction.atomic
def complete_build_output(self, output, user, **kwargs):
"""Complete a particular build output.
@ -801,15 +868,21 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
output.location = location
output.status = status
output.save()
output.save(add_note=False)
deltas = {
'status': status,
'buildorder': self.pk
}
if location:
deltas['location'] = location.pk
output.add_tracking_entry(
StockHistoryCode.BUILD_OUTPUT_COMPLETED,
user,
notes=notes,
deltas={
'status': status,
}
deltas=deltas
)
# Increase the completed quantity for this build

View File

@ -302,12 +302,14 @@ class BuildOutputCreateSerializer(serializers.Serializer):
auto_allocate = data.get('auto_allocate', False)
build = self.get_build()
user = self.context['request'].user
build.create_build_output(
quantity,
serials=self.serials,
batch=batch_code,
auto_allocate=auto_allocate,
user=user,
)
@ -349,6 +351,76 @@ class BuildOutputDeleteSerializer(serializers.Serializer):
build.delete_output(output)
class BuildOutputScrapSerializer(serializers.Serializer):
"""DRF serializer for scrapping one or more build outputs"""
class Meta:
"""Serializer metaclass"""
fields = [
'outputs',
'location',
'notes',
]
outputs = BuildOutputSerializer(
many=True,
required=True,
)
location = serializers.PrimaryKeyRelatedField(
queryset=StockLocation.objects.all(),
many=False,
allow_null=False,
required=True,
label=_('Location'),
help_text=_('Stock location for scrapped outputs'),
)
discard_allocations = serializers.BooleanField(
required=False,
default=False,
label=_('Discard Allocations'),
help_text=_('Discard any stock allocations for scrapped outputs'),
)
notes = serializers.CharField(
label=_('Notes'),
help_text=_('Reason for scrapping build output(s)'),
required=True,
allow_blank=False,
)
def validate(self, data):
"""Perform validation on the serializer data"""
super().validate(data)
outputs = data.get('outputs', [])
if len(outputs) == 0:
raise ValidationError(_("A list of build outputs must be provided"))
return data
def save(self):
"""Save the serializer to scrap the build outputs"""
build = self.context['build']
request = self.context['request']
data = self.validated_data
outputs = data.get('outputs', [])
# Scrap the build outputs
with transaction.atomic():
for item in outputs:
output = item['output']
build.scrap_build_output(
output,
data.get('location', None),
user=request.user,
notes=data.get('notes', ''),
discard_allocations=data.get('discard_allocations', False)
)
class BuildOutputCompleteSerializer(serializers.Serializer):
"""DRF serializer for completing one or more build outputs."""

View File

@ -261,6 +261,11 @@
<span class='fas fa-check-circle icon-green'></span> {% trans "Complete outputs" %}
</a></li>
{% endif %}
{% if roles.build.change %}
<li><a class='dropdown-item' href='#' id='multi-output-scrap' title='{% trans "Scrap selected build outputs" %}'>
<span class='fas fa-times-circle icon-red'></span> {% trans "Scrap outputs" %}
</a></li>
{% endif %}
{% if roles.build.delete %}
<li><a class='dropdown-item' href='#' id='multi-output-delete' title='{% trans "Delete selected build outputs" %}'>
<span class='fas fa-trash-alt icon-red'></span> {% trans "Delete outputs" %}

View File

@ -10,7 +10,7 @@ from part.models import Part
from build.models import Build, BuildItem
from stock.models import StockItem
from InvenTree.status_codes import BuildStatus
from InvenTree.status_codes import BuildStatus, StockStatus
from InvenTree.api_tester import InvenTreeAPITestCase
@ -924,3 +924,127 @@ class BuildListTest(BuildAPITest):
builds = response.data
self.assertEqual(len(builds), 20)
class BuildOutputScrapTest(BuildAPITest):
"""Unit tests for scrapping build outputs"""
def scrap(self, build_id, data, expected_code=None):
"""Helper method to POST to the scrap API"""
url = reverse('api-build-output-scrap', kwargs={'pk': build_id})
response = self.post(url, data, expected_code=expected_code)
return response.data
def test_invalid_scraps(self):
"""Test that invalid scrap attempts are rejected"""
# Test with missing required fields
response = self.scrap(1, {}, expected_code=400)
for field in ['outputs', 'location', 'notes']:
self.assertIn('This field is required', str(response[field]))
# Scrap with no outputs specified
response = self.scrap(
1,
{
'outputs': [],
'location': 1,
'notes': 'Should fail',
}
)
self.assertIn('A list of build outputs must be provided', str(response))
# Scrap with an invalid output ID
response = self.scrap(
1,
{
'outputs': [
{
'output': 9999,
}
],
'location': 1,
'notes': 'Should fail',
},
expected_code=400
)
self.assertIn('object does not exist', str(response['outputs']))
# Create a build output, for a different build
build = Build.objects.get(pk=2)
output = StockItem.objects.create(
part=build.part,
quantity=10,
batch='BATCH-TEST',
is_building=True,
build=build,
)
response = self.scrap(
1,
{
'outputs': [
{
'output': output.pk,
},
],
'location': 1,
'notes': 'Should fail',
},
expected_code=400
)
self.assertIn("Build output does not match the parent build", str(response['outputs']))
def test_valid_scraps(self):
"""Test that valid scrap attempts succeed"""
# Create a build output
build = Build.objects.get(pk=1)
for _ in range(3):
build.create_build_output(2)
outputs = build.build_outputs.all()
self.assertEqual(outputs.count(), 3)
self.assertEqual(StockItem.objects.filter(build=build).count(), 3)
for output in outputs:
self.assertEqual(output.status, StockStatus.OK)
self.assertTrue(output.is_building)
# Scrap all three outputs
self.scrap(
1,
{
'outputs': [
{
'output': outputs[0].pk,
},
{
'output': outputs[1].pk,
},
{
'output': outputs[2].pk,
},
],
'location': 1,
'notes': 'Should succeed',
},
expected_code=201
)
# There should still be three outputs associated with this build
self.assertEqual(StockItem.objects.filter(build=build).count(), 3)
for output in outputs:
output.refresh_from_db()
self.assertEqual(output.status, StockStatus.REJECTED)
self.assertFalse(output.is_building)