mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-14 19:15:41 +00:00
[Feature] External build order (#9312)
* Add BuildOrder reference to PurchaseOrderLineItem * Add setting to enable / disable external build orders * Fix for supplier part detail * Update forms * Filter build list by "external" status * Add "external" attribute to BuildOrder * Filter by external build when selecting against purchase order line item * Add frontend elements * Prevent creation of build outputs * Tweak related model field - Send filters when fetching initial data * Fix migrations * Fix some existing typos * Add build info when receiving line items * Logic fix * Bump API version * Updated relationship * Add external orders tab for order * Display table of external purchase orders against a build order * Fix permissions * Tweak field definition * Add unit tests * Tweak api_version.py * Playwright testing * Fix discrepancy in 'building' filter * Add basic documentation ( more work required ) * Tweak docs macros * Migration fix * Adjust build page tabs * Fix imports * Fix broken import * Update playywright tests * Bump API version * Handle DB issues * Improve filter * Cleaner code * Fix column ordering bug * Add filters to build output table * Documentation * Tweak unit test * Add "scheduled_for_production" field * Add helper function to part model * Cleanup
This commit is contained in:
@ -1,12 +1,15 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 347
|
||||
INVENTREE_API_VERSION = 348
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
v348 -> 2025-04-22 : https://github.com/inventree/InvenTree/pull/9312
|
||||
- Adds "external" flag for BuildOrder
|
||||
- Adds link between PurchaseOrderLineItem and BuildOrder
|
||||
|
||||
v347 -> 2025-06-12 : https://github.com/inventree/InvenTree/pull/9764
|
||||
- Adds "copy_tests" field to the DuplicatePart API endpoint
|
||||
|
@ -33,7 +33,7 @@ class BuildFilter(rest_filters.FilterSet):
|
||||
"""Metaclass options."""
|
||||
|
||||
model = Build
|
||||
fields = ['sales_order']
|
||||
fields = ['sales_order', 'external']
|
||||
|
||||
status = rest_filters.NumberFilter(label=_('Order Status'), method='filter_status')
|
||||
|
||||
@ -355,6 +355,7 @@ class BuildList(DataExportViewMixin, BuildMixin, ListCreateAPI):
|
||||
'project_code',
|
||||
'priority',
|
||||
'level',
|
||||
'external',
|
||||
]
|
||||
|
||||
ordering_field_aliases = {
|
||||
|
@ -0,0 +1,22 @@
|
||||
# Generated by Django 4.2.20 on 2025-03-13 22:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("build", "0056_alter_build_link"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="build",
|
||||
name="external",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="This build order is fulfilled externally",
|
||||
verbose_name="External Build",
|
||||
),
|
||||
),
|
||||
]
|
@ -96,6 +96,7 @@ class Build(
|
||||
sales_order: References to a SalesOrder object for which this Build is required (e.g. the output of this build will be used to fulfil a sales order)
|
||||
take_from: Location to take stock from to make this build (if blank, can take from anywhere)
|
||||
status: Build status code
|
||||
external: Set to indicate that this build order is fulfilled externally
|
||||
batch: Batch code transferred to build parts (optional)
|
||||
creation_date: Date the build was created (auto)
|
||||
target_date: Date the build will be overdue
|
||||
@ -191,6 +192,13 @@ class Build(
|
||||
"""Validate the BuildOrder model."""
|
||||
super().clean()
|
||||
|
||||
if self.external and not self.part.purchaseable:
|
||||
raise ValidationError({
|
||||
'external': _(
|
||||
'Build orders can only be externally fulfilled for purchaseable parts'
|
||||
)
|
||||
})
|
||||
|
||||
if get_global_setting('BUILDORDER_REQUIRE_RESPONSIBLE'):
|
||||
if not self.responsible:
|
||||
raise ValidationError({
|
||||
@ -286,6 +294,12 @@ class Build(
|
||||
),
|
||||
)
|
||||
|
||||
external = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('External Build'),
|
||||
help_text=_('This build order is fulfilled externally'),
|
||||
)
|
||||
|
||||
destination = models.ForeignKey(
|
||||
'stock.StockLocation',
|
||||
verbose_name=_('Destination Location'),
|
||||
|
@ -73,6 +73,7 @@ class BuildSerializer(
|
||||
'completed',
|
||||
'completion_date',
|
||||
'destination',
|
||||
'external',
|
||||
'parent',
|
||||
'part',
|
||||
'part_name',
|
||||
|
@ -9,18 +9,21 @@ from django.core.exceptions import ValidationError
|
||||
from django.db.models import Sum
|
||||
from django.test import TestCase
|
||||
from django.test.utils import override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
import structlog
|
||||
|
||||
import build.tasks
|
||||
import common.models
|
||||
import company.models
|
||||
from build.models import Build, BuildItem, BuildLine, generate_next_build_reference
|
||||
from build.status_codes import BuildStatus
|
||||
from common.settings import set_global_setting
|
||||
from InvenTree import status_codes as status
|
||||
from InvenTree.unit_test import findOffloadedEvent
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase, findOffloadedEvent
|
||||
from order.models import PurchaseOrder, PurchaseOrderLineItem
|
||||
from part.models import BomItem, BomItemSubstitute, Part, PartTestTemplate
|
||||
from stock.models import StockItem, StockItemTestResult
|
||||
from stock.models import StockItem, StockItemTestResult, StockLocation
|
||||
from users.models import Owner
|
||||
|
||||
logger = structlog.get_logger('inventree')
|
||||
@ -809,3 +812,142 @@ class AutoAllocationTests(BuildTestBase):
|
||||
|
||||
self.assertEqual(self.line_1.unallocated_quantity(), 0)
|
||||
self.assertEqual(self.line_2.unallocated_quantity(), 0)
|
||||
|
||||
|
||||
class ExternalBuildTest(InvenTreeAPITestCase):
|
||||
"""Unit tests for external build order functionality."""
|
||||
|
||||
def test_validation(self):
|
||||
"""Test validation of external build logic."""
|
||||
part = Part.objects.create(
|
||||
name='Test part', description='A test part', purchaseable=False
|
||||
)
|
||||
|
||||
# Create a build order
|
||||
# Cannot create an external build for a non-purchaseable part
|
||||
with self.assertRaises(ValidationError) as err:
|
||||
build = Build.objects.create(
|
||||
part=part, title='Test build order', quantity=10, external=True
|
||||
)
|
||||
|
||||
build.clean()
|
||||
|
||||
self.assertIn(
|
||||
'Build orders can only be externally fulfilled for purchaseable parts',
|
||||
str(err.exception.messages),
|
||||
)
|
||||
|
||||
def test_logic(self):
|
||||
"""Test external build logic."""
|
||||
# Create a purchaseable assembly part
|
||||
assembly = Part.objects.create(
|
||||
name='Test assembly',
|
||||
description='A test assembly',
|
||||
purchaseable=True,
|
||||
assembly=True,
|
||||
active=True,
|
||||
)
|
||||
|
||||
# Create a supplier part
|
||||
supplier = company.models.Company.objects.create(
|
||||
name='Test supplier', active=True, is_supplier=True
|
||||
)
|
||||
|
||||
supplier_part = company.models.SupplierPart.objects.create(
|
||||
part=assembly, supplier=supplier, SKU='TEST-123'
|
||||
)
|
||||
|
||||
# Create a build order against the assembly
|
||||
build = Build.objects.create(
|
||||
part=assembly, title='Test build order', quantity=10, external=True
|
||||
)
|
||||
|
||||
# Order some parts
|
||||
po = PurchaseOrder.objects.create(supplier=supplier, reference='PO-9999')
|
||||
|
||||
# Create a line item to fulfil the build order
|
||||
po_line = PurchaseOrderLineItem.objects.create(
|
||||
order=po, part=supplier_part, quantity=10, build_order=build
|
||||
)
|
||||
|
||||
# Validate starting conditions
|
||||
self.assertEqual(build.quantity, 10)
|
||||
self.assertEqual(build.completed, 0)
|
||||
self.assertEqual(build.build_outputs.count(), 0)
|
||||
self.assertEqual(build.consumed_stock.count(), 0)
|
||||
|
||||
# PLACE the order
|
||||
po.place_order()
|
||||
|
||||
location = StockLocation.objects.first()
|
||||
|
||||
# Receive half the items against the purchase order
|
||||
po.receive_line_item(po_line, location, 5, self.user)
|
||||
|
||||
# As the order was incomplete, the build output has been marked as "building"
|
||||
self.assertEqual(build.quantity, 10)
|
||||
self.assertEqual(build.completed, 0)
|
||||
self.assertEqual(build.build_outputs.count(), 1)
|
||||
|
||||
output = build.build_outputs.first()
|
||||
self.assertTrue(output.is_building)
|
||||
|
||||
build.complete_build_output(output, self.user)
|
||||
build.refresh_from_db()
|
||||
self.assertEqual(build.completed, 5)
|
||||
|
||||
output.refresh_from_db()
|
||||
self.assertFalse(output.is_building)
|
||||
|
||||
# Mark the build order as completed
|
||||
build.complete_build(self.user)
|
||||
self.assertEqual(build.status, BuildStatus.COMPLETE)
|
||||
|
||||
# Receive the rest of the line item
|
||||
po.receive_line_item(po_line, location, 5, self.user)
|
||||
po_line.refresh_from_db()
|
||||
self.assertEqual(po_line.received, 10)
|
||||
|
||||
build.refresh_from_db()
|
||||
self.assertEqual(build.completed, 10)
|
||||
self.assertEqual(build.build_outputs.count(), 2)
|
||||
|
||||
# As the build was already completed, output has been marked as "complete" too
|
||||
output = build.build_outputs.order_by('-pk').first()
|
||||
self.assertFalse(output.is_building)
|
||||
|
||||
def test_api_filter(self):
|
||||
"""Test that the 'external' API filter works as expected."""
|
||||
self.assignRole('build.view')
|
||||
|
||||
# Create a purchaseable assembly part
|
||||
assembly = Part.objects.create(
|
||||
name='Test assembly',
|
||||
description='A test assembly',
|
||||
purchaseable=True,
|
||||
assembly=True,
|
||||
active=True,
|
||||
)
|
||||
|
||||
# Create some build orders
|
||||
for i in range(5):
|
||||
Build.objects.create(
|
||||
part=assembly,
|
||||
title=f'Test build order {i}',
|
||||
quantity=10,
|
||||
external=i % 2 == 0,
|
||||
)
|
||||
|
||||
url = reverse('api-build-list')
|
||||
|
||||
response = self.get(url)
|
||||
|
||||
self.assertEqual(len(response.data), 5)
|
||||
|
||||
# Filter by 'external'
|
||||
response = self.get(url, {'external': 'true'})
|
||||
self.assertEqual(len(response.data), 3)
|
||||
|
||||
# Filter by 'not external'
|
||||
response = self.get(url, {'external': 'false'})
|
||||
self.assertEqual(len(response.data), 2)
|
||||
|
@ -756,6 +756,12 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'BUILDORDER_EXTERNAL_BUILDS': {
|
||||
'name': _('External Build Orders'),
|
||||
'description': _('Enable external build order functionality'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS': {
|
||||
'name': _('Block Until Tests Pass'),
|
||||
'description': _(
|
||||
|
@ -19,6 +19,7 @@ from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
import build.models
|
||||
import common.models
|
||||
import common.settings
|
||||
import company.models
|
||||
@ -324,6 +325,22 @@ class PurchaseOrderFilter(OrderFilter):
|
||||
label=_('Completed After'), field_name='complete_date', lookup_expr='gt'
|
||||
)
|
||||
|
||||
external_build = rest_filters.ModelChoiceFilter(
|
||||
queryset=build.models.Build.objects.filter(external=True),
|
||||
method='filter_external_build',
|
||||
label=_('External Build Order'),
|
||||
)
|
||||
|
||||
@extend_schema_field(
|
||||
rest_framework.serializers.IntegerField(help_text=_('External Build Order'))
|
||||
)
|
||||
def filter_external_build(self, queryset, name, build):
|
||||
"""Filter to only include orders which fill fulfil the provided Build Order.
|
||||
|
||||
To achieve this, we return any order which has a line item which is allocated to the build order.
|
||||
"""
|
||||
return queryset.filter(lines__build_order=build).distinct()
|
||||
|
||||
|
||||
class PurchaseOrderMixin:
|
||||
"""Mixin class for PurchaseOrder endpoints."""
|
||||
|
@ -0,0 +1,29 @@
|
||||
# Generated by Django 4.2.20 on 2025-04-11 04:59
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("build", "0057_build_external"),
|
||||
("order", "0110_salesordershipment_barcode_data_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="purchaseorderlineitem",
|
||||
name="build_order",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="External Build Order to be fulfilled by this line item",
|
||||
limit_choices_to={"external": True},
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="external_line_items",
|
||||
to="build.build",
|
||||
verbose_name="Build Order",
|
||||
),
|
||||
),
|
||||
]
|
@ -30,6 +30,7 @@ import order.validators
|
||||
import report.mixins
|
||||
import stock.models
|
||||
import users.models as UserModels
|
||||
from build.status_codes import BuildStatus
|
||||
from common.currency import currency_code_default
|
||||
from common.notifications import InvenTreeNotificationBodies
|
||||
from common.settings import get_global_setting
|
||||
@ -279,7 +280,7 @@ class Order(
|
||||
|
||||
Instances of this class:
|
||||
|
||||
- PuchaseOrder
|
||||
- PurchaseOrder
|
||||
- SalesOrder
|
||||
|
||||
Attributes:
|
||||
@ -558,7 +559,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
||||
|
||||
@classmethod
|
||||
def get_status_class(cls):
|
||||
"""Return the PurchasOrderStatus class."""
|
||||
"""Return the PurchaseOrderStatus class."""
|
||||
return PurchaseOrderStatusGroups
|
||||
|
||||
@classmethod
|
||||
@ -923,7 +924,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
||||
status: The StockStatus to assign to the item (default: StockStatus.OK)
|
||||
|
||||
Keyword Arguments:
|
||||
barch_code: Optional batch code for the new StockItem
|
||||
batch_code: Optional batch code for the new StockItem
|
||||
serials: Optional list of serial numbers to assign to the new StockItem(s)
|
||||
notes: Optional notes field for the StockItem
|
||||
packaging: Optional packaging field for the StockItem
|
||||
@ -1005,20 +1006,61 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
||||
serialize = False
|
||||
serials = [None]
|
||||
|
||||
# Construct dataset for receiving items
|
||||
data = {
|
||||
'part': line.part.part,
|
||||
'supplier_part': line.part,
|
||||
'location': location,
|
||||
'quantity': 1 if serialize else stock_quantity,
|
||||
'purchase_order': self,
|
||||
'status': status,
|
||||
'batch': batch_code,
|
||||
'expiry_date': expiry_date,
|
||||
'packaging': packaging,
|
||||
'purchase_price': unit_purchase_price,
|
||||
}
|
||||
|
||||
if build_order := line.build_order:
|
||||
# Receiving items against an "external" build order
|
||||
|
||||
if not build_order.external:
|
||||
raise ValidationError(
|
||||
'Cannot receive items against an internal build order'
|
||||
)
|
||||
|
||||
if build_order.part != data['part']:
|
||||
raise ValidationError(
|
||||
'Cannot receive items against a build order for a different part'
|
||||
)
|
||||
|
||||
if not location and build_order.destination:
|
||||
# Override with the build order destination (if not specified)
|
||||
data['location'] = location = build_order.destination
|
||||
|
||||
if build_order.active:
|
||||
# An 'active' build order marks the items as "in production"
|
||||
data['build'] = build_order
|
||||
data['is_building'] = True
|
||||
elif build_order.status == BuildStatus.COMPLETE:
|
||||
# A 'completed' build order marks the items as "completed"
|
||||
data['build'] = build_order
|
||||
data['is_building'] = False
|
||||
|
||||
# Increase the 'completed' quantity for the build order
|
||||
build_order.completed += stock_quantity
|
||||
build_order.save()
|
||||
|
||||
elif build_order.status == BuildStatus.CANCELLED:
|
||||
# A 'cancelled' build order is ignored
|
||||
pass
|
||||
else:
|
||||
# Un-handled state - raise an error
|
||||
raise ValidationError(
|
||||
"Cannot receive items against a build order in state '{build_order.status}'"
|
||||
)
|
||||
|
||||
for sn in serials:
|
||||
item = stock.models.StockItem(
|
||||
part=line.part.part,
|
||||
supplier_part=line.part,
|
||||
location=location,
|
||||
quantity=1 if serialize else stock_quantity,
|
||||
purchase_order=self,
|
||||
status=status,
|
||||
batch=batch_code,
|
||||
expiry_date=expiry_date,
|
||||
packaging=packaging,
|
||||
serial=sn,
|
||||
purchase_price=unit_purchase_price,
|
||||
)
|
||||
item = stock.models.StockItem(serial=sn, **data)
|
||||
|
||||
# Assign the provided barcode
|
||||
if barcode:
|
||||
@ -1639,6 +1681,11 @@ class PurchaseOrderLineItem(OrderLineItem):
|
||||
|
||||
Attributes:
|
||||
order: Reference to a PurchaseOrder object
|
||||
part: Reference to a SupplierPart object
|
||||
received: Number of items received
|
||||
purchase_price: Unit purchase price for this line item
|
||||
build_order: Link to an external BuildOrder to be fulfilled by this line item
|
||||
destination: Destination for received items
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
@ -1670,6 +1717,25 @@ class PurchaseOrderLineItem(OrderLineItem):
|
||||
if self.part.supplier != self.order.supplier:
|
||||
raise ValidationError({'part': _('Supplier part must match supplier')})
|
||||
|
||||
if self.build_order:
|
||||
if not self.build_order.external:
|
||||
raise ValidationError({
|
||||
'build_order': _('Build order must be marked as external')
|
||||
})
|
||||
|
||||
if part := self.part.part:
|
||||
if not part.assembly:
|
||||
raise ValidationError({
|
||||
'build_order': _(
|
||||
'Build orders can only be linked to assembly parts'
|
||||
)
|
||||
})
|
||||
|
||||
if self.build_order.part != self.part.part:
|
||||
raise ValidationError({
|
||||
'build_order': _('Build order part must match line item part')
|
||||
})
|
||||
|
||||
def __str__(self):
|
||||
"""Render a string representation of a PurchaseOrderLineItem instance."""
|
||||
return '{n} x {part} - {po}'.format(
|
||||
@ -1727,6 +1793,17 @@ class PurchaseOrderLineItem(OrderLineItem):
|
||||
"""Return the 'purchase_price' field as 'price'."""
|
||||
return self.purchase_price
|
||||
|
||||
build_order = models.ForeignKey(
|
||||
'build.Build',
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True,
|
||||
related_name='external_line_items',
|
||||
limit_choices_to={'external': True},
|
||||
null=True,
|
||||
verbose_name=_('Build Order'),
|
||||
help_text=_('External Build Order to be fulfilled by this line item'),
|
||||
)
|
||||
|
||||
destination = TreeForeignKey(
|
||||
'stock.StockLocation',
|
||||
on_delete=models.SET_NULL,
|
||||
@ -2544,7 +2621,7 @@ class ReturnOrder(TotalPriceMixin, Order):
|
||||
|
||||
@transaction.atomic
|
||||
def hold_order(self):
|
||||
"""Attempt to tranasition to ON_HOLD status."""
|
||||
"""Attempt to transition to ON_HOLD status."""
|
||||
return self.handle_transition(
|
||||
self.status, ReturnOrderStatus.ON_HOLD.value, self, self._action_hold
|
||||
)
|
||||
|
@ -21,6 +21,7 @@ from rest_framework import serializers
|
||||
from rest_framework.serializers import ValidationError
|
||||
from sql_util.utils import SubqueryCount, SubquerySum
|
||||
|
||||
import build.serializers
|
||||
import order.models
|
||||
import part.filters as part_filters
|
||||
import part.models as part_models
|
||||
@ -496,6 +497,8 @@ class PurchaseOrderLineItemSerializer(
|
||||
'notes',
|
||||
'order',
|
||||
'order_detail',
|
||||
'build_order',
|
||||
'build_order_detail',
|
||||
'overdue',
|
||||
'part_detail',
|
||||
'supplier_part_detail',
|
||||
@ -645,6 +648,10 @@ class PurchaseOrderLineItemSerializer(
|
||||
source='order', read_only=True, allow_null=True, many=False
|
||||
)
|
||||
|
||||
build_order_detail = build.serializers.BuildSerializer(
|
||||
source='build_order', read_only=True, many=False
|
||||
)
|
||||
|
||||
merge_items = serializers.BooleanField(
|
||||
label=_('Merge Items'),
|
||||
help_text=_(
|
||||
|
@ -44,7 +44,9 @@ from order.status_codes import PurchaseOrderStatusGroups, SalesOrderStatusGroups
|
||||
def annotate_in_production_quantity(reference=''):
|
||||
"""Annotate the 'in production' quantity for each part in a queryset.
|
||||
|
||||
Sum the 'quantity' field for all stock items which are 'in production' for each part.
|
||||
- Sum the 'quantity' field for all stock items which are 'in production' for each part.
|
||||
- This is the total quantity of "incomplete build outputs" for all active builds
|
||||
- This will return the same quantity as the 'quantity_in_production' method on the Part model
|
||||
|
||||
Arguments:
|
||||
reference: Reference to the part from the current queryset (default = '')
|
||||
@ -60,6 +62,28 @@ def annotate_in_production_quantity(reference=''):
|
||||
)
|
||||
|
||||
|
||||
def annotate_scheduled_to_build_quantity(reference: str = ''):
|
||||
"""Annotate the 'scheduled to build' quantity for each part in a queryset.
|
||||
|
||||
- This is total scheduled quantity for all build orders which are 'active'
|
||||
- This may be different to the "in production" quantity
|
||||
- This will return the same quantity as the 'quantity_being_built' method no the Part model
|
||||
"""
|
||||
building_filter = Q(status__in=BuildStatusGroups.ACTIVE_CODES)
|
||||
|
||||
return Coalesce(
|
||||
SubquerySum(
|
||||
ExpressionWrapper(
|
||||
F(f'{reference}builds__quantity') - F(f'{reference}builds__completed'),
|
||||
output_field=DecimalField(),
|
||||
),
|
||||
filter=building_filter,
|
||||
),
|
||||
Decimal(0),
|
||||
output_field=DecimalField(),
|
||||
)
|
||||
|
||||
|
||||
def annotate_on_order_quantity(reference: str = ''):
|
||||
"""Annotate the 'on order' quantity for each part in a queryset.
|
||||
|
||||
|
@ -1627,6 +1627,24 @@ class Part(
|
||||
|
||||
return quantity
|
||||
|
||||
@property
|
||||
def quantity_in_production(self):
|
||||
"""Quantity of this part currently actively in production.
|
||||
|
||||
Note: This may return a different value to `quantity_being_built`
|
||||
"""
|
||||
quantity = 0
|
||||
|
||||
items = self.stock_items.filter(
|
||||
is_building=True, build__status__in=BuildStatusGroups.ACTIVE_CODES
|
||||
)
|
||||
|
||||
for item in items:
|
||||
# The remaining items in the build
|
||||
quantity += item.quantity
|
||||
|
||||
return quantity
|
||||
|
||||
def build_order_allocations(self, **kwargs):
|
||||
"""Return all 'BuildItem' objects which allocate this part to Build objects."""
|
||||
include_variants = kwargs.get('include_variants', True)
|
||||
|
@ -18,7 +18,7 @@ from djmoney.contrib.exchange.exceptions import MissingRate
|
||||
from djmoney.contrib.exchange.models import convert_money
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
from sql_util.utils import SubqueryCount, SubquerySum
|
||||
from sql_util.utils import SubqueryCount
|
||||
from taggit.serializers import TagListSerializerField
|
||||
|
||||
import common.currency
|
||||
@ -33,7 +33,6 @@ import part.stocktake
|
||||
import part.tasks
|
||||
import stock.models
|
||||
import users.models
|
||||
from build.status_codes import BuildStatusGroups
|
||||
from importer.registry import register_importer
|
||||
from InvenTree.mixins import DataImportExportSerializerMixin
|
||||
from InvenTree.ready import isGeneratingSchema
|
||||
@ -724,6 +723,7 @@ class PartSerializer(
|
||||
'allocated_to_build_orders',
|
||||
'allocated_to_sales_orders',
|
||||
'building',
|
||||
'scheduled_to_build',
|
||||
'category_default_location',
|
||||
'in_stock',
|
||||
'ordering',
|
||||
@ -831,16 +831,13 @@ class PartSerializer(
|
||||
)
|
||||
)
|
||||
|
||||
# Filter to limit builds to "active"
|
||||
build_filter = Q(status__in=BuildStatusGroups.ACTIVE_CODES)
|
||||
|
||||
# Annotate with the total 'building' quantity
|
||||
queryset = queryset.annotate(
|
||||
building=Coalesce(
|
||||
SubquerySum('builds__quantity', filter=build_filter),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
building=part_filters.annotate_in_production_quantity()
|
||||
)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
scheduled_to_build=part_filters.annotate_scheduled_to_build_quantity()
|
||||
)
|
||||
|
||||
# Annotate with the number of 'suppliers'
|
||||
@ -947,42 +944,65 @@ class PartSerializer(
|
||||
# Annotated fields
|
||||
allocated_to_build_orders = serializers.FloatField(read_only=True, allow_null=True)
|
||||
allocated_to_sales_orders = serializers.FloatField(read_only=True, allow_null=True)
|
||||
|
||||
building = serializers.FloatField(
|
||||
read_only=True, allow_null=True, label=_('Building')
|
||||
read_only=True,
|
||||
allow_null=True,
|
||||
label=_('Building'),
|
||||
help_text=_('Quantity of this part currently being in production'),
|
||||
)
|
||||
|
||||
scheduled_to_build = serializers.FloatField(
|
||||
read_only=True,
|
||||
allow_null=True,
|
||||
label=_('Scheduled to Build'),
|
||||
help_text=_('Outstanding quantity of this part scheduled to be built'),
|
||||
)
|
||||
|
||||
in_stock = serializers.FloatField(
|
||||
read_only=True, allow_null=True, label=_('In Stock')
|
||||
)
|
||||
|
||||
ordering = serializers.FloatField(
|
||||
read_only=True, allow_null=True, label=_('On Order')
|
||||
)
|
||||
|
||||
required_for_build_orders = serializers.IntegerField(
|
||||
read_only=True, allow_null=True
|
||||
)
|
||||
|
||||
required_for_sales_orders = serializers.IntegerField(
|
||||
read_only=True, allow_null=True
|
||||
)
|
||||
|
||||
stock_item_count = serializers.IntegerField(
|
||||
read_only=True, allow_null=True, label=_('Stock Items')
|
||||
)
|
||||
|
||||
revision_count = serializers.IntegerField(
|
||||
read_only=True, allow_null=True, label=_('Revisions')
|
||||
)
|
||||
|
||||
suppliers = serializers.IntegerField(
|
||||
read_only=True, allow_null=True, label=_('Suppliers')
|
||||
)
|
||||
|
||||
total_in_stock = serializers.FloatField(
|
||||
read_only=True, allow_null=True, label=_('Total Stock')
|
||||
)
|
||||
|
||||
external_stock = serializers.FloatField(
|
||||
read_only=True, allow_null=True, label=_('External Stock')
|
||||
)
|
||||
|
||||
unallocated_stock = serializers.FloatField(
|
||||
read_only=True, allow_null=True, label=_('Unallocated Stock')
|
||||
)
|
||||
|
||||
category_default_location = serializers.IntegerField(
|
||||
read_only=True, allow_null=True
|
||||
)
|
||||
|
||||
variant_stock = serializers.FloatField(
|
||||
read_only=True, allow_null=True, label=_('Variant Stock')
|
||||
)
|
||||
|
@ -181,6 +181,7 @@ class ScheduleMixin:
|
||||
obj['args'] = f"'{slug}', '{func_name}'"
|
||||
|
||||
tasks = Schedule.objects.filter(name=task_name)
|
||||
|
||||
if len(tasks) > 1:
|
||||
logger.info(
|
||||
"Found multiple tasks; Adding a new scheduled task '%s'",
|
||||
@ -191,10 +192,11 @@ class ScheduleMixin:
|
||||
elif len(tasks) == 1:
|
||||
# Scheduled task already exists - update it!
|
||||
logger.info("Updating scheduled task '%s'", task_name)
|
||||
instance = Schedule.objects.get(name=task_name)
|
||||
for item in obj:
|
||||
setattr(instance, item, obj[item])
|
||||
instance.save()
|
||||
|
||||
if instance := tasks.first():
|
||||
for item in obj:
|
||||
setattr(instance, item, obj[item])
|
||||
instance.save()
|
||||
else:
|
||||
logger.info("Adding scheduled task '%s'", task_name)
|
||||
# Create a new scheduled task
|
||||
|
@ -410,6 +410,7 @@ function ProgressBarValue(props: Readonly<FieldProps>) {
|
||||
|
||||
return (
|
||||
<ProgressBar
|
||||
size='lg'
|
||||
value={props.field_data.progress}
|
||||
maximum={props.field_data.total}
|
||||
progressLabel
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
|
||||
import { ApiEndpoints, apiUrl } from '@lib/index';
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { api } from '../../../../App';
|
||||
import type { PreviewAreaComponent } from '../TemplateEditor';
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { ApiEndpoints, apiUrl } from '@lib/index';
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import {
|
||||
|
@ -71,24 +71,30 @@ export function RelatedModelField({
|
||||
return;
|
||||
}
|
||||
|
||||
api.get(url).then((response) => {
|
||||
const pk_field = definition.pk_field ?? 'pk';
|
||||
if (response.data?.[pk_field]) {
|
||||
const value = {
|
||||
value: response.data[pk_field],
|
||||
data: response.data
|
||||
};
|
||||
const params = definition?.filters ?? {};
|
||||
|
||||
// Run custom callback for this field (if provided)
|
||||
if (definition.onValueChange) {
|
||||
definition.onValueChange(response.data[pk_field], response.data);
|
||||
api
|
||||
.get(url, {
|
||||
params: params
|
||||
})
|
||||
.then((response) => {
|
||||
const pk_field = definition.pk_field ?? 'pk';
|
||||
if (response.data?.[pk_field]) {
|
||||
const value = {
|
||||
value: response.data[pk_field],
|
||||
data: response.data
|
||||
};
|
||||
|
||||
// Run custom callback for this field (if provided)
|
||||
if (definition.onValueChange) {
|
||||
definition.onValueChange(response.data[pk_field], response.data);
|
||||
}
|
||||
|
||||
setInitialData(value);
|
||||
dataRef.current = [value];
|
||||
setPk(response.data[pk_field]);
|
||||
}
|
||||
|
||||
setInitialData(value);
|
||||
dataRef.current = [value];
|
||||
setPk(response.data[pk_field]);
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
setPk(null);
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { UserRoles } from '@lib/index';
|
||||
import type { ApiFormFieldSet } from '@lib/types/Forms';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Table } from '@mantine/core';
|
||||
|
@ -126,7 +126,8 @@ export function useBuildOrderFields({
|
||||
filters: {
|
||||
is_active: true
|
||||
}
|
||||
}
|
||||
},
|
||||
external: {}
|
||||
};
|
||||
|
||||
if (create) {
|
||||
@ -137,6 +138,10 @@ export function useBuildOrderFields({
|
||||
delete fields.project_code;
|
||||
}
|
||||
|
||||
if (!globalSettings.isSet('BUILDORDER_EXTERNAL_BUILDS', true)) {
|
||||
delete fields.external;
|
||||
}
|
||||
|
||||
return fields;
|
||||
}, [create, destination, batchCode, globalSettings]);
|
||||
}
|
||||
|
@ -64,9 +64,14 @@ export function usePurchaseOrderLineItemFields({
|
||||
orderId?: number;
|
||||
create?: boolean;
|
||||
}) {
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
|
||||
const [purchasePrice, setPurchasePrice] = useState<string>('');
|
||||
const [autoPricing, setAutoPricing] = useState(true);
|
||||
|
||||
// Internal part information
|
||||
const [part, setPart] = useState<any>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (autoPricing) {
|
||||
setPurchasePrice('');
|
||||
@ -92,6 +97,9 @@ export function usePurchaseOrderLineItemFields({
|
||||
active: true,
|
||||
part_active: true
|
||||
},
|
||||
onValueChange: (value, record) => {
|
||||
setPart(record?.part_detail ?? {});
|
||||
},
|
||||
adjustFilters: (adjust: ApiFormAdjustFilterType) => {
|
||||
return {
|
||||
...adjust.filters,
|
||||
@ -119,6 +127,14 @@ export function usePurchaseOrderLineItemFields({
|
||||
destination: {
|
||||
icon: <IconSitemap />
|
||||
},
|
||||
build_order: {
|
||||
disabled: !part?.assembly,
|
||||
filters: {
|
||||
external: true,
|
||||
outstanding: true,
|
||||
part: part?.pk
|
||||
}
|
||||
},
|
||||
notes: {
|
||||
icon: <IconNotes />
|
||||
},
|
||||
@ -127,12 +143,24 @@ export function usePurchaseOrderLineItemFields({
|
||||
}
|
||||
};
|
||||
|
||||
if (!globalSettings.isSet('BUILDORDER_EXTERNAL_BUILDS', false)) {
|
||||
delete fields.build_order;
|
||||
}
|
||||
|
||||
if (create) {
|
||||
fields['merge_items'] = {};
|
||||
}
|
||||
|
||||
return fields;
|
||||
}, [create, orderId, supplierId, autoPricing, purchasePrice]);
|
||||
}, [
|
||||
create,
|
||||
orderId,
|
||||
part,
|
||||
globalSettings,
|
||||
supplierId,
|
||||
autoPricing,
|
||||
purchasePrice
|
||||
]);
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
@ -32,7 +32,7 @@ import RemoveRowButton from '../components/buttons/RemoveRowButton';
|
||||
import { StandaloneField } from '../components/forms/StandaloneField';
|
||||
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { getDetailUrl } from '@lib/index';
|
||||
import { getDetailUrl } from '@lib/functions/Navigation';
|
||||
import type {
|
||||
ApiFormAdjustFilterType,
|
||||
ApiFormFieldChoice,
|
||||
|
@ -248,6 +248,7 @@ export default function SystemSettings() {
|
||||
<GlobalSettingList
|
||||
keys={[
|
||||
'BUILDORDER_REFERENCE_PATTERN',
|
||||
'BUILDORDER_EXTERNAL_BUILDS',
|
||||
'BUILDORDER_REQUIRE_RESPONSIBLE',
|
||||
'BUILDORDER_REQUIRE_ACTIVE_PART',
|
||||
'BUILDORDER_REQUIRE_LOCKED_PART',
|
||||
|
@ -8,6 +8,7 @@ import {
|
||||
IconList,
|
||||
IconListCheck,
|
||||
IconListNumbers,
|
||||
IconShoppingCart,
|
||||
IconSitemap
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
@ -25,6 +26,7 @@ import {
|
||||
type DetailsField,
|
||||
DetailsTable
|
||||
} from '../../components/details/Details';
|
||||
import DetailsBadge from '../../components/details/DetailsBadge';
|
||||
import { DetailsImage } from '../../components/details/DetailsImage';
|
||||
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
|
||||
import {
|
||||
@ -49,12 +51,14 @@ import {
|
||||
} from '../../hooks/UseForm';
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import useStatusCodes from '../../hooks/UseStatusCodes';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import BuildAllocatedStockTable from '../../tables/build/BuildAllocatedStockTable';
|
||||
import BuildLineTable from '../../tables/build/BuildLineTable';
|
||||
import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
|
||||
import BuildOrderTestTable from '../../tables/build/BuildOrderTestTable';
|
||||
import BuildOutputTable from '../../tables/build/BuildOutputTable';
|
||||
import { PurchaseOrderTable } from '../../tables/purchasing/PurchaseOrderTable';
|
||||
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
||||
|
||||
/**
|
||||
@ -64,6 +68,7 @@ export default function BuildDetail() {
|
||||
const { id } = useParams();
|
||||
|
||||
const user = useUserState();
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
|
||||
const buildStatus = useStatusCodes({ modelType: ModelType.build });
|
||||
|
||||
@ -124,6 +129,24 @@ export default function BuildDetail() {
|
||||
hidden:
|
||||
!build.status_custom_key || build.status_custom_key == build.status
|
||||
},
|
||||
{
|
||||
type: 'boolean',
|
||||
name: 'external',
|
||||
label: t`External`,
|
||||
icon: 'manufacturers',
|
||||
hidden: !build.external
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'purchase_order',
|
||||
label: t`Purchase Order`,
|
||||
icon: 'purchase_orders',
|
||||
copy: true,
|
||||
hidden: !build.external,
|
||||
value_formatter: () => {
|
||||
return 'TODO: external PO';
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'reference',
|
||||
@ -287,10 +310,38 @@ export default function BuildDetail() {
|
||||
},
|
||||
{
|
||||
name: 'line-items',
|
||||
label: t`Line Items`,
|
||||
label: t`Required Stock`,
|
||||
icon: <IconListNumbers />,
|
||||
content: build?.pk ? <BuildLineTable build={build} /> : <Skeleton />
|
||||
},
|
||||
{
|
||||
name: 'allocated-stock',
|
||||
label: t`Allocated Stock`,
|
||||
icon: <IconList />,
|
||||
hidden:
|
||||
build.status == buildStatus.COMPLETE ||
|
||||
build.status == buildStatus.CANCELLED,
|
||||
content: build.pk ? (
|
||||
<BuildAllocatedStockTable buildId={build.pk} showPartInfo allowEdit />
|
||||
) : (
|
||||
<Skeleton />
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'consumed-stock',
|
||||
label: t`Consumed Stock`,
|
||||
icon: <IconListCheck />,
|
||||
content: (
|
||||
<StockItemTable
|
||||
allowAdd={false}
|
||||
tableName='build-consumed'
|
||||
showLocation={false}
|
||||
params={{
|
||||
consumed_by: id
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'incomplete-outputs',
|
||||
label: t`Incomplete Outputs`,
|
||||
@ -320,32 +371,18 @@ export default function BuildDetail() {
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'allocated-stock',
|
||||
label: t`Allocated Stock`,
|
||||
icon: <IconList />,
|
||||
hidden:
|
||||
build.status == buildStatus.COMPLETE ||
|
||||
build.status == buildStatus.CANCELLED,
|
||||
name: 'external-purchase-orders',
|
||||
label: t`External Orders`,
|
||||
icon: <IconShoppingCart />,
|
||||
content: build.pk ? (
|
||||
<BuildAllocatedStockTable buildId={build.pk} showPartInfo allowEdit />
|
||||
<PurchaseOrderTable externalBuildId={build.pk} />
|
||||
) : (
|
||||
<Skeleton />
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'consumed-stock',
|
||||
label: t`Consumed Stock`,
|
||||
icon: <IconListCheck />,
|
||||
content: (
|
||||
<StockItemTable
|
||||
allowAdd={false}
|
||||
tableName='build-consumed'
|
||||
showLocation={false}
|
||||
params={{
|
||||
consumed_by: id
|
||||
}}
|
||||
/>
|
||||
)
|
||||
),
|
||||
hidden:
|
||||
!user.hasViewRole(UserRoles.purchase_order) ||
|
||||
!build.external ||
|
||||
!globalSettings.isSet('BUILDORDER_EXTERNAL_BUILDS')
|
||||
},
|
||||
{
|
||||
name: 'child-orders',
|
||||
@ -377,7 +414,7 @@ export default function BuildDetail() {
|
||||
model_id: build.pk
|
||||
})
|
||||
];
|
||||
}, [build, id, user, buildStatus]);
|
||||
}, [build, id, user, buildStatus, globalSettings]);
|
||||
|
||||
const buildOrderFields = useBuildOrderFields({ create: false });
|
||||
|
||||
@ -531,6 +568,12 @@ export default function BuildDetail() {
|
||||
status={build.status_custom_key}
|
||||
type={ModelType.build}
|
||||
options={{ size: 'lg' }}
|
||||
/>,
|
||||
<DetailsBadge
|
||||
label={t`External`}
|
||||
color='blue'
|
||||
key='external'
|
||||
visible={build.external}
|
||||
/>
|
||||
];
|
||||
}, [build, instanceQuery]);
|
||||
|
@ -13,14 +13,25 @@ import PermissionDenied from '../../components/errors/PermissionDenied';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import type { PanelType } from '../../components/panels/Panel';
|
||||
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { PartCategoryFilter } from '../../tables/Filter';
|
||||
import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
|
||||
|
||||
function BuildOrderCalendar() {
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
|
||||
const calendarFilters: TableFilter[] = useMemo(() => {
|
||||
return [PartCategoryFilter()];
|
||||
}, []);
|
||||
return [
|
||||
{
|
||||
name: 'external',
|
||||
label: t`External`,
|
||||
description: t`Show external build orders`,
|
||||
active: globalSettings.isSet('BUILDORDER_EXTERNAL_BUILDS')
|
||||
},
|
||||
PartCategoryFilter()
|
||||
];
|
||||
}, [globalSettings]);
|
||||
|
||||
return (
|
||||
<OrderCalendar
|
||||
|
@ -266,7 +266,10 @@ export default function SupplierPartDetail() {
|
||||
label: t`Purchase Orders`,
|
||||
icon: <IconShoppingCart />,
|
||||
content: supplierPart?.pk ? (
|
||||
<PurchaseOrderTable supplierPartId={supplierPart.pk} />
|
||||
<PurchaseOrderTable
|
||||
supplierId={supplierPart.supplier}
|
||||
supplierPartId={supplierPart.pk}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton />
|
||||
)
|
||||
|
@ -288,11 +288,12 @@ export default function PartDetail() {
|
||||
name: 'required',
|
||||
label: t`Required for Orders`,
|
||||
hidden: part.required <= 0,
|
||||
icon: 'tick_off'
|
||||
icon: 'stocktake'
|
||||
},
|
||||
{
|
||||
type: 'progressbar',
|
||||
name: 'allocated_to_build_orders',
|
||||
icon: 'tick_off',
|
||||
total: part.required_for_build_orders,
|
||||
progress: part.allocated_to_build_orders,
|
||||
label: t`Allocated to Build Orders`,
|
||||
@ -303,6 +304,7 @@ export default function PartDetail() {
|
||||
},
|
||||
{
|
||||
type: 'progressbar',
|
||||
icon: 'tick_off',
|
||||
name: 'allocated_to_sales_orders',
|
||||
total: part.required_for_sales_orders,
|
||||
progress: part.allocated_to_sales_orders,
|
||||
@ -320,11 +322,12 @@ export default function PartDetail() {
|
||||
hidden: true // TODO: Expose "can_build" to the API
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
type: 'progressbar',
|
||||
name: 'building',
|
||||
unit: true,
|
||||
label: t`In Production`,
|
||||
hidden: !part.assembly || !part.building
|
||||
progress: part.building,
|
||||
total: part.scheduled_to_build,
|
||||
hidden: !part.assembly || (!part.building && !part.scheduled_to_build)
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { getDetailUrl } from '@lib/functions/Navigation';
|
||||
import { apiUrl } from '@lib/index';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Group, Skeleton, Stack, Text } from '@mantine/core';
|
||||
import { IconInfoCircle, IconPackages, IconSitemap } from '@tabler/icons-react';
|
||||
|
@ -34,6 +34,7 @@ import type { TableColumn } from './Column';
|
||||
import InvenTreeTableHeader from './InvenTreeTableHeader';
|
||||
import { type RowAction, RowActions } from './RowActions';
|
||||
|
||||
const ACTIONS_COLUMN_ACCESSOR: string = '--actions--';
|
||||
const defaultPageSize: number = 25;
|
||||
const PAGE_SIZES = [10, 15, 20, 25, 50, 100, 500];
|
||||
|
||||
@ -313,7 +314,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
||||
// If row actions are available, add a column for them
|
||||
if (tableProps.rowActions) {
|
||||
cols.push({
|
||||
accessor: '--actions--',
|
||||
accessor: ACTIONS_COLUMN_ACCESSOR,
|
||||
title: ' ',
|
||||
hidden: false,
|
||||
resizable: false,
|
||||
@ -359,6 +360,23 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
||||
columns: dataColumns
|
||||
});
|
||||
|
||||
// Ensure that the "actions" column is always at the end of the list
|
||||
// This effect is necessary as sometimes the underlying mantine-datatable columns change
|
||||
useEffect(() => {
|
||||
const idx: number = tableColumns.columnsOrder.indexOf(
|
||||
ACTIONS_COLUMN_ACCESSOR
|
||||
);
|
||||
|
||||
if (idx >= 0 && idx < tableColumns.columnsOrder.length - 1) {
|
||||
// Actions column is not at the end of the list - move it there
|
||||
const newOrder = tableColumns.columnsOrder.filter(
|
||||
(col) => col != ACTIONS_COLUMN_ACCESSOR
|
||||
);
|
||||
newOrder.push(ACTIONS_COLUMN_ACCESSOR);
|
||||
tableColumns.setColumnsOrder(newOrder);
|
||||
}
|
||||
}, [tableColumns.columnsOrder]);
|
||||
|
||||
// Reset the pagination state when the search term changes
|
||||
useEffect(() => {
|
||||
tableState.setPage(1);
|
||||
|
@ -12,8 +12,10 @@ import { RenderUser } from '../../components/render/User';
|
||||
import { useBuildOrderFields } from '../../forms/BuildForms';
|
||||
import { useCreateApiFormModal } from '../../hooks/UseForm';
|
||||
import { useTable } from '../../hooks/UseTable';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import {
|
||||
BooleanColumn,
|
||||
CreationDateColumn,
|
||||
DateColumn,
|
||||
PartColumn,
|
||||
@ -59,6 +61,7 @@ export function BuildOrderTable({
|
||||
parentBuildId?: number;
|
||||
salesOrderId?: number;
|
||||
}>) {
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
const table = useTable(!!partId ? 'buildorder-part' : 'buildorder-index');
|
||||
|
||||
const tableColumns = useMemo(() => {
|
||||
@ -109,6 +112,13 @@ export function BuildOrderTable({
|
||||
accessor: 'priority',
|
||||
sortable: true
|
||||
},
|
||||
BooleanColumn({
|
||||
accessor: 'external',
|
||||
title: t`External`,
|
||||
sortable: true,
|
||||
switchable: true,
|
||||
hidden: !globalSettings.isSet('BUILDORDER_EXTERNAL_BUILDS')
|
||||
}),
|
||||
CreationDateColumn({}),
|
||||
StartDateColumn({}),
|
||||
TargetDateColumn({}),
|
||||
@ -126,7 +136,7 @@ export function BuildOrderTable({
|
||||
},
|
||||
ResponsibleColumn({})
|
||||
];
|
||||
}, [parentBuildId]);
|
||||
}, [parentBuildId, globalSettings]);
|
||||
|
||||
const tableFilters: TableFilter[] = useMemo(() => {
|
||||
const filters: TableFilter[] = [
|
||||
@ -160,6 +170,12 @@ export function BuildOrderTable({
|
||||
HasProjectCodeFilter(),
|
||||
IssuedByFilter(),
|
||||
ResponsibleFilter(),
|
||||
{
|
||||
name: 'external',
|
||||
label: t`External`,
|
||||
description: t`Show external build orders`,
|
||||
active: globalSettings.isSet('BUILDORDER_EXTERNAL_BUILDS')
|
||||
},
|
||||
PartCategoryFilter()
|
||||
];
|
||||
|
||||
@ -174,7 +190,7 @@ export function BuildOrderTable({
|
||||
}
|
||||
|
||||
return filters;
|
||||
}, [partId]);
|
||||
}, [partId, globalSettings]);
|
||||
|
||||
const user = useUserState();
|
||||
|
||||
|
@ -2,17 +2,11 @@ import { t } from '@lingui/core/macro';
|
||||
import { ActionIcon, Badge, Group, Text, Tooltip } from '@mantine/core';
|
||||
import { IconCirclePlus } from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState
|
||||
} from 'react';
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { ModelType } from '@lib/index';
|
||||
import type { TableFilter } from '@lib/types/Filters';
|
||||
import type { ApiFormFieldSet } from '@lib/types/Forms';
|
||||
import { PassFailButton } from '../../components/buttons/YesNoButton';
|
||||
@ -26,7 +20,6 @@ import { useUserState } from '../../states/UserState';
|
||||
import type { TableColumn } from '../Column';
|
||||
import { LocationColumn } from '../ColumnRenderers';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
import type { RowAction } from '../RowActions';
|
||||
import { TableHoverCard } from '../TableHoverCard';
|
||||
|
||||
/**
|
||||
@ -235,13 +228,6 @@ export default function BuildOrderTestTable({
|
||||
return [];
|
||||
}, []);
|
||||
|
||||
const rowActions = useCallback(
|
||||
(record: any): RowAction[] => {
|
||||
return [];
|
||||
},
|
||||
[user]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{createTestResult.modal}
|
||||
@ -256,7 +242,6 @@ export default function BuildOrderTestTable({
|
||||
tests: true,
|
||||
build: buildId
|
||||
},
|
||||
rowActions: rowActions,
|
||||
tableFilters: tableFilters,
|
||||
tableActions: tableActions,
|
||||
modelType: ModelType.stockitem
|
||||
|
@ -6,10 +6,12 @@ import {
|
||||
Group,
|
||||
Paper,
|
||||
Space,
|
||||
Stack,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import {
|
||||
IconBuildingFactory2,
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconExclamationCircle
|
||||
@ -22,6 +24,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import type { TableFilter } from '@lib/types/Filters';
|
||||
import { ActionButton } from '../../components/buttons/ActionButton';
|
||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||
import { ProgressBar } from '../../components/items/ProgressBar';
|
||||
@ -43,6 +46,7 @@ import { useTable } from '../../hooks/UseTable';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import type { TableColumn } from '../Column';
|
||||
import { LocationColumn, PartColumn, StatusColumn } from '../ColumnRenderers';
|
||||
import { StatusFilterOptions } from '../Filter';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
import { type RowAction, RowEditAction, RowViewAction } from '../RowActions';
|
||||
import { TableHoverCard } from '../TableHoverCard';
|
||||
@ -335,6 +339,17 @@ export default function BuildOutputTable({
|
||||
}
|
||||
});
|
||||
|
||||
const tableFilters: TableFilter[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
name: 'status',
|
||||
label: t`Status`,
|
||||
description: t`Filter by stock status`,
|
||||
choiceFunction: StatusFilterOptions(ModelType.stockitem)
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
return [
|
||||
<ActionButton
|
||||
@ -373,11 +388,11 @@ export default function BuildOutputTable({
|
||||
<AddItemButton
|
||||
key='add-build-output'
|
||||
tooltip={t`Add Build Output`}
|
||||
hidden={!user.hasAddRole(UserRoles.build)}
|
||||
hidden={build.external || !user.hasAddRole(UserRoles.build)}
|
||||
onClick={addBuildOutput.open}
|
||||
/>
|
||||
];
|
||||
}, [user, table.selectedRecords, table.hasSelectedRecords]);
|
||||
}, [build, user, table.selectedRecords, table.hasSelectedRecords]);
|
||||
|
||||
const rowActions = useCallback(
|
||||
(record: any): RowAction[] => {
|
||||
@ -568,32 +583,44 @@ export default function BuildOutputTable({
|
||||
opened={drawerOpen}
|
||||
close={closeDrawer}
|
||||
/>
|
||||
<InvenTreeTable
|
||||
tableState={table}
|
||||
url={apiUrl(ApiEndpoints.stock_item_list)}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
params: {
|
||||
part_detail: true,
|
||||
location_detail: true,
|
||||
tests: true,
|
||||
is_building: true,
|
||||
build: buildId
|
||||
},
|
||||
enableLabels: true,
|
||||
enableReports: true,
|
||||
dataFormatter: formatRecords,
|
||||
tableActions: tableActions,
|
||||
rowActions: rowActions,
|
||||
enableSelection: true,
|
||||
onRowClick: (record: any) => {
|
||||
if (hasTrackedItems && !!record.serial) {
|
||||
setSelectedOutputs([record]);
|
||||
openDrawer();
|
||||
<Stack gap='xs'>
|
||||
{build.external && (
|
||||
<Alert
|
||||
color='blue'
|
||||
icon={<IconBuildingFactory2 />}
|
||||
title={t`External Build`}
|
||||
>
|
||||
{t`This build order is fulfilled by an external purchase order`}
|
||||
</Alert>
|
||||
)}
|
||||
<InvenTreeTable
|
||||
tableState={table}
|
||||
url={apiUrl(ApiEndpoints.stock_item_list)}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
params: {
|
||||
part_detail: true,
|
||||
location_detail: true,
|
||||
tests: true,
|
||||
is_building: true,
|
||||
build: buildId
|
||||
},
|
||||
enableLabels: true,
|
||||
enableReports: true,
|
||||
dataFormatter: formatRecords,
|
||||
tableFilters: tableFilters,
|
||||
tableActions: tableActions,
|
||||
rowActions: rowActions,
|
||||
enableSelection: true,
|
||||
onRowClick: (record: any) => {
|
||||
if (hasTrackedItems && !!record.serial) {
|
||||
setSelectedOutputs([record]);
|
||||
openDrawer();
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -8,10 +8,12 @@ import { ModelType } from '@lib/enums/ModelType';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import type { TableFilter } from '@lib/types/Filters';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ActionButton } from '../../components/buttons/ActionButton';
|
||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||
import ImporterDrawer from '../../components/importer/ImporterDrawer';
|
||||
import { ProgressBar } from '../../components/items/ProgressBar';
|
||||
import { RenderInstance } from '../../components/render/Instance';
|
||||
import { RenderStockLocation } from '../../components/render/Stock';
|
||||
import { dataImporterSessionFields } from '../../forms/ImporterForms';
|
||||
import {
|
||||
@ -40,7 +42,8 @@ import {
|
||||
type RowAction,
|
||||
RowDeleteAction,
|
||||
RowDuplicateAction,
|
||||
RowEditAction
|
||||
RowEditAction,
|
||||
RowViewAction
|
||||
} from '../RowActions';
|
||||
import { TableHoverCard } from '../TableHoverCard';
|
||||
|
||||
@ -62,6 +65,7 @@ export function PurchaseOrderLineItemTable({
|
||||
}>) {
|
||||
const table = useTable('purchase-order-line-item');
|
||||
|
||||
const navigate = useNavigate();
|
||||
const user = useUserState();
|
||||
|
||||
// Data import
|
||||
@ -142,6 +146,23 @@ export function PurchaseOrderLineItemTable({
|
||||
sortable: false
|
||||
},
|
||||
ReferenceColumn({}),
|
||||
{
|
||||
accessor: 'build_order',
|
||||
title: t`Build Order`,
|
||||
sortable: true,
|
||||
render: (record: any) => {
|
||||
if (record.build_order_detail) {
|
||||
return (
|
||||
<RenderInstance
|
||||
instance={record.build_order_detail}
|
||||
model={ModelType.build}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'quantity',
|
||||
title: t`Quantity`,
|
||||
@ -276,7 +297,7 @@ export function PurchaseOrderLineItemTable({
|
||||
|
||||
const [selectedLine, setSelectedLine] = useState<number>(0);
|
||||
|
||||
const editPurchaseOrderFields = usePurchaseOrderLineItemFields({
|
||||
const editLineItemFields = usePurchaseOrderLineItemFields({
|
||||
create: false,
|
||||
orderId: orderId,
|
||||
supplierId: supplierId
|
||||
@ -286,7 +307,7 @@ export function PurchaseOrderLineItemTable({
|
||||
url: ApiEndpoints.purchase_order_line_list,
|
||||
pk: selectedLine,
|
||||
title: t`Edit Line Item`,
|
||||
fields: editPurchaseOrderFields,
|
||||
fields: editLineItemFields,
|
||||
table: table
|
||||
});
|
||||
|
||||
@ -326,6 +347,13 @@ export function PurchaseOrderLineItemTable({
|
||||
receiveLineItems.open();
|
||||
}
|
||||
},
|
||||
RowViewAction({
|
||||
hidden: !record.build_order,
|
||||
title: t`View Build Order`,
|
||||
modelType: ModelType.build,
|
||||
modelId: record.build_order,
|
||||
navigate: navigate
|
||||
}),
|
||||
RowEditAction({
|
||||
hidden: !user.hasChangeRole(UserRoles.purchase_order),
|
||||
onClick: () => {
|
||||
|
@ -53,10 +53,12 @@ import { InvenTreeTable } from '../InvenTreeTable';
|
||||
*/
|
||||
export function PurchaseOrderTable({
|
||||
supplierId,
|
||||
supplierPartId
|
||||
supplierPartId,
|
||||
externalBuildId
|
||||
}: Readonly<{
|
||||
supplierId?: number;
|
||||
supplierPartId?: number;
|
||||
externalBuildId?: number;
|
||||
}>) {
|
||||
const table = useTable('purchase-order');
|
||||
const user = useUserState();
|
||||
@ -178,7 +180,8 @@ export function PurchaseOrderTable({
|
||||
params: {
|
||||
supplier_detail: true,
|
||||
supplier: supplierId,
|
||||
supplier_part: supplierPartId
|
||||
supplier_part: supplierPartId,
|
||||
external_build: externalBuildId
|
||||
},
|
||||
tableFilters: tableFilters,
|
||||
tableActions: tableActions,
|
||||
|
@ -2,8 +2,8 @@ import { t } from '@lingui/core/macro';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { UserRoles } from '@lib/index';
|
||||
import { notifications, showNotification } from '@mantine/notifications';
|
||||
import { IconTrashXFilled, IconX } from '@tabler/icons-react';
|
||||
import { api } from '../../App';
|
||||
|
@ -3,6 +3,7 @@ import { test } from '../baseFixtures.ts';
|
||||
import {
|
||||
activateCalendarView,
|
||||
clearTableFilters,
|
||||
clickOnRowMenu,
|
||||
getRowFromCell,
|
||||
loadTab,
|
||||
navigate,
|
||||
@ -65,7 +66,7 @@ test('Build Order - Basic Tests', async ({ browser }) => {
|
||||
await loadTab(page, 'Attachments');
|
||||
await loadTab(page, 'Notes');
|
||||
await loadTab(page, 'Incomplete Outputs');
|
||||
await loadTab(page, 'Line Items');
|
||||
await loadTab(page, 'Required Stock');
|
||||
await loadTab(page, 'Allocated Stock');
|
||||
|
||||
// Check for expected text in the table
|
||||
@ -373,3 +374,53 @@ test('Build Order - Duplicate', async ({ browser }) => {
|
||||
|
||||
await page.getByText('Pending').first().waitFor();
|
||||
});
|
||||
|
||||
// Tests for external build orders
|
||||
test('Build Order - External', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, { url: 'manufacturing/index/' });
|
||||
await loadTab(page, 'Build Orders');
|
||||
|
||||
// Filter to show only external builds
|
||||
await clearTableFilters(page);
|
||||
await setTableChoiceFilter(page, 'External', 'Yes');
|
||||
await page.getByRole('cell', { name: 'BO0026' }).waitFor();
|
||||
await page.getByRole('cell', { name: 'BO0025' }).click();
|
||||
await page
|
||||
.locator('span')
|
||||
.filter({ hasText: /^External$/ })
|
||||
.waitFor();
|
||||
|
||||
await loadTab(page, 'Allocated Stock');
|
||||
await loadTab(page, 'Incomplete Outputs');
|
||||
await page
|
||||
.getByText('This build order is fulfilled by an external purchase order')
|
||||
.waitFor();
|
||||
|
||||
await loadTab(page, 'External Orders');
|
||||
await page.getByRole('cell', { name: 'PO0016' }).click();
|
||||
|
||||
await loadTab(page, 'Attachments');
|
||||
await loadTab(page, 'Received Stock');
|
||||
await loadTab(page, 'Line Items');
|
||||
|
||||
const cell = await page.getByRole('cell', {
|
||||
name: '002.01-PCBA',
|
||||
exact: true
|
||||
});
|
||||
await clickOnRowMenu(cell);
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Receive line item' }).waitFor();
|
||||
await page.getByRole('menuitem', { name: 'Duplicate' }).waitFor();
|
||||
await page.getByRole('menuitem', { name: 'Edit' }).waitFor();
|
||||
await page.getByRole('menuitem', { name: 'View Build Order' }).click();
|
||||
|
||||
// Wait for navigation back to build order detail page
|
||||
await page.getByText('Build Order: BO0025', { exact: true }).waitFor();
|
||||
|
||||
// Let's look at BO0026 too
|
||||
await navigate(page, 'manufacturing/build-order/26/details');
|
||||
await loadTab(page, 'External Orders');
|
||||
|
||||
await page.getByRole('cell', { name: 'PO0017' }).waitFor();
|
||||
await page.getByRole('cell', { name: 'PO0018' }).waitFor();
|
||||
});
|
||||
|
Reference in New Issue
Block a user