2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-17 20:45:44 +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

@ -2,11 +2,14 @@
# InvenTree API version
INVENTREE_API_VERSION = 112
INVENTREE_API_VERSION = 113
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v113 -> 2023-05-13 : https://github.com/inventree/InvenTree/pull/4800
- Adds API endpoints for scrapping a build output
v112 -> 2023-05-13: https://github.com/inventree/InvenTree/pull/4741
- Adds flag use_pack_size to the stock addition API, which allows addings packs
@ -16,6 +19,7 @@ v111 -> 2023-05-02 : https://github.com/inventree/InvenTree/pull/4367
- Adds tags to the ManufacturerPart serializer
- Adds tags to the StockItem serializer
- Adds tags to the StockLocation serializer
v110 -> 2023-04-26 : https://github.com/inventree/InvenTree/pull/4698
- Adds 'order_currency' field for PurchaseOrder / SalesOrder endpoints

View File

@ -227,13 +227,12 @@ class StockStatus(StatusCode):
LOST: _("Lost"),
REJECTED: _("Rejected"),
QUARANTINED: _("Quarantined"),
RETURNED: _("Returned"),
}
colors = {
OK: 'success',
ATTENTION: 'warning',
DAMAGED: 'danger',
DAMAGED: 'warning',
DESTROYED: 'danger',
LOST: 'dark',
REJECTED: 'danger',
@ -289,6 +288,7 @@ class StockHistoryCode(StatusCode):
# Build order codes
BUILD_OUTPUT_CREATED = 50
BUILD_OUTPUT_COMPLETED = 55
BUILD_OUTPUT_REJECTED = 56
BUILD_CONSUMED = 57
# Sales order codes
@ -337,6 +337,7 @@ class StockHistoryCode(StatusCode):
BUILD_OUTPUT_CREATED: _('Build order output created'),
BUILD_OUTPUT_COMPLETED: _('Build order output completed'),
BUILD_OUTPUT_REJECTED: _('Build order output rejected'),
BUILD_CONSUMED: _('Consumed by build order'),
SHIPPED_AGAINST_SALES_ORDER: _("Shipped against Sales Order"),

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)

View File

@ -164,7 +164,7 @@ class PluginsRegistry:
if not _maintenance:
set_maintenance_mode(False)
logger.info('Finished loading plugins')
logger.debug('Finished loading plugins')
def unload_plugins(self, force_reload: bool = False):
"""Unload and deactivate all IntegrationPlugins.
@ -335,7 +335,7 @@ class PluginsRegistry:
return True
try:
output = str(subprocess.check_output(['pip', 'install', '-U', '-r', settings.PLUGIN_FILE], cwd=settings.BASE_DIR.parent), 'utf-8')
subprocess.check_output(['pip', 'install', '-U', '-r', settings.PLUGIN_FILE], cwd=settings.BASE_DIR.parent)
except subprocess.CalledProcessError as error: # pragma: no cover
logger.error(f'Ran into error while trying to install plugins!\n{str(error)}')
return False
@ -343,8 +343,6 @@ class PluginsRegistry:
# System most likely does not have 'git' installed
return False
logger.info(f'plugin requirements were run\n{output}')
# do not run again
settings.PLUGIN_FILE_CHECKED = True
return 'first_run'
@ -503,7 +501,7 @@ class PluginsRegistry:
if hasattr(mixin, '_activate_mixin'):
mixin._activate_mixin(self, plugins, force_reload=force_reload, full_reload=full_reload)
logger.info('Done activating')
logger.debug('Done activating')
def _deactivate_plugins(self, force_reload: bool = False):
"""Run deactivation functions for all plugins.

View File

@ -19,6 +19,7 @@ import common.models
import common.settings
import stock.serializers as StockSerializers
from build.models import Build
from build.serializers import BuildSerializer
from company.models import Company, SupplierPart
from company.serializers import CompanySerializer, SupplierPartSerializer
from InvenTree.api import (APIDownloadMixin, AttachmentMixin,
@ -1308,6 +1309,15 @@ class StockTrackingList(ListAPI):
except Exception:
pass
# Add BuildOrder detail
if 'buildorder' in deltas:
try:
order = Build.objects.get(pk=deltas['buildorder'])
serializer = BuildSerializer(order)
deltas['buildorder_detail'] = serializer.data
except Exception:
pass
if page is not None:
return self.get_paginated_response(data)
if request.is_ajax():

View File

@ -0,0 +1,19 @@
# Generated by Django 3.2.18 on 2023-05-13 05:15
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stock', '0098_auto_20230427_2033'),
]
operations = [
migrations.AlterField(
model_name='stockitem',
name='status',
field=models.PositiveIntegerField(choices=[(10, 'OK'), (50, 'Attention needed'), (55, 'Damaged'), (60, 'Destroyed'), (70, 'Lost'), (65, 'Rejected'), (75, 'Quarantined')], default=10, validators=[django.core.validators.MinValueValidator(0)]),
),
]

View File

@ -397,9 +397,17 @@ function makeBuildOutputButtons(output_id, build_info, options={}) {
'{% trans "Complete build output" %}',
);
// Add a button to "delete" this build output
// Add a button to "scrap" the build output
html += makeIconButton(
'fa-times-circle icon-red',
'button-output-scrap',
output_id,
'{% trans "Scrap build output" %}',
);
// Add a button to "remove" this build output
html += makeDeleteButton(
'button-output-delete',
'button-output-remove',
output_id,
'{% trans "Delete build output" %}',
);
@ -452,6 +460,51 @@ function unallocateStock(build_id, options={}) {
}
/*
* Helper function to render a single build output in a modal form
*/
function renderBuildOutput(output, opts={}) {
let pk = output.pk;
let output_html = imageHoverIcon(output.part_detail.thumbnail);
if (output.quantity == 1 && output.serial) {
output_html += `{% trans "Serial Number" %}: ${output.serial}`;
} else {
output_html += `{% trans "Quantity" %}: ${output.quantity}`;
if (output.part_detail && output.part_detail.units) {
output_html += ` ${output.part_detail.units} `;
}
}
let buttons = `<div class='btn-group float-right' role='group'>`;
buttons += makeRemoveButton('button-row-remove', pk, '{% trans "Remove row" %}');
buttons += '</div>';
let field = constructField(
`outputs_output_${pk}`,
{
type: 'raw',
html: output_html,
},
{
hideLabels: true,
}
);
let html = `
<tr id='output_row_${pk}'>
<td>${field}</td>
<td>${output.part_detail.full_name}</td>
<td>${buttons}</td>
</tr>`;
return html;
}
/**
* Launch a modal form to complete selected build outputs
*/
@ -465,48 +518,6 @@ function completeBuildOutputs(build_id, outputs, options={}) {
return;
}
// Render a single build output (StockItem)
function renderBuildOutput(output, opts={}) {
var pk = output.pk;
var output_html = imageHoverIcon(output.part_detail.thumbnail);
if (output.quantity == 1 && output.serial) {
output_html += `{% trans "Serial Number" %}: ${output.serial}`;
} else {
output_html += `{% trans "Quantity" %}: ${output.quantity}`;
if (output.part_detail && output.part_detail.units) {
output_html += ` ${output.part_detail.units} `;
}
}
var buttons = `<div class='btn-group float-right' role='group'>`;
buttons += makeRemoveButton('button-row-remove', pk, '{% trans "Remove row" %}');
buttons += '</div>';
var field = constructField(
`outputs_output_${pk}`,
{
type: 'raw',
html: output_html,
},
{
hideLabels: true,
}
);
var html = `
<tr id='output_row_${pk}'>
<td>${field}</td>
<td>${output.part_detail.full_name}</td>
<td>${buttons}</td>
</tr>`;
return html;
}
// Construct table entries
var table_entries = '';
@ -515,6 +526,9 @@ function completeBuildOutputs(build_id, outputs, options={}) {
});
var html = `
<div class='alert alert-block alert-success'>
{% trans "Selected build outputs will be marked as complete" %}
</div>
<table class='table table-striped table-condensed' id='build-complete-table'>
<thead>
<th colspan='2'>{% trans "Output" %}</th>
@ -613,8 +627,122 @@ function completeBuildOutputs(build_id, outputs, options={}) {
/*
* Launch a modal form to scrap selected build outputs.
* Scrapped outputs are marked as "complete", but with the "rejected" code
* These outputs are not included in build completion calculations.
*/
function scrapBuildOutputs(build_id, outputs, options={}) {
if (outputs.length == 0) {
showAlertDialog(
'{% trans "Select Build Outputs" %}',
'{% trans "At least one build output must be selected" %}',
);
return;
}
let table_entries = '';
outputs.forEach(function(output) {
table_entries += renderBuildOutput(output);
});
var html = `
<div class='alert alert-block alert-danger'>
{% trans "Selected build outputs will be marked as scrapped" %}
<ul>
<li>{% trans "Scrapped output are given the 'rejected' status" %}</li>
<li>{% trans "Allocated stock items will no longer be available" %}</li>
<li>{% trans "The completion status of the build order will not be adjusted" %}</li>
</ul>
</div>
<table class='table table-striped table-condensed' id='build-scrap-table'>
<thead>
<th colspan='2'>{% trans "Output" %}</th>
<th><!-- Actions --></th>
</thead>
<tbody>
${table_entries}
</tbody>
</table>`;
constructForm(`{% url "api-build-list" %}${build_id}/scrap-outputs/`, {
method: 'POST',
preFormContent: html,
fields: {
location: {},
notes: {},
discard_allocations: {},
},
confirm: true,
title: '{% trans "Scrap Build Outputs" %}',
afterRender: function(fields, opts) {
// Setup callbacks to remove outputs
$(opts.modal).find('.button-row-remove').click(function() {
let pk = $(this).attr('pk');
$(opts.modal).find(`#output_row_${pk}`).remove();
});
},
onSubmit: function(fields, opts) {
let data = {
outputs: [],
location: getFormFieldValue('location', {}, opts),
notes: getFormFieldValue('notes', {}, opts),
discard_allocations: getFormFieldValue('discard_allocations', {type: 'boolean'}, opts),
};
let output_pk_values = [];
outputs.forEach(function(output) {
let pk = output.pk;
let row = $(opts.modal).find(`#output_row_${pk}`);
if (row.exists()) {
data.outputs.push({
output: pk,
});
output_pk_values.push(pk);
}
});
opts.nested = {
'outputs': output_pk_values,
};
inventreePut(
opts.url,
data,
{
method: 'POST',
success: function(response) {
$(opts.modal).modal('hide');
if (options.success) {
options.success(response);
}
},
error: function(xhr) {
switch (xhr.status) {
case 400:
handleFormErrors(xhr.responseJSON, fields, opts);
break;
default:
$(opts.modal).modal('hide');
showApiError(xhr, opts.url);
break;
}
}
}
);
}
});
}
/**
* Launch a modal form to delete selected build outputs
* Launch a modal form to delete selected build outputs.
* Deleted outputs are expunged from the database.
*/
function deleteBuildOutputs(build_id, outputs, options={}) {
@ -626,48 +754,6 @@ function deleteBuildOutputs(build_id, outputs, options={}) {
return;
}
// Render a single build output (StockItem)
function renderBuildOutput(output, opts={}) {
var pk = output.pk;
var output_html = imageHoverIcon(output.part_detail.thumbnail);
if (output.quantity == 1 && output.serial) {
output_html += `{% trans "Serial Number" %}: ${output.serial}`;
} else {
output_html += `{% trans "Quantity" %}: ${output.quantity}`;
if (output.part_detail && output.part_detail.units) {
output_html += ` ${output.part_detail.units} `;
}
}
var buttons = `<div class='btn-group float-right' role='group'>`;
buttons += makeRemoveButton('button-row-remove', pk, '{% trans "Remove row" %}');
buttons += '</div>';
var field = constructField(
`outputs_output_${pk}`,
{
type: 'raw',
html: output_html,
},
{
hideLabels: true,
}
);
var html = `
<tr id='output_row_${pk}'>
<td>${field}</td>
<td>${output.part_detail.full_name}</td>
<td>${buttons}</td>
</tr>`;
return html;
}
// Construct table entries
var table_entries = '';
@ -676,6 +762,13 @@ function deleteBuildOutputs(build_id, outputs, options={}) {
});
var html = `
<div class='alert alert-block alert-danger'>
{% trans "Selected build outputs will be deleted" %}
<ul>
<li>{% trans "Build output data will be permanently deleted" %}</li>
<li>{% trans "Allocated stock items will be returned to stock" %}</li>
</ul>
</div>
<table class='table table-striped table-condensed' id='build-complete-table'>
<thead>
<th colspan='2'>{% trans "Output" %}</th>
@ -952,8 +1045,25 @@ function loadBuildOutputTable(build_info, options={}) {
);
});
// Callback for the "delete" button
$(table).find('.button-output-delete').click(function() {
// Callback for the "scrap" button
$(table).find('.button-output-scrap').click(function() {
var pk = $(this).attr('pk');
var output = $(table).bootstrapTable('getRowByUniqueId', pk);
scrapBuildOutputs(
build_info.pk,
[output],
{
success: function() {
$(table).bootstrapTable('refresh');
$('#build-stock-table').bootstrapTable('refresh');
}
}
);
});
// Callback for the "remove" button
$(table).find('.button-output-remove').click(function() {
var pk = $(this).attr('pk');
var output = $(table).bootstrapTable('getRowByUniqueId', pk);
@ -1368,6 +1478,25 @@ function loadBuildOutputTable(build_info, options={}) {
// Add callbacks for the various table menubar buttons
// Scrap multiple outputs
$('#multi-output-scrap').click(function() {
var outputs = getTableData(table);
scrapBuildOutputs(
build_info.pk,
outputs,
{
success: function() {
// Reload the "in progress" table
$('#build-output-table').bootstrapTable('refresh');
// Reload the "completed" table
$('#build-stock-table').bootstrapTable('refresh');
}
}
);
});
// Complete multiple outputs
$('#multi-output-complete').click(function() {
var outputs = getTableData(table);

View File

@ -3,8 +3,6 @@
{% load status_codes %}
/* globals
attachSelect,
closeModal,
constructField,
constructFormBody,
getFormFieldValue,
@ -18,12 +16,6 @@
loadTableFilters,
makeIconBadge,
makeIconButton,
makeOptionsList,
modalEnable,
modalSetContent,
modalSetTitle,
modalSubmit,
openModal,
renderLink,
scanItemsIntoLocation,
showAlertDialog,
@ -54,7 +46,6 @@
serializeStockItem,
stockItemFields,
stockLocationFields,
stockStatusCodes,
uninstallStockItem,
*/
@ -603,22 +594,6 @@ function findStockItemBySerialNumber(part_id) {
}
/* Stock API functions
* Requires api.js to be loaded first
*/
function stockStatusCodes() {
return [
{% for code in StockStatus.list %}
{
key: {{ code.key }},
text: '{{ code.value }}',
},
{% endfor %}
];
}
/**
* Assign multiple stock items to a customer
*/
@ -2261,93 +2236,6 @@ function loadStockTable(table, options) {
orderParts(parts, {});
});
$('#multi-item-set-status').click(function() {
// Select and set the STATUS field for selected stock items
var selections = getTableData(table);
// Select stock status
var modal = '#modal-form';
var status_list = makeOptionsList(
stockStatusCodes(),
function(item) {
return item.text;
},
function(item) {
return item.key;
}
);
// Add an empty option at the start of the list
status_list.unshift('<option value="">---------</option>');
// Construct form
var html = `
<form method='post' action='' class='js-modal-form' enctype='multipart/form-data'>
<div class='form-group'>
<label class='control-label requiredField' for='id_status'>
{% trans "Stock Status" %}
</label>
<div class='controls'>
<select id='id_status' class='select form-control' name='label'>
${status_list}
</select>
</div>
</div>
</form>`;
openModal({
modal: modal,
});
modalEnable(modal, true);
modalSetTitle(modal, '{% trans "Set Stock Status" %}');
modalSetContent(modal, html);
attachSelect(modal);
modalSubmit(modal, function() {
var label = $(modal).find('#id_status');
var status_code = label.val();
closeModal(modal);
if (!status_code) {
showAlertDialog(
'{% trans "Select Status Code" %}',
'{% trans "Status code must be selected" %}'
);
return;
}
var requests = [];
selections.forEach(function(item) {
var url = `/api/stock/${item.pk}/`;
requests.push(
inventreePut(
url,
{
status: status_code,
},
{
method: 'PATCH',
success: function() {
}
}
)
);
});
$.when.apply($, requests).done(function() {
$(table).bootstrapTable('refresh');
});
});
});
$('#multi-item-delete').click(function() {
var selections = getTableData(table);
@ -2697,11 +2585,24 @@ function loadStockTrackingTable(table, options) {
html += '</td></tr>';
}
// BuildOrder Information
if (details.buildorder) {
html += `<tr><th>{% trans "Build Order" %}</th>`;
html += `<td>`;
if (details.buildorder_detail) {
html += renderLink(
details.buildorder_detail.reference,
`/build/${details.buildorder}/`
);
} else {
html += `<i>{% trans "Build order no longer exists" %}</i>`;
}
}
// PurchaseOrder Information
if (details.purchaseorder) {
html += `<tr><th>{% trans "Purchase Order" %}</th>`;
html += '<td>';
if (details.purchaseorder_detail) {

View File

@ -405,6 +405,12 @@ function getStockTestTableFilters() {
}
// Return a dictionary of filters for the "stocktracking" table
function getStockTrackingTableFilters() {
return {};
}
// Return a dictionary of filters for the "part tests" table
function getPartTestTemplateFilters() {
return {
@ -741,6 +747,8 @@ function getAvailableTableFilters(tableKey) {
return getStockTableFilters();
case 'stocktests':
return getStockTestTableFilters();
case 'stocktracking':
return getStockTrackingTableFilters();
case 'supplierpart':
return getSupplierPartFilters();
case 'usedin':

View File

@ -37,7 +37,6 @@
<li><a class='dropdown-item' href='#' id='multi-item-merge' title='{% trans "Merge selected stock items" %}'><span class='fas fa-object-group'></span> {% trans "Merge stock" %}</a></li>
<li><a class='dropdown-item' href='#' id='multi-item-order' title='{% trans "Order selected items" %}'><span class='fas fa-shopping-cart'></span> {% trans "Order stock" %}</a></li>
<li><a class='dropdown-item' href='#' id='multi-item-assign' title='{% trans "Assign to customer" %}'><span class='fas fa-user-tie'></span> {% trans "Assign to customer" %}</a></li>
<li><a class='dropdown-item' href='#' id='multi-item-set-status' title='{% trans "Change status" %}'><span class='fas fa-exclamation-circle'></span> {% trans "Change stock status" %}</a></li>
{% endif %}
{% if roles.stock.delete %}
<li><a class='dropdown-item' href='#' id='multi-item-delete' title='{% trans "Delete selected items" %}'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete stock" %}</a></li>