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:
Oliver 2023-05-16 21:25:02 +10:00 committed by GitHub
parent 368f615d71
commit 397419f365
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 207 additions and 95 deletions

View File

@ -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,

View File

@ -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):

View File

@ -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' %}",
});
});

View File

@ -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 %}

View File

@ -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)

View File

@ -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() {

View File

@ -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 () {

View File

@ -356,6 +356,7 @@ class StockFilter(rest_filters.FilterSet):
'belongs_to',
'build',
'customer',
'consumed_by',
'sales_order',
'purchase_order',
'tags__name',

View 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'),
),
]

View File

@ -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

View File

@ -93,6 +93,7 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
'batch',
'belongs_to',
'build',
'consumed_by',
'customer',
'delete_on_deplete',
'expired',

View File

@ -315,11 +315,9 @@
ancestor: {{ item.id }},
},
name: 'item-childs',
groupByField: 'location',
buttons: [
'#stock-options',
],
url: "{% url 'api-stock-list' %}",
});
{% endif %}

View File

@ -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>

View File

@ -423,7 +423,6 @@
location_detail: true,
supplier_part_detail: true,
},
url: "{% url 'api-stock-list' %}",
});
});

View File

@ -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 %}

View File

@ -156,7 +156,6 @@
loadStockTable($('#table-stock'), {
filterKey: 'stocksearch',
url: "{% url 'api-stock-list' %}",
params: {
original_search: search_text,
part_detail: true,

View File

@ -671,7 +671,11 @@ function scrapBuildOutputs(build_id, outputs, options={}) {
method: 'POST',
preFormContent: html,
fields: {
location: {},
location: {
filters: {
structural: false,
}
},
notes: {},
discard_allocations: {},
},

View File

@ -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" %}');
}

View File

@ -17,17 +17,17 @@ Before continuing, it is important that the difference between *untracked* and *
#### Untracked Stock
*Untracked* stock items disappear from the database once they are "used". Once stock items for these parts are removed from the InvenTree database (e.g. used to create an assembly), the tracking information for these stock items disappears. The stock items no longer persist in the database.
*Untracked* stock items are consumed against the build order, once the order is completed. When a build order is completed, any allocated stock items which are not [trackable](../part/trackable.md) are marked as *consumed*. These items remain in the InvenTree database, but are unavailable for use in any stock operations.
!!! info "Example: Untracked Parts"
You require 15 x 47K resistors to make a batch of PCBs. You have a reel of 1,000 resistors which you allocate to the build. At completion of the build, the stock quantity is reduced to 985
You require 15 x 47K resistors to make a batch of PCBs. You have a reel of 1,000 resistors which you allocate to the build. At completion of the build, the available stock quantity is reduced to 985.
#### Tracked Stock
*Tracked* stock items, on the other hand, require special attention. These are parts which we wish to track indefinitely, even if they are "consumed" to create an assembly. *Tracked* stock items are not deleted as they are consumed. Instead, they are installed *within* the assembled unit
[Tracked](../part/trackable.md) stock items, on the other hand, require special attention. These are parts which we wish to track against specific [build outputs](./output.md). When the build order is completed, *tracked* stock items are installed *within* the assembled build output.
!!! info "Example: Tracked Parts"
The assembled PCB (in the example above) is a *trackable* part, and is given a serial number #001. The PCB is then used to make a larger assembly in a subsequent build order. At the completion of that build order, the tracked PCB is *installed* in the assembly, rather than being deleted from stock.
The assembled PCB (in the example above) is a *trackable* part, and is given a serial number #001. The PCB is then used to make a larger assembly in a subsequent build order. At the completion of that build order, the tracked PCB is *installed* in the assembly.
#### BOM Considerations
@ -151,4 +151,4 @@ Once all build outputs have been completed, the build order itself can be comple
### Allocated Stock
All *untracked* stock items which are allocated against this build will be removed from stock.
All *untracked* stock items which are allocated against this build will be removed from stock, and *consumed* by the build order. These consumed items can be later viewed in the [consumed stock tab](./build.md#consumed-stock).

View File

@ -122,6 +122,13 @@ The allocation table (as shown above) shows the stock allocation progress for th
!!! info "Completed Builds"
The *Allocate Stock* tab is not available if the build has been completed!
### Consumed Stock
The *Consumed Stock* tab displays all stock items which have been *consumed* by this build order. These stock items remain in the database after the build order has been completed, but are no longer available for use.
- [Tracked stock items](./allocate.md#tracked-stock) are consumed by specific build outputs
- [Untracked stock items](./allocate.md#untracked-stock) are consumed by the build order
### Build Outputs
The *Build Outputs* tab shows the [build outputs](./output.md) (created stock items) associated with this build.

View File

@ -6,23 +6,27 @@ title: Terminology
There are different systems in the industry for the management of getting, storing and making parts. An overview what they are for and what the acronyms mean.
**InvenTree** is mainly focused on [**IMS**](#inventory-management-system-ims) and [**PLM**](#part-library-management-plm) functionality.
### Inventory management *(IMS)*
### Inventory Management System *(IMS)*
Evolves around manufacturing of parts out of other parts. It keeps track of stock, part origin, orders, shelf live and more.
### Part library management *(PLM)*
### Part Library Management *(PLM)*
Keeps track of BOMs, part variants, possible substitutions, versions, IPNs and further part parameters.
PLM can also mean product lifecycle management those systems manage all stages from design through manufacturing up to customer support and recycling.
**InvenTree** is mainly an **IMS**, it also has aspects of a **PLM** integrated.
A similar system is [Partkeepr](https://partkeepr.org/) (seems mostly inactive - there is a 3rd party importer).
### Asset management *(AM)*
### Asset Management *(AM)*
Manages many unique items, which need tracking per part and are assignable to users / groups / locations. These systems often include features like item states, refurbishing / maintenance / reservation, or request-flows.
Often these systems are used for IT-Hardware (then they are called *ITAM*).
A good open-source example would be [Snipe-IT](https://snipeitapp.com/).
### Enterprise resource planning *(ERP)*
### Enterprise Resource Planning *(ERP)*
Is the centre of your business. It manages timesheets, warehousing, finances (prices, taxes, …), customer relations and more. InvenTree covers parts of this but aims to keep an intuitive and simple user interface.
Popular, fully fledged ERPs are [ERPNext](https://erpnext.com/) or [odoo](https://www.odoo.com).
### Customer Relationship Manager *(CRM)*
Customer relationship management (CRM) is a technology for managing all your company's relationships and interactions with customers and potential customers.