mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-17 04:25:42 +00:00
[Feature] Build allocation export (#7611)
* CUI: Add "allocated stock" panel to build order page * Implement CUI table for build order allocations * Add "bulk delete" option for build order allocations * Add row actions * Add extra fields for data export * Add build allocation table in PUI * Add 'batch' column * Bump API version * Add playwright tests * Fix missing renderer * Update build docs * Update playwright tests * Update playwright tests
This commit is contained in:
@ -1,12 +1,16 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 218
|
||||
INVENTREE_API_VERSION = 219
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
v219 - 2024-07-11 : https://github.com/inventree/InvenTree/pull/7611
|
||||
- Adds new fields to the BuildItem API endpoints
|
||||
- Adds new ordering / filtering options to the BuildItem API endpoints
|
||||
|
||||
v218 - 2024-07-11 : https://github.com/inventree/InvenTree/pull/7619
|
||||
- Adds "can_build" field to the BomItem API
|
||||
|
||||
|
@ -8,12 +8,11 @@ from django.contrib.auth.models import User
|
||||
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django_filters import rest_framework as rest_filters
|
||||
|
||||
from importer.mixins import DataExportViewMixin
|
||||
|
||||
from InvenTree.api import MetadataView
|
||||
from InvenTree.api import BulkDeleteMixin, MetadataView
|
||||
from generic.states.api import StatusView
|
||||
from InvenTree.helpers import str2bool, isNull
|
||||
from build.status_codes import BuildStatus, BuildStatusGroups
|
||||
@ -546,15 +545,17 @@ class BuildItemFilter(rest_filters.FilterSet):
|
||||
return queryset.filter(install_into=None)
|
||||
|
||||
|
||||
class BuildItemList(DataExportViewMixin, ListCreateAPI):
|
||||
class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI):
|
||||
"""API endpoint for accessing a list of BuildItem objects.
|
||||
|
||||
- GET: Return list of objects
|
||||
- POST: Create a new BuildItem object
|
||||
"""
|
||||
|
||||
queryset = BuildItem.objects.all()
|
||||
serializer_class = build.serializers.BuildItemSerializer
|
||||
filterset_class = BuildItemFilter
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Returns a BuildItemSerializer instance based on the request."""
|
||||
@ -571,7 +572,7 @@ class BuildItemList(DataExportViewMixin, ListCreateAPI):
|
||||
|
||||
def get_queryset(self):
|
||||
"""Override the queryset method, to allow filtering by stock_item.part."""
|
||||
queryset = BuildItem.objects.all()
|
||||
queryset = super().get_queryset()
|
||||
|
||||
queryset = queryset.select_related(
|
||||
'build_line',
|
||||
@ -607,8 +608,25 @@ class BuildItemList(DataExportViewMixin, ListCreateAPI):
|
||||
|
||||
return queryset
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
ordering_fields = [
|
||||
'part',
|
||||
'sku',
|
||||
'quantity',
|
||||
'location',
|
||||
'reference',
|
||||
]
|
||||
|
||||
ordering_field_aliases = {
|
||||
'part': 'stock_item__part__name',
|
||||
'sku': 'stock_item__supplier_part__SKU',
|
||||
'location': 'stock_item__location__name',
|
||||
'reference': 'build_line__bom_item__reference',
|
||||
}
|
||||
|
||||
search_fields = [
|
||||
'stock_item__supplier_part__SKU',
|
||||
'stock_item__part__name',
|
||||
'build_line__bom_item__reference',
|
||||
]
|
||||
|
||||
|
||||
|
@ -26,8 +26,9 @@ from stock.serializers import StockItemSerializerBrief, LocationSerializer
|
||||
import common.models
|
||||
from common.serializers import ProjectCodeSerializer
|
||||
from importer.mixins import DataImportExportSerializerMixin
|
||||
import company.serializers
|
||||
import part.filters
|
||||
from part.serializers import BomItemSerializer, PartSerializer, PartBriefSerializer
|
||||
import part.serializers as part_serializers
|
||||
from users.serializers import OwnerSerializer
|
||||
|
||||
from .models import Build, BuildLine, BuildItem
|
||||
@ -85,7 +86,7 @@ class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTre
|
||||
|
||||
status_text = serializers.CharField(source='get_status_display', read_only=True)
|
||||
|
||||
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
||||
part_detail = part_serializers.PartBriefSerializer(source='part', many=False, read_only=True)
|
||||
|
||||
part_name = serializers.CharField(source='part.name', read_only=True, label=_('Part Name'))
|
||||
|
||||
@ -1062,10 +1063,13 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
||||
# These fields are only used for data export
|
||||
export_only_fields = [
|
||||
'build_reference',
|
||||
'bom_reference',
|
||||
'sku',
|
||||
'mpn',
|
||||
'location_name',
|
||||
'part_id',
|
||||
'part_name',
|
||||
'part_ipn',
|
||||
'available_quantity',
|
||||
]
|
||||
|
||||
class Meta:
|
||||
@ -1085,6 +1089,7 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
||||
'location_detail',
|
||||
'part_detail',
|
||||
'stock_item_detail',
|
||||
'supplier_part_detail',
|
||||
|
||||
# The following fields are only used for data export
|
||||
'bom_reference',
|
||||
@ -1092,27 +1097,12 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
||||
'location_name',
|
||||
'mpn',
|
||||
'sku',
|
||||
'part_id',
|
||||
'part_name',
|
||||
'part_ipn',
|
||||
'available_quantity',
|
||||
]
|
||||
|
||||
# Export-only fields
|
||||
sku = serializers.CharField(source='stock_item.supplier_part.SKU', label=_('Supplier Part Number'), read_only=True)
|
||||
mpn = serializers.CharField(source='stock_item.supplier_part.manufacturer_part.MPN', label=_('Manufacturer Part Number'), read_only=True)
|
||||
location_name = serializers.CharField(source='stock_item.location.name', label=_('Location Name'), read_only=True)
|
||||
build_reference = serializers.CharField(source='build.reference', label=_('Build Reference'), read_only=True)
|
||||
bom_reference = serializers.CharField(source='build_line.bom_item.reference', label=_('BOM Reference'), read_only=True)
|
||||
|
||||
# Annotated fields
|
||||
build = serializers.PrimaryKeyRelatedField(source='build_line.build', many=False, read_only=True)
|
||||
|
||||
# Extra (optional) detail fields
|
||||
part_detail = PartBriefSerializer(source='stock_item.part', many=False, read_only=True, pricing=False)
|
||||
stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True)
|
||||
location = serializers.PrimaryKeyRelatedField(source='stock_item.location', many=False, 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"""
|
||||
part_detail = kwargs.pop('part_detail', True)
|
||||
@ -1134,6 +1124,32 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
||||
if not build_detail:
|
||||
self.fields.pop('build_detail', None)
|
||||
|
||||
# Export-only fields
|
||||
sku = serializers.CharField(source='stock_item.supplier_part.SKU', label=_('Supplier Part Number'), read_only=True)
|
||||
mpn = serializers.CharField(source='stock_item.supplier_part.manufacturer_part.MPN', label=_('Manufacturer Part Number'), read_only=True)
|
||||
location_name = serializers.CharField(source='stock_item.location.name', label=_('Location Name'), read_only=True)
|
||||
build_reference = serializers.CharField(source='build.reference', label=_('Build Reference'), read_only=True)
|
||||
bom_reference = serializers.CharField(source='build_line.bom_item.reference', label=_('BOM Reference'), read_only=True)
|
||||
|
||||
# Part detail fields
|
||||
part_id = serializers.PrimaryKeyRelatedField(source='stock_item.part', label=_('Part ID'), many=False, read_only=True)
|
||||
part_name = serializers.CharField(source='stock_item.part.name', label=_('Part Name'), read_only=True)
|
||||
part_ipn = serializers.CharField(source='stock_item.part.IPN', label=_('Part IPN'), read_only=True)
|
||||
|
||||
# Annotated fields
|
||||
build = serializers.PrimaryKeyRelatedField(source='build_line.build', many=False, read_only=True)
|
||||
|
||||
# Extra (optional) detail fields
|
||||
part_detail = part_serializers.PartBriefSerializer(source='stock_item.part', many=False, read_only=True, pricing=False)
|
||||
stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True)
|
||||
location = serializers.PrimaryKeyRelatedField(source='stock_item.location', many=False, 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)
|
||||
supplier_part_detail = company.serializers.SupplierPartSerializer(source='stock_item.supplier_part', many=False, read_only=True)
|
||||
|
||||
quantity = InvenTreeDecimalField(label=_('Allocated Quantity'))
|
||||
available_quantity = InvenTreeDecimalField(source='stock_item.quantity', read_only=True, label=_('Available Quantity'))
|
||||
|
||||
|
||||
class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
|
||||
"""Serializer for a BuildItem object."""
|
||||
@ -1217,8 +1233,8 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
||||
bom_item = serializers.PrimaryKeyRelatedField(label=_('BOM Item'), read_only=True)
|
||||
|
||||
# Foreign key fields
|
||||
bom_item_detail = BomItemSerializer(source='bom_item', many=False, read_only=True, pricing=False)
|
||||
part_detail = PartSerializer(source='bom_item.sub_part', many=False, read_only=True, pricing=False)
|
||||
bom_item_detail = part_serializers.BomItemSerializer(source='bom_item', many=False, read_only=True, pricing=False)
|
||||
part_detail = part_serializers.PartSerializer(source='bom_item.sub_part', many=False, read_only=True, pricing=False)
|
||||
allocations = BuildItemSerializer(many=True, read_only=True)
|
||||
|
||||
# Annotated (calculated) fields
|
||||
|
@ -174,7 +174,7 @@
|
||||
<div class='panel panel-hidden' id='panel-allocate'>
|
||||
<div class='panel-heading'>
|
||||
<div class='d-flex flex-wrap'>
|
||||
<h4>{% trans "Allocate Stock to Build" %}</h4>
|
||||
<h4>{% trans "Build Order Line Items" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% if roles.build.add and build.active %}
|
||||
@ -231,6 +231,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel panel-hidden' id='panel-allocated'>
|
||||
<div class='panel-heading'>
|
||||
<h4>{% trans "Allocated Stock" %}</h4>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='build-allocated-stock-toolbar'>
|
||||
{% include "filter_list.html" with id='buildorderallocatedstock' %}
|
||||
</div>
|
||||
<table class='table table-striped table-condensed' id='allocated-stock-table' data-toolbar='#build-allocated-stock-toolbar'></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel panel-hidden' id='panel-consumed'>
|
||||
<div class='panel-heading'>
|
||||
<h4>
|
||||
@ -290,6 +302,10 @@
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
onPanelLoad('allocated', function() {
|
||||
loadBuildOrderAllocatedStockTable($('#allocated-stock-table'), {{ build.pk }});
|
||||
});
|
||||
|
||||
onPanelLoad('consumed', function() {
|
||||
loadStockTable($('#consumed-stock-table'), {
|
||||
filterTarget: '#filter-list-consumed-stock',
|
||||
|
@ -5,15 +5,19 @@
|
||||
{% trans "Build Order Details" as text %}
|
||||
{% include "sidebar_item.html" with label='details' text=text icon="fa-info-circle" %}
|
||||
{% if build.is_active %}
|
||||
{% trans "Allocate Stock" as text %}
|
||||
{% include "sidebar_item.html" with label='allocate' text=text icon="fa-tasks" %}
|
||||
{% trans "Line Items" as text %}
|
||||
{% include "sidebar_item.html" with label='allocate' text=text icon="fa-list-ol" %}
|
||||
{% 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" %}
|
||||
{% if build.is_active %}
|
||||
{% trans "Allocated Stock" as text %}
|
||||
{% include "sidebar_item.html" with label='allocated' text=text icon="fa-list" %}
|
||||
{% endif %}
|
||||
{% trans "Consumed Stock" as text %}
|
||||
{% include "sidebar_item.html" with label='consumed' text=text icon="fa-list" %}
|
||||
{% include "sidebar_item.html" with label='consumed' text=text icon="fa-tasks" %}
|
||||
{% trans "Child Build Orders" as text %}
|
||||
{% include "sidebar_item.html" with label='children' text=text icon="fa-sitemap" %}
|
||||
{% trans "Attachments" as text %}
|
||||
|
@ -58,6 +58,7 @@
|
||||
duplicateBuildOrder,
|
||||
editBuildOrder,
|
||||
loadBuildLineTable,
|
||||
loadBuildOrderAllocatedStockTable,
|
||||
loadBuildOrderAllocationTable,
|
||||
loadBuildOutputTable,
|
||||
loadBuildTable,
|
||||
@ -933,6 +934,180 @@ function deleteBuildOutputs(build_id, outputs, options={}) {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Load a table showing all stock allocated to a given Build Order
|
||||
*/
|
||||
function loadBuildOrderAllocatedStockTable(table, buildId) {
|
||||
|
||||
let params = {
|
||||
build: buildId,
|
||||
part_detail: true,
|
||||
location_detail: true,
|
||||
stock_detail: true,
|
||||
supplier_detail: true,
|
||||
};
|
||||
|
||||
let filters = loadTableFilters('buildorderallocatedstock', params);
|
||||
setupFilterList(
|
||||
'buildorderallocatedstock',
|
||||
$(table),
|
||||
null,
|
||||
{
|
||||
download: true,
|
||||
custom_actions: [{
|
||||
label: 'actions',
|
||||
actions: [{
|
||||
label: 'delete',
|
||||
title: '{% trans "Delete allocations" %}',
|
||||
icon: 'fa-trash-alt icon-red',
|
||||
permission: 'build.delete',
|
||||
callback: function(data) {
|
||||
constructForm('{% url "api-build-item-list" %}', {
|
||||
method: 'DELETE',
|
||||
multi_delete: true,
|
||||
title: '{% trans "Delete Stock Allocations" %}',
|
||||
form_data: {
|
||||
items: data.map(item => item.pk),
|
||||
},
|
||||
onSuccess: function() {
|
||||
$(table).bootstrapTable('refresh');
|
||||
}
|
||||
});
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
);
|
||||
|
||||
$(table).inventreeTable({
|
||||
url: '{% url "api-build-item-list" %}',
|
||||
queryParams: filters,
|
||||
original: params,
|
||||
sortable: true,
|
||||
search: true,
|
||||
groupBy: false,
|
||||
sidePagination: 'server',
|
||||
formatNoMatches: function() {
|
||||
return '{% trans "No allocated stock" %}';
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
title: '',
|
||||
visible: true,
|
||||
checkbox: true,
|
||||
switchable: false,
|
||||
},
|
||||
{
|
||||
field: 'part',
|
||||
sortable: true,
|
||||
switchable: false,
|
||||
title: '{% trans "Part" %}',
|
||||
formatter: function(value, row) {
|
||||
return imageHoverIcon(row.part_detail.thumbnail) + renderLink(row.part_detail.full_name, `/part/${row.part_detail.pk}/`);
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'bom_reference',
|
||||
sortable: true,
|
||||
switchable: true,
|
||||
title: '{% trans "Reference" %}',
|
||||
},
|
||||
{
|
||||
field: 'quantity',
|
||||
sortable: true,
|
||||
switchable: false,
|
||||
title: '{% trans "Allocated Quantity" %}',
|
||||
formatter: function(value, row) {
|
||||
let stock_item = row.stock_item_detail;
|
||||
let text = value;
|
||||
|
||||
if (stock_item.serial && stock_item.quantity == 1) {
|
||||
text = `# ${stock_item.serial}`;
|
||||
}
|
||||
|
||||
return renderLink(text, `/stock/item/${stock_item.pk}/`);
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'location',
|
||||
sortable: true,
|
||||
title: '{% trans "Location" %}',
|
||||
formatter: function(value, row) {
|
||||
if (row.location_detail) {
|
||||
return locationDetail(row, true);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'install_into',
|
||||
sortable: true,
|
||||
title: '{% trans "Build Output" %}',
|
||||
formatter: function(value, row) {
|
||||
if (value) {
|
||||
return renderLink(`{% trans "Stock item" %}: ${value}`, `/stock/item/${value}/`);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'sku',
|
||||
sortable: true,
|
||||
title: '{% trans "Supplier Part" %}',
|
||||
formatter: function(value, row) {
|
||||
if (row.supplier_part_detail) {
|
||||
let text = row.supplier_part_detail.SKU;
|
||||
|
||||
return renderLink(text, `/supplier-part/${row.supplier_part_detail.pk}/`);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'pk',
|
||||
title: '{% trans "Actions" %}',
|
||||
visible: true,
|
||||
switchable: false,
|
||||
sortable: false,
|
||||
formatter: function(value, row) {
|
||||
let buttons = '';
|
||||
|
||||
buttons += makeEditButton('build-item-edit', row.pk, '{% trans "Edit build allocation" %}');
|
||||
buttons += makeDeleteButton('build-item-delete', row.pk, '{% trans "Delete build allocation" %}');
|
||||
|
||||
return wrapButtons(buttons);
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Add row callbacks
|
||||
$(table).on('click', '.build-item-edit', function() {
|
||||
let pk = $(this).attr('pk');
|
||||
|
||||
constructForm(
|
||||
`/api/build/item/${pk}/`,
|
||||
{
|
||||
fields: {
|
||||
quantity: {},
|
||||
},
|
||||
title: '{% trans "Edit Build Allocation" %}',
|
||||
refreshTable: table
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$(table).on('click', '.build-item-delete', function() {
|
||||
let pk = $(this).attr('pk');
|
||||
|
||||
constructForm(
|
||||
`/api/build/item/${pk}/`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
title: '{% trans "Delete Build Allocation" %}',
|
||||
refreshTable: table,
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a table showing all the BuildOrder allocations for a given part
|
||||
*/
|
||||
|
Reference in New Issue
Block a user