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:
@ -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
|
||||
|
||||
|
@ -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"),
|
||||
|
@ -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'),
|
||||
|
@ -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
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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" %}
|
||||
|
@ -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)
|
||||
|
@ -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.
|
||||
|
@ -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():
|
||||
|
19
InvenTree/stock/migrations/0099_alter_stockitem_status.py
Normal file
19
InvenTree/stock/migrations/0099_alter_stockitem_status.py
Normal 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)]),
|
||||
),
|
||||
]
|
@ -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);
|
||||
|
@ -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) {
|
||||
|
@ -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':
|
||||
|
@ -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>
|
||||
|
Reference in New Issue
Block a user