Adds ability to partially scrap build outputs (#4846)

* BuildOrder updates:

- Use batch code generation when creating a new build output
- Allow partial scrapping of build outputs

* Fixes for stock table

* Bump API version

* Update unit tests
This commit is contained in:
Oliver 2023-05-18 14:04:57 +10:00 committed by GitHub
parent 120a710ad4
commit 327381357b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 107 additions and 13 deletions

View File

@ -2,11 +2,14 @@
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 113 INVENTREE_API_VERSION = 115
""" """
Increment this API version number whenever there is a significant change to the API that any clients need to know about Increment this API version number whenever there is a significant change to the API that any clients need to know about
v115 - > 2023-05-18 : https://github.com/inventree/InvenTree/pull/4846
- Adds ability to partially scrap a build output
v114 -> 2023-05-16 : https://github.com/inventree/InvenTree/pull/4825 v114 -> 2023-05-16 : https://github.com/inventree/InvenTree/pull/4825
- Adds "delivery_date" to shipments - Adds "delivery_date" to shipments

View File

@ -797,7 +797,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
items.all().delete() items.all().delete()
@transaction.atomic @transaction.atomic
def scrap_build_output(self, output, location, **kwargs): def scrap_build_output(self, output, quantity, location, **kwargs):
"""Mark a particular build output as scrapped / rejected """Mark a particular build output as scrapped / rejected
- Mark the output as "complete" - Mark the output as "complete"
@ -809,10 +809,25 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
if not output: if not output:
raise ValidationError(_("No build output specified")) raise ValidationError(_("No build output specified"))
if quantity <= 0:
raise ValidationError({
'quantity': _("Quantity must be greater than zero")
})
if quantity > output.quantity:
raise ValidationError({
'quantity': _("Quantity cannot be greater than the output quantity")
})
user = kwargs.get('user', None) user = kwargs.get('user', None)
notes = kwargs.get('notes', '') notes = kwargs.get('notes', '')
discard_allocations = kwargs.get('discard_allocations', False) discard_allocations = kwargs.get('discard_allocations', False)
if quantity < output.quantity:
# Split output into two items
output = output.splitStock(quantity, location=location, user=user)
output.build = self
# Update build output item # Update build output item
output.is_building = False output.is_building = False
output.status = StockStatus.REJECTED output.status = StockStatus.REJECTED

View File

@ -17,7 +17,7 @@ import InvenTree.helpers
from InvenTree.serializers import InvenTreeDecimalField from InvenTree.serializers import InvenTreeDecimalField
from InvenTree.status_codes import StockStatus from InvenTree.status_codes import StockStatus
from stock.models import StockItem, StockLocation from stock.models import generate_batch_code, StockItem, StockLocation
from stock.serializers import StockItemSerializerBrief, LocationSerializer from stock.serializers import StockItemSerializerBrief, LocationSerializer
from part.models import BomItem from part.models import BomItem
@ -181,6 +181,45 @@ class BuildOutputSerializer(serializers.Serializer):
return output return output
class BuildOutputQuantitySerializer(BuildOutputSerializer):
"""Serializer for a single build output, with additional quantity field"""
class Meta:
"""Serializer metaclass"""
fields = BuildOutputSerializer.Meta.fields + [
'quantity',
]
quantity = serializers.DecimalField(
max_digits=15,
decimal_places=5,
min_value=0,
required=True,
label=_('Quantity'),
help_text=_('Enter quantity for build output'),
)
def validate(self, data):
"""Validate the serializer data"""
data = super().validate(data)
output = data.get('output')
quantity = data.get('quantity')
if quantity <= 0:
raise ValidationError({
'quantity': _('Quantity must be greater than zero')
})
if quantity > output.quantity:
raise ValidationError({
'quantity': _("Quantity cannot be greater than the output quantity")
})
return data
class BuildOutputCreateSerializer(serializers.Serializer): class BuildOutputCreateSerializer(serializers.Serializer):
"""Serializer for creating a new BuildOutput against a BuildOrder. """Serializer for creating a new BuildOutput against a BuildOrder.
@ -226,6 +265,7 @@ class BuildOutputCreateSerializer(serializers.Serializer):
batch_code = serializers.CharField( batch_code = serializers.CharField(
required=False, required=False,
allow_blank=True, allow_blank=True,
default=generate_batch_code,
label=_('Batch Code'), label=_('Batch Code'),
help_text=_('Batch code for this build output'), help_text=_('Batch code for this build output'),
) )
@ -362,7 +402,7 @@ class BuildOutputScrapSerializer(serializers.Serializer):
'notes', 'notes',
] ]
outputs = BuildOutputSerializer( outputs = BuildOutputQuantitySerializer(
many=True, many=True,
required=True, required=True,
) )
@ -412,8 +452,10 @@ class BuildOutputScrapSerializer(serializers.Serializer):
with transaction.atomic(): with transaction.atomic():
for item in outputs: for item in outputs:
output = item['output'] output = item['output']
quantity = item['quantity']
build.scrap_build_output( build.scrap_build_output(
output, output,
quantity,
data.get('location', None), data.get('location', None),
user=request.user, user=request.user,
notes=data.get('notes', ''), notes=data.get('notes', ''),

View File

@ -302,7 +302,7 @@
</div> </div>
<div class='panel-content'> <div class='panel-content'>
{% include "stock_table.html" with read_only=True prefix="build-" %} {% include "stock_table.html" with prefix="build-" %}
</div> </div>
</div> </div>
@ -343,6 +343,7 @@
onPanelLoad('consumed', function() { onPanelLoad('consumed', function() {
loadStockTable($('#consumed-stock-table'), { loadStockTable($('#consumed-stock-table'), {
filterTarget: '#filter-list-consumed-stock',
params: { params: {
location_detail: true, location_detail: true,
part_detail: true, part_detail: true,
@ -354,6 +355,7 @@ onPanelLoad('consumed', function() {
onPanelLoad('completed', function() { onPanelLoad('completed', function() {
loadStockTable($("#build-stock-table"), { loadStockTable($("#build-stock-table"), {
filterTarget: '#filter-list-build-stock',
params: { params: {
location_detail: true, location_detail: true,
part_detail: true, part_detail: true,

View File

@ -1027,12 +1027,15 @@ class BuildOutputScrapTest(BuildAPITest):
'outputs': [ 'outputs': [
{ {
'output': outputs[0].pk, 'output': outputs[0].pk,
'quantity': outputs[0].quantity,
}, },
{ {
'output': outputs[1].pk, 'output': outputs[1].pk,
'quantity': outputs[1].quantity,
}, },
{ {
'output': outputs[2].pk, 'output': outputs[2].pk,
'quantity': outputs[2].quantity,
}, },
], ],
'location': 1, 'location': 1,

View File

@ -1575,7 +1575,7 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo
self.save() self.save()
@transaction.atomic @transaction.atomic
def splitStock(self, quantity, location, user, **kwargs): def splitStock(self, quantity, location=None, user=None, **kwargs):
"""Split this stock item into two items, in the same location. """Split this stock item into two items, in the same location.
Stock tracking notes for this StockItem will be duplicated, Stock tracking notes for this StockItem will be duplicated,
@ -1585,9 +1585,11 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo
quantity: Number of stock items to remove from this entity, and pass to the next quantity: Number of stock items to remove from this entity, and pass to the next
location: Where to move the new StockItem to location: Where to move the new StockItem to
Notes: Returns:
The provided quantity will be subtracted from this item and given to the new one. The new StockItem object
The new item will have a different StockItem ID, while this will remain the same.
- The provided quantity will be subtracted from this item and given to the new one.
- The new item will have a different StockItem ID, while this will remain the same.
""" """
notes = kwargs.get('notes', '') notes = kwargs.get('notes', '')

View File

@ -1 +1 @@
<div class='filter-list d-flex flex-row form-row' id='filter-list-{{ id }}'><!-- Empty div for table filters --></div> <div class='filter-list d-flex flex-row form-row' id='filter-list-{% if prefix %}{{ prefix }}{% endif %}{{ id }}'><!-- Empty div for table filters --></div>

View File

@ -463,7 +463,7 @@ function unallocateStock(build_id, options={}) {
/* /*
* Helper function to render a single build output in a modal form * Helper function to render a single build output in a modal form
*/ */
function renderBuildOutput(output, opts={}) { function renderBuildOutput(output, options={}) {
let pk = output.pk; let pk = output.pk;
let output_html = imageHoverIcon(output.part_detail.thumbnail); let output_html = imageHoverIcon(output.part_detail.thumbnail);
@ -494,10 +494,31 @@ function renderBuildOutput(output, opts={}) {
} }
); );
let quantity_field = '';
if (options.adjust_quantity) {
quantity_field = constructField(
`outputs_quantity_${pk}`,
{
type: 'decimal',
value: output.quantity,
min_value: 0,
max_value: output.quantity,
required: true,
},
{
hideLabels: true,
}
);
quantity_field = `<td>${quantity_field}</td>`;
}
let html = ` let html = `
<tr id='output_row_${pk}'> <tr id='output_row_${pk}'>
<td>${field}</td> <td>${field}</td>
<td>${output.part_detail.full_name}</td> <td>${output.part_detail.full_name}</td>
${quantity_field}
<td>${buttons}</td> <td>${buttons}</td>
</tr>`; </tr>`;
@ -645,7 +666,9 @@ function scrapBuildOutputs(build_id, outputs, options={}) {
let table_entries = ''; let table_entries = '';
outputs.forEach(function(output) { outputs.forEach(function(output) {
table_entries += renderBuildOutput(output); table_entries += renderBuildOutput(output, {
adjust_quantity: true,
});
}); });
var html = ` var html = `
@ -660,6 +683,7 @@ function scrapBuildOutputs(build_id, outputs, options={}) {
<table class='table table-striped table-condensed' id='build-scrap-table'> <table class='table table-striped table-condensed' id='build-scrap-table'>
<thead> <thead>
<th colspan='2'>{% trans "Output" %}</th> <th colspan='2'>{% trans "Output" %}</th>
<th>{% trans "Quantity" %}</th>
<th><!-- Actions --></th> <th><!-- Actions --></th>
</thead> </thead>
<tbody> <tbody>
@ -701,11 +725,14 @@ function scrapBuildOutputs(build_id, outputs, options={}) {
outputs.forEach(function(output) { outputs.forEach(function(output) {
let pk = output.pk; let pk = output.pk;
let row = $(opts.modal).find(`#output_row_${pk}`); let row = $(opts.modal).find(`#output_row_${pk}`);
let quantity = getFormFieldValue(`outputs_quantity_${pk}`, {}, opts);
if (row.exists()) { if (row.exists()) {
data.outputs.push({ data.outputs.push({
output: pk, output: pk,
quantity: quantity,
}); });
output_pk_values.push(pk); output_pk_values.push(pk);
} }
}); });

View File

@ -45,7 +45,7 @@
</div> </div>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% include "filter_list.html" with id="stock" %} {% include "filter_list.html" with prefix=prefix id="stock" %}
</div> </div>
</div> </div>
</div> </div>