mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-17 12:35:46 +00:00
Build consume stock (#4817)
* Adds "consumed_by" field to the StockItem model. - Points to a BuildOrder instance which "consumed" this stock - Marks item as unavailable - Allows filtering against build order * Allow API filtering * Adds table of "consumed stock items" to build order page * Update stock table to show "consumed by" stock status * Add "consumed_by" link to stock item detail * Optionally add 'buildorder' details to installStockItem method * Update methodology for completing a build item - Instead of deleting stock, mark as "consumed by" * Fix history entry for splitting stock * Bug fix * track "consumed_by" field for tracked items also * Update build docs * Update allocation documentation * Update terminology.md * Unit test updates * Fix conflicting migrations * revert change
This commit is contained in:
@ -563,6 +563,12 @@ def send_email(subject, body, recipients, from_email=None, html_message=None):
|
||||
if type(recipients) == str:
|
||||
recipients = [recipients]
|
||||
|
||||
import InvenTree.ready
|
||||
|
||||
if InvenTree.ready.isImportingData():
|
||||
# If we are importing data, don't send emails
|
||||
return
|
||||
|
||||
offload_task(
|
||||
django_mail.send_mail,
|
||||
subject,
|
||||
|
@ -1349,39 +1349,48 @@ class BuildItem(InvenTree.models.MetadataMixin, models.Model):
|
||||
"""Complete the allocation of this BuildItem into the output stock item.
|
||||
|
||||
- If the referenced part is trackable, the stock item will be *installed* into the build output
|
||||
- If the referenced part is *not* trackable, the stock item will be removed from stock
|
||||
- If the referenced part is *not* trackable, the stock item will be *consumed* by the build order
|
||||
"""
|
||||
item = self.stock_item
|
||||
|
||||
# Split the allocated stock if there are more available than allocated
|
||||
if item.quantity > self.quantity:
|
||||
item = item.splitStock(
|
||||
self.quantity,
|
||||
None,
|
||||
user,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
# For a trackable part, special consideration needed!
|
||||
if item.part.trackable:
|
||||
# Split the allocated stock if there are more available than allocated
|
||||
if item.quantity > self.quantity:
|
||||
item = item.splitStock(
|
||||
self.quantity,
|
||||
None,
|
||||
user,
|
||||
code=StockHistoryCode.BUILD_CONSUMED,
|
||||
)
|
||||
|
||||
# Make sure we are pointing to the new item
|
||||
self.stock_item = item
|
||||
self.save()
|
||||
# Make sure we are pointing to the new item
|
||||
self.stock_item = item
|
||||
self.save()
|
||||
|
||||
# Install the stock item into the output
|
||||
self.install_into.installStockItem(
|
||||
item,
|
||||
self.quantity,
|
||||
user,
|
||||
notes
|
||||
notes,
|
||||
build=self.build,
|
||||
)
|
||||
|
||||
else:
|
||||
# Simply remove the items from stock
|
||||
item.take_stock(
|
||||
self.quantity,
|
||||
# Mark the item as "consumed" by the build order
|
||||
item.consumed_by = self.build
|
||||
item.save(add_note=False)
|
||||
|
||||
item.add_tracking_entry(
|
||||
StockHistoryCode.BUILD_CONSUMED,
|
||||
user,
|
||||
code=StockHistoryCode.BUILD_CONSUMED
|
||||
notes=notes,
|
||||
deltas={
|
||||
'buildorder': self.build.pk,
|
||||
'quantity': float(item.quantity),
|
||||
}
|
||||
)
|
||||
|
||||
def getStockItemThumbnail(self):
|
||||
|
@ -282,6 +282,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel panel-hidden' id='panel-consumed'>
|
||||
<div class='panel-heading'>
|
||||
<h4>
|
||||
{% trans "Consumed Stock" %}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div class='panel-content'>
|
||||
{% include "stock_table.html" with read_only=True prefix="consumed-" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel panel-hidden' id='panel-completed'>
|
||||
<div class='panel-heading'>
|
||||
<h4>
|
||||
@ -329,6 +341,17 @@
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
onPanelLoad('consumed', function() {
|
||||
loadStockTable($('#consumed-stock-table'), {
|
||||
params: {
|
||||
location_detail: true,
|
||||
part_detail: true,
|
||||
consumed_by: {{ build.pk }},
|
||||
in_stock: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
onPanelLoad('completed', function() {
|
||||
loadStockTable($("#build-stock-table"), {
|
||||
params: {
|
||||
@ -337,11 +360,9 @@ onPanelLoad('completed', function() {
|
||||
build: {{ build.id }},
|
||||
is_building: false,
|
||||
},
|
||||
groupByField: 'location',
|
||||
buttons: [
|
||||
'#stock-options',
|
||||
],
|
||||
url: "{% url 'api-stock-list' %}",
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -8,7 +8,9 @@
|
||||
{% trans "Allocate Stock" as text %}
|
||||
{% include "sidebar_item.html" with label='allocate' text=text icon="fa-tasks" %}
|
||||
{% endif %}
|
||||
{% if not build.is_complete %}
|
||||
{% 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 %}
|
||||
|
@ -424,6 +424,7 @@ class BuildTest(BuildTestBase):
|
||||
extra_2_2: 4, # 35
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(self.build.has_overallocated_parts(None))
|
||||
|
||||
self.build.trim_allocated_stock()
|
||||
@ -433,15 +434,30 @@ class BuildTest(BuildTestBase):
|
||||
self.build.complete_build_output(self.output_2, None)
|
||||
self.assertTrue(self.build.can_complete)
|
||||
|
||||
n = StockItem.objects.filter(consumed_by=self.build).count()
|
||||
|
||||
self.build.complete_build(None)
|
||||
|
||||
self.assertEqual(self.build.status, status.BuildStatus.COMPLETE)
|
||||
|
||||
# Check stock items are in expected state.
|
||||
self.assertEqual(StockItem.objects.get(pk=self.stock_1_2.pk).quantity, 53)
|
||||
self.assertEqual(StockItem.objects.filter(part=self.sub_part_2).aggregate(Sum('quantity'))['quantity__sum'], 5)
|
||||
|
||||
# Total stock quantity has not been decreased
|
||||
items = StockItem.objects.filter(part=self.sub_part_2)
|
||||
self.assertEqual(items.aggregate(Sum('quantity'))['quantity__sum'], 35)
|
||||
|
||||
# However, the "available" stock quantity has been decreased
|
||||
self.assertEqual(items.filter(consumed_by=None).aggregate(Sum('quantity'))['quantity__sum'], 5)
|
||||
|
||||
# And the "consumed_by" quantity has been increased
|
||||
self.assertEqual(items.filter(consumed_by=self.build).aggregate(Sum('quantity'))['quantity__sum'], 30)
|
||||
|
||||
self.assertEqual(StockItem.objects.get(pk=self.stock_3_1.pk).quantity, 980)
|
||||
|
||||
# Check that the "consumed_by" item count has increased
|
||||
self.assertEqual(StockItem.objects.filter(consumed_by=self.build).count(), n + 8)
|
||||
|
||||
def test_cancel(self):
|
||||
"""Test cancellation of the build"""
|
||||
|
||||
@ -510,15 +526,12 @@ class BuildTest(BuildTestBase):
|
||||
self.assertEqual(BuildItem.objects.count(), 0)
|
||||
|
||||
# New stock items should have been created!
|
||||
self.assertEqual(StockItem.objects.count(), 10)
|
||||
self.assertEqual(StockItem.objects.count(), 13)
|
||||
|
||||
# This stock item has been depleted!
|
||||
with self.assertRaises(StockItem.DoesNotExist):
|
||||
StockItem.objects.get(pk=self.stock_1_1.pk)
|
||||
|
||||
# This stock item has also been depleted
|
||||
with self.assertRaises(StockItem.DoesNotExist):
|
||||
StockItem.objects.get(pk=self.stock_2_1.pk)
|
||||
# This stock item has been marked as "consumed"
|
||||
item = StockItem.objects.get(pk=self.stock_1_1.pk)
|
||||
self.assertIsNotNone(item.consumed_by)
|
||||
self.assertFalse(item.in_stock)
|
||||
|
||||
# And 10 new stock items created for the build output
|
||||
outputs = StockItem.objects.filter(build=self.build)
|
||||
|
@ -313,9 +313,7 @@ loadStockTable($("#stock-table"), {
|
||||
location_detail: true,
|
||||
part_detail: false,
|
||||
},
|
||||
groupByField: 'location',
|
||||
buttons: ['#stock-options'],
|
||||
url: "{% url 'api-stock-list' %}",
|
||||
});
|
||||
|
||||
$("#item-create").click(function() {
|
||||
|
@ -822,11 +822,9 @@
|
||||
part_detail: true,
|
||||
supplier_part_detail: true,
|
||||
},
|
||||
groupByField: 'location',
|
||||
buttons: [
|
||||
'#stock-options',
|
||||
],
|
||||
url: "{% url 'api-stock-list' %}",
|
||||
});
|
||||
|
||||
$('#item-create').click(function () {
|
||||
|
@ -356,6 +356,7 @@ class StockFilter(rest_filters.FilterSet):
|
||||
'belongs_to',
|
||||
'build',
|
||||
'customer',
|
||||
'consumed_by',
|
||||
'sales_order',
|
||||
'purchase_order',
|
||||
'tags__name',
|
||||
|
20
InvenTree/stock/migrations/0100_stockitem_consumed_by.py
Normal file
20
InvenTree/stock/migrations/0100_stockitem_consumed_by.py
Normal file
@ -0,0 +1,20 @@
|
||||
# Generated by Django 3.2.19 on 2023-05-14 23:48
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('build', '0042_alter_build_notes'),
|
||||
('stock', '0100_auto_20230515_0004'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='stockitem',
|
||||
name='consumed_by',
|
||||
field=models.ForeignKey(blank=True, help_text='Build order which consumed this stock item', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='consumed_stock', to='build.build', verbose_name='Consumed By'),
|
||||
),
|
||||
]
|
@ -332,6 +332,7 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo
|
||||
sales_order=None,
|
||||
belongs_to=None,
|
||||
customer=None,
|
||||
consumed_by=None,
|
||||
is_building=False,
|
||||
status__in=StockStatus.AVAILABLE_CODES
|
||||
)
|
||||
@ -755,6 +756,14 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo
|
||||
related_name='build_outputs',
|
||||
)
|
||||
|
||||
consumed_by = models.ForeignKey(
|
||||
'build.Build', on_delete=models.CASCADE,
|
||||
verbose_name=_('Consumed By'),
|
||||
blank=True, null=True,
|
||||
help_text=_('Build order which consumed this stock item'),
|
||||
related_name='consumed_stock',
|
||||
)
|
||||
|
||||
is_building = models.BooleanField(
|
||||
default=False,
|
||||
)
|
||||
@ -1167,7 +1176,7 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo
|
||||
return self.installed_parts.count()
|
||||
|
||||
@transaction.atomic
|
||||
def installStockItem(self, other_item, quantity, user, notes):
|
||||
def installStockItem(self, other_item, quantity, user, notes, build=None):
|
||||
"""Install another stock item into this stock item.
|
||||
|
||||
Args:
|
||||
@ -1175,6 +1184,7 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo
|
||||
quantity: The quantity of stock to install
|
||||
user: The user performing the operation
|
||||
notes: Any notes associated with the operation
|
||||
build: The BuildOrder to associate with the operation (optional)
|
||||
"""
|
||||
# If the quantity is less than the stock item, split the stock!
|
||||
stock_item = other_item.splitStock(quantity, None, user)
|
||||
@ -1184,16 +1194,22 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo
|
||||
|
||||
# Assign the other stock item into this one
|
||||
stock_item.belongs_to = self
|
||||
stock_item.save()
|
||||
stock_item.consumed_by = build
|
||||
stock_item.save(add_note=False)
|
||||
|
||||
deltas = {
|
||||
'stockitem': self.pk,
|
||||
}
|
||||
|
||||
if build is not None:
|
||||
deltas['buildorder'] = build.pk
|
||||
|
||||
# Add a transaction note to the other item
|
||||
stock_item.add_tracking_entry(
|
||||
StockHistoryCode.INSTALLED_INTO_ASSEMBLY,
|
||||
user,
|
||||
notes=notes,
|
||||
deltas={
|
||||
'stockitem': self.pk,
|
||||
}
|
||||
deltas=deltas,
|
||||
)
|
||||
|
||||
# Add a transaction note to this item (the assembly)
|
||||
@ -1574,7 +1590,6 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo
|
||||
The new item will have a different StockItem ID, while this will remain the same.
|
||||
"""
|
||||
notes = kwargs.get('notes', '')
|
||||
code = kwargs.get('code', StockHistoryCode.SPLIT_FROM_PARENT)
|
||||
|
||||
# Do not split a serialized part
|
||||
if self.serialized:
|
||||
@ -1606,30 +1621,31 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo
|
||||
else:
|
||||
new_stock.location = self.location
|
||||
|
||||
new_stock.save()
|
||||
new_stock.save(add_note=False)
|
||||
|
||||
# Copy the transaction history of this part into the new one
|
||||
new_stock.copyHistoryFrom(self)
|
||||
# Add a stock tracking entry for the newly created item
|
||||
new_stock.add_tracking_entry(
|
||||
StockHistoryCode.SPLIT_FROM_PARENT,
|
||||
user,
|
||||
quantity=quantity,
|
||||
notes=notes,
|
||||
location=location,
|
||||
deltas={
|
||||
'stockitem': self.pk,
|
||||
}
|
||||
)
|
||||
|
||||
# Copy the test results of this part to the new one
|
||||
new_stock.copyTestResultsFrom(self)
|
||||
|
||||
# Add a new tracking item for the new stock item
|
||||
new_stock.add_tracking_entry(
|
||||
code,
|
||||
user,
|
||||
notes=notes,
|
||||
deltas={
|
||||
'stockitem': self.pk,
|
||||
},
|
||||
location=location,
|
||||
)
|
||||
|
||||
# Remove the specified quantity from THIS stock item
|
||||
self.take_stock(
|
||||
quantity,
|
||||
user,
|
||||
notes=notes
|
||||
code=StockHistoryCode.SPLIT_CHILD_ITEM,
|
||||
notes=notes,
|
||||
location=location,
|
||||
stockitem=new_stock,
|
||||
)
|
||||
|
||||
# Return a copy of the "new" stock item
|
||||
@ -1798,7 +1814,7 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo
|
||||
return True
|
||||
|
||||
@transaction.atomic
|
||||
def take_stock(self, quantity, user, notes='', code=StockHistoryCode.STOCK_REMOVE):
|
||||
def take_stock(self, quantity, user, notes='', code=StockHistoryCode.STOCK_REMOVE, **kwargs):
|
||||
"""Remove items from stock."""
|
||||
# Cannot remove items from a serialized part
|
||||
if self.serialized:
|
||||
@ -1814,14 +1830,22 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo
|
||||
|
||||
if self.updateQuantity(self.quantity - quantity):
|
||||
|
||||
deltas = {
|
||||
'removed': float(quantity),
|
||||
'quantity': float(self.quantity),
|
||||
}
|
||||
|
||||
if location := kwargs.get('location', None):
|
||||
deltas['location'] = location.pk
|
||||
|
||||
if stockitem := kwargs.get('stockitem', None):
|
||||
deltas['stockitem'] = stockitem.pk
|
||||
|
||||
self.add_tracking_entry(
|
||||
code,
|
||||
user,
|
||||
notes=notes,
|
||||
deltas={
|
||||
'removed': float(quantity),
|
||||
'quantity': float(self.quantity),
|
||||
}
|
||||
deltas=deltas,
|
||||
)
|
||||
|
||||
return True
|
||||
|
@ -93,6 +93,7 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
|
||||
'batch',
|
||||
'belongs_to',
|
||||
'build',
|
||||
'consumed_by',
|
||||
'customer',
|
||||
'delete_on_deplete',
|
||||
'expired',
|
||||
|
@ -315,11 +315,9 @@
|
||||
ancestor: {{ item.id }},
|
||||
},
|
||||
name: 'item-childs',
|
||||
groupByField: 'location',
|
||||
buttons: [
|
||||
'#stock-options',
|
||||
],
|
||||
url: "{% url 'api-stock-list' %}",
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
|
@ -358,6 +358,12 @@
|
||||
<a href="{% url 'stock-item-detail' item.belongs_to.id %}">{{ item.belongs_to }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% elif item.consumed_by %}
|
||||
<tr>
|
||||
<td><span class='fas fa-tools'></span></td>
|
||||
<td>{% trans "Consumed By" %}</td>
|
||||
<td><a href='{% url "build-detail" item.consumed_by.pk %}'>{{ item.consumed_by }}</td>
|
||||
</tr>
|
||||
{% elif item.sales_order %}
|
||||
<tr>
|
||||
<td><span class='fas fa-th-list'></span></td>
|
||||
|
@ -423,7 +423,6 @@
|
||||
location_detail: true,
|
||||
supplier_part_detail: true,
|
||||
},
|
||||
url: "{% url 'api-stock-list' %}",
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -160,7 +160,6 @@ loadStockTable($('#table-recently-updated-stock'), {
|
||||
limit: {% settings_value "STOCK_RECENT_COUNT" user=request.user %},
|
||||
},
|
||||
name: 'recently-updated-stock',
|
||||
grouping: false,
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
|
@ -156,7 +156,6 @@
|
||||
|
||||
loadStockTable($('#table-stock'), {
|
||||
filterKey: 'stocksearch',
|
||||
url: "{% url 'api-stock-list' %}",
|
||||
params: {
|
||||
original_search: search_text,
|
||||
part_detail: true,
|
||||
|
@ -671,7 +671,11 @@ function scrapBuildOutputs(build_id, outputs, options={}) {
|
||||
method: 'POST',
|
||||
preFormContent: html,
|
||||
fields: {
|
||||
location: {},
|
||||
location: {
|
||||
filters: {
|
||||
structural: false,
|
||||
}
|
||||
},
|
||||
notes: {},
|
||||
discard_allocations: {},
|
||||
},
|
||||
|
@ -1617,26 +1617,29 @@ function loadStockTestResultsTable(table, options) {
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Function to display a "location" of a StockItem.
|
||||
*
|
||||
* Complicating factors: A StockItem may not actually *be* in a location!
|
||||
* - Could be at a customer
|
||||
* - Could be installed in another stock item
|
||||
* - Could be assigned to a sales order
|
||||
* - Could be currently in production!
|
||||
*
|
||||
* So, instead of being naive, we'll check!
|
||||
*/
|
||||
function locationDetail(row, showLink=true) {
|
||||
/*
|
||||
* Function to display a "location" of a StockItem.
|
||||
*
|
||||
* Complicating factors: A StockItem may not actually *be* in a location!
|
||||
* - Could be at a customer
|
||||
* - Could be installed in another stock item
|
||||
* - Could be assigned to a sales order
|
||||
* - Could be currently in production!
|
||||
*
|
||||
* So, instead of being naive, we'll check!
|
||||
*/
|
||||
|
||||
// Display text
|
||||
var text = '';
|
||||
let text = '';
|
||||
|
||||
// URL (optional)
|
||||
var url = '';
|
||||
let url = '';
|
||||
|
||||
if (row.is_building && row.build) {
|
||||
if (row.consumed_by) {
|
||||
text = '{% trans "Consumed by build order" %}';
|
||||
url = `/build/${row.consumed_by}/`;
|
||||
} else if (row.is_building && row.build) {
|
||||
// StockItem is currently being built!
|
||||
text = '{% trans "In production" %}';
|
||||
url = `/build/${row.build}/`;
|
||||
@ -1827,6 +1830,8 @@ function loadStockTable(table, options) {
|
||||
}
|
||||
} else if (row.belongs_to) {
|
||||
html += makeIconBadge('fa-box', '{% trans "Stock item has been installed in another item" %}');
|
||||
} else if (row.consumed_by) {
|
||||
html += makeIconBadge('fa-tools', '{% trans "Stock item has been consumed by a build order" %}');
|
||||
}
|
||||
|
||||
if (row.expired) {
|
||||
@ -1836,13 +1841,11 @@ function loadStockTable(table, options) {
|
||||
}
|
||||
|
||||
// Special stock status codes
|
||||
|
||||
// REJECTED
|
||||
if (row.status == {{ StockStatus.REJECTED }}) {
|
||||
if (row.status == stockCodes.REJECTED.key) {
|
||||
html += makeIconBadge('fa-times-circle icon-red', '{% trans "Stock item has been rejected" %}');
|
||||
} else if (row.status == {{ StockStatus.LOST }}) {
|
||||
} else if (row.status == stockCodes.LOST.key) {
|
||||
html += makeIconBadge('fa-question-circle', '{% trans "Stock item is lost" %}');
|
||||
} else if (row.status == {{ StockStatus.DESTROYED }}) {
|
||||
} else if (row.status == stockCodes.DESTROYED.key) {
|
||||
html += makeIconBadge('fa-skull-crossbones', '{% trans "Stock item is destroyed" %}');
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user