diff --git a/docs/docs/assets/images/build/external_build_detail.png b/docs/docs/assets/images/build/external_build_detail.png
new file mode 100644
index 0000000000..3e7fb94aed
Binary files /dev/null and b/docs/docs/assets/images/build/external_build_detail.png differ
diff --git a/docs/docs/assets/images/build/external_build_fulfilment.png b/docs/docs/assets/images/build/external_build_fulfilment.png
new file mode 100644
index 0000000000..702369b919
Binary files /dev/null and b/docs/docs/assets/images/build/external_build_fulfilment.png differ
diff --git a/docs/docs/assets/images/build/external_build_incomplete.png b/docs/docs/assets/images/build/external_build_incomplete.png
new file mode 100644
index 0000000000..b4cf9ea711
Binary files /dev/null and b/docs/docs/assets/images/build/external_build_incomplete.png differ
diff --git a/docs/docs/assets/images/build/external_build_receive_items.png b/docs/docs/assets/images/build/external_build_receive_items.png
new file mode 100644
index 0000000000..60eee92fa0
Binary files /dev/null and b/docs/docs/assets/images/build/external_build_receive_items.png differ
diff --git a/docs/docs/assets/images/build/external_build_select_build.png b/docs/docs/assets/images/build/external_build_select_build.png
new file mode 100644
index 0000000000..b83cd5220a
Binary files /dev/null and b/docs/docs/assets/images/build/external_build_select_build.png differ
diff --git a/docs/docs/manufacturing/build.md b/docs/docs/manufacturing/build.md
index 2e7c713328..0b8ae2fb0c 100644
--- a/docs/docs/manufacturing/build.md
+++ b/docs/docs/manufacturing/build.md
@@ -36,6 +36,8 @@ The build calendar allows the user to navigate month-by-month and display the fi
## Build Order Details
+Select an individual build order from the build order table to navigate to the Build Order detail page. The build order detail page provides a comprehensive overview of the build order, including all relevant information and actions.
+
### Build Order Reference
Each Build Order is uniquely identified by its *Reference* field. Read more about [reference fields](../settings/reference.md).
@@ -181,6 +183,10 @@ Build order notes (which support markdown formatting) are displayed in the *Note
{{ image("build/build_notes.png", title="Notes") }}
+## External Build Orders
+
+InvenTree supports the creation of *external build orders*, which are used to manage the manufacturing of parts by an external supplier. Read more about [external build orders](./external.md).
+
## Create Build Order
To create a build order for your part, you have two options:
@@ -266,6 +272,7 @@ The following [global settings](../settings/global.md) are available for adjusti
| Name | Description | Default | Units |
| ---- | ----------- | ------- | ----- |
{{ globalsetting("BUILDORDER_REFERENCE_PATTERN") }}
+{{ globalsetting("BUILDORDER_EXTERNAL_BUILDS") }}
{{ globalsetting("BUILDORDER_REQUIRE_RESPONSIBLE") }}
{{ globalsetting("BUILDORDER_REQUIRE_ACTIVE_PART") }}
{{ globalsetting("BUILDORDER_REQUIRE_LOCKED_PART") }}
diff --git a/docs/docs/manufacturing/external.md b/docs/docs/manufacturing/external.md
new file mode 100644
index 0000000000..2ad0d04655
--- /dev/null
+++ b/docs/docs/manufacturing/external.md
@@ -0,0 +1,72 @@
+---
+title: External Manufacturing
+---
+
+## External Manufacturing
+
+In some cases, it may be necessary to outsource the manufacturing of certain components or assemblies to an external supplier. InvenTree provides a simple interface for managing external manufacturing processes, allowing users to create and track orders with external suppliers.
+
+In the context of InvenTree, an *external build order* is a request to an external supplier to manufacture a specific assembly or component. This order is linked to a specific BOM and build order, ensuring that the correct components are produced.
+
+In conjunction with the external [Build Order](./build.md), a [Purchase Order](../purchasing/purchase_order.md) is required to manage the procurement of the manufactured items. The purchase order is used to track the order with the external supplier, including details such as pricing, delivery dates, and payment terms.
+
+When items are received from the external supplier (against the Purchase Order), they are automatically allocated to the external build order.
+
+### Stock Allocation
+
+Stock item can be allocated against an external build order as per the normal build order process. The external manufacturer may be expected to provide all of the components required to complete the build order, or they may only be responsible for a subset of the components. In either case, it is important to ensure that the correct stock items are allocated to the external build order. The allocation process is flexible, allowing users to specify which components should be included in the order.
+
+In the case of partial allocation, the user simply selects the stock items that should be included in the order.
+
+### Fulfillment
+
+The fulfillment process for external build orders is slightly different from the standard build order process. Instead of manually creating build outputs for the build order, these outputs are generated when the linked Purchase Order items are received.
+The Build Order and Purchase Order processes are closely linked, allowing users to easily manage the entire manufacturing process from start to finish. This includes tracking the progress of the external manufacturing process, managing stock allocations, and ensuring that the correct components are delivered on time.
+
+### Extra Requirements
+
+To successfully manage external build orders, the "assembly" (the part which is being manufactured) must be a *purchaseable* part, and it must have a linked Supplier Part which is associated with the external supplier.
+
+This ensures that external build orders are only created for parts that can be purchased from the supplier, and that the supplier is provided with the correct order codes and descriptions.
+
+The external suppiler must also be marked as a "manufacturer" in the InvenTree system.
+
+## Enable External Manufacturing
+
+By default, external manufacturing is disabled in InvenTree. To enable this feature, enable the {{ globalsetting("BUILDORDER_EXTERNAL_BUILDS", short=True) }} setting.
+
+## External Build Order Process
+
+The process for managing external build orders in InvenTree is as follows:
+
+### Create Build Order
+
+Create a new build order, specifying the assembly part and the quantity to be manufactured. When creating the new build order, select the "External" option to indicate that this is an external build order. The Build detail page will indicate that this is an external build order:
+
+{{ image("build/external_build_detail.png", "External build order detail") }}
+
+Additionally, the *Incomplete Outputs* panel will indicate that the build order is linked to an external purchase order, and that the outputs will be generated when the purchase order items are received.
+
+{{ image("build/external_build_fulfilment.png", "External build order fulfillment") }}
+
+### Create Purchase Order
+
+Once the build order has been created, a purchase order provides the link between the build order and the incoming goods (which will fulfil the build order).
+
+Create a purchase order against the external supplier which will be responsible for manufacturing the assembly. This assumes that there is already a [supplier part](../purchasing/supplier.md#supplier-parts) which links the assembled part to the external supplier.
+
+### Add Items to Purchase Order
+
+Once the purchase order has been created, add the assembly part to the purchase order. When adding the line item to the purchase order, ensure that the "Build Order" field is set to the external build order that was created earlier. This links the purchase order to the build order, allowing InvenTree to automatically allocate the received items to the build order when they are received.
+
+{{ image("build/external_build_select_build.png", "Link items to build order") }}
+
+### Receive Items
+
+Follow the normal process for receiving items against the purchase order. When the items are received from the external supplier, they will be marked as "build outputs" against the external build order.
+
+{{ image("build/external_build_receive_items.png", "Receive items against purchase order") }}
+
+The received items will now be registered as "incomplete outputs" against the external build order:
+
+{{ image("build/external_build_incomplete.png", "Incomplete build outputs") }}
diff --git a/docs/main.py b/docs/main.py
index f61e22b6f6..e26b498f67 100644
--- a/docs/main.py
+++ b/docs/main.py
@@ -318,8 +318,14 @@ def define_env(env):
json.dump(data, f, indent=4)
@env.macro
- def rendersetting(key: str, setting: dict):
- """Render a provided setting object into a table row."""
+ def rendersetting(key: str, setting: dict, short: bool = False):
+ """Render a provided setting object into a table row.
+
+ Arguments:
+ key: The name of the setting to extract information for.
+ setting: The setting object to render.
+ short: If True, return a short version of the setting (default: False)
+ """
name = setting['name']
description = setting['description']
default = setting.get('default')
@@ -328,37 +334,44 @@ def define_env(env):
default = f'`{default}`' if default else ''
units = f'`{units}`' if units else ''
- return (
- f'|
{name}
| {description} | {default} | {units} |'
- )
+ if short:
+ return f'{name}'
+
+ return f'| {name}
| {description} | {default} | {units} |'
@env.macro
- def globalsetting(key: str):
+ def globalsetting(key: str, short: bool = False):
"""Extract information on a particular global setting.
Arguments:
key: The name of the global setting to extract information for.
+ short: If True, return a short version of the setting (default: False)
"""
global GLOBAL_SETTINGS
setting = GLOBAL_SETTINGS[key]
- observe_setting(key, 'global')
+ # Settings are only 'observed' if they are displayed in full
+ if not short:
+ observe_setting(key, 'global')
- return rendersetting(key, setting)
+ return rendersetting(key, setting, short=short)
@env.macro
- def usersetting(key: str):
+ def usersetting(key: str, short: bool = False):
"""Extract information on a particular user setting.
Arguments:
key: The name of the user setting to extract information for.
+ short: If True, return a short version of the setting (default: False)
"""
global USER_SETTINGS
setting = USER_SETTINGS[key]
- observe_setting(key, 'user')
+ # Settings are only 'observed' if they are displayed in full
+ if not short:
+ observe_setting(key, 'user')
- return rendersetting(key, setting)
+ return rendersetting(key, setting, short=short)
@env.macro
def tags_and_filters():
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
index 96a19c3261..712dce5cf2 100644
--- a/docs/mkdocs.yml
+++ b/docs/mkdocs.yml
@@ -167,6 +167,7 @@ nav:
- Build Orders: manufacturing/build.md
- Build Outputs: manufacturing/output.md
- Allocating Stock: manufacturing/allocate.md
+ - External Manufacturing: manufacturing/external.md
- Example Build Order: manufacturing/example.md
- Purchasing:
- Purchasing: purchasing/index.md
diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py
index 5edc5e71bb..0dea53508b 100644
--- a/src/backend/InvenTree/InvenTree/api_version.py
+++ b/src/backend/InvenTree/InvenTree/api_version.py
@@ -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
diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py
index b70abbe119..55e4bb0980 100644
--- a/src/backend/InvenTree/build/api.py
+++ b/src/backend/InvenTree/build/api.py
@@ -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 = {
diff --git a/src/backend/InvenTree/build/migrations/0057_build_external.py b/src/backend/InvenTree/build/migrations/0057_build_external.py
new file mode 100644
index 0000000000..9ceb3a6378
--- /dev/null
+++ b/src/backend/InvenTree/build/migrations/0057_build_external.py
@@ -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",
+ ),
+ ),
+ ]
diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py
index 37e3e4faa7..cf5b848cb2 100644
--- a/src/backend/InvenTree/build/models.py
+++ b/src/backend/InvenTree/build/models.py
@@ -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'),
diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py
index aaef6bae81..675bdd76b6 100644
--- a/src/backend/InvenTree/build/serializers.py
+++ b/src/backend/InvenTree/build/serializers.py
@@ -73,6 +73,7 @@ class BuildSerializer(
'completed',
'completion_date',
'destination',
+ 'external',
'parent',
'part',
'part_name',
diff --git a/src/backend/InvenTree/build/test_build.py b/src/backend/InvenTree/build/test_build.py
index 0aba34bc2a..323132a6a8 100644
--- a/src/backend/InvenTree/build/test_build.py
+++ b/src/backend/InvenTree/build/test_build.py
@@ -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)
diff --git a/src/backend/InvenTree/common/setting/system.py b/src/backend/InvenTree/common/setting/system.py
index c2b38a6b8d..2a20685afc 100644
--- a/src/backend/InvenTree/common/setting/system.py
+++ b/src/backend/InvenTree/common/setting/system.py
@@ -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': _(
diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py
index 283e616cd9..dfeda69ebf 100644
--- a/src/backend/InvenTree/order/api.py
+++ b/src/backend/InvenTree/order/api.py
@@ -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."""
diff --git a/src/backend/InvenTree/order/migrations/0111_purchaseorderlineitem_build_order.py b/src/backend/InvenTree/order/migrations/0111_purchaseorderlineitem_build_order.py
new file mode 100644
index 0000000000..57b1c5147d
--- /dev/null
+++ b/src/backend/InvenTree/order/migrations/0111_purchaseorderlineitem_build_order.py
@@ -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",
+ ),
+ ),
+ ]
diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py
index 96fa7f1b52..788fd52bc0 100644
--- a/src/backend/InvenTree/order/models.py
+++ b/src/backend/InvenTree/order/models.py
@@ -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
)
diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py
index 9cada9a8a4..f49af7ae8a 100644
--- a/src/backend/InvenTree/order/serializers.py
+++ b/src/backend/InvenTree/order/serializers.py
@@ -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=_(
diff --git a/src/backend/InvenTree/part/filters.py b/src/backend/InvenTree/part/filters.py
index 420613d266..a5be94a6a2 100644
--- a/src/backend/InvenTree/part/filters.py
+++ b/src/backend/InvenTree/part/filters.py
@@ -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.
diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py
index 24b9b50f11..f97f7a519a 100644
--- a/src/backend/InvenTree/part/models.py
+++ b/src/backend/InvenTree/part/models.py
@@ -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)
diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py
index b1141db39a..875d2f64c3 100644
--- a/src/backend/InvenTree/part/serializers.py
+++ b/src/backend/InvenTree/part/serializers.py
@@ -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')
)
diff --git a/src/backend/InvenTree/plugin/base/integration/ScheduleMixin.py b/src/backend/InvenTree/plugin/base/integration/ScheduleMixin.py
index 6aeb14d4c5..8adaf9a2f6 100644
--- a/src/backend/InvenTree/plugin/base/integration/ScheduleMixin.py
+++ b/src/backend/InvenTree/plugin/base/integration/ScheduleMixin.py
@@ -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
diff --git a/src/frontend/src/components/details/Details.tsx b/src/frontend/src/components/details/Details.tsx
index 2086997845..cb5f50e69f 100644
--- a/src/frontend/src/components/details/Details.tsx
+++ b/src/frontend/src/components/details/Details.tsx
@@ -410,6 +410,7 @@ function ProgressBarValue(props: Readonly) {
return (
{
- 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);
}
diff --git a/src/frontend/src/forms/BomForms.tsx b/src/frontend/src/forms/BomForms.tsx
index 8852cb5fa2..f5354a5975 100644
--- a/src/frontend/src/forms/BomForms.tsx
+++ b/src/frontend/src/forms/BomForms.tsx
@@ -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';
diff --git a/src/frontend/src/forms/BuildForms.tsx b/src/frontend/src/forms/BuildForms.tsx
index bfc4bf5d17..0ff3241a09 100644
--- a/src/frontend/src/forms/BuildForms.tsx
+++ b/src/frontend/src/forms/BuildForms.tsx
@@ -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]);
}
diff --git a/src/frontend/src/forms/PurchaseOrderForms.tsx b/src/frontend/src/forms/PurchaseOrderForms.tsx
index e80ec6548e..ca1a9e2452 100644
--- a/src/frontend/src/forms/PurchaseOrderForms.tsx
+++ b/src/frontend/src/forms/PurchaseOrderForms.tsx
@@ -64,9 +64,14 @@ export function usePurchaseOrderLineItemFields({
orderId?: number;
create?: boolean;
}) {
+ const globalSettings = useGlobalSettingsState();
+
const [purchasePrice, setPurchasePrice] = useState('');
const [autoPricing, setAutoPricing] = useState(true);
+ // Internal part information
+ const [part, setPart] = useState({});
+
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:
},
+ build_order: {
+ disabled: !part?.assembly,
+ filters: {
+ external: true,
+ outstanding: true,
+ part: part?.pk
+ }
+ },
notes: {
icon:
},
@@ -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;
}
diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx
index a0449c4168..1f2f5c5e2b 100644
--- a/src/frontend/src/forms/StockForms.tsx
+++ b/src/frontend/src/forms/StockForms.tsx
@@ -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,
diff --git a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx
index e77831655a..b268ec65dd 100644
--- a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx
+++ b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx
@@ -248,6 +248,7 @@ export default function SystemSettings() {
{
+ 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: ,
content: build?.pk ? :
},
+ {
+ name: 'allocated-stock',
+ label: t`Allocated Stock`,
+ icon: ,
+ hidden:
+ build.status == buildStatus.COMPLETE ||
+ build.status == buildStatus.CANCELLED,
+ content: build.pk ? (
+
+ ) : (
+
+ )
+ },
+ {
+ name: 'consumed-stock',
+ label: t`Consumed Stock`,
+ icon: ,
+ content: (
+
+ )
+ },
{
name: 'incomplete-outputs',
label: t`Incomplete Outputs`,
@@ -320,32 +371,18 @@ export default function BuildDetail() {
)
},
{
- name: 'allocated-stock',
- label: t`Allocated Stock`,
- icon: ,
- hidden:
- build.status == buildStatus.COMPLETE ||
- build.status == buildStatus.CANCELLED,
+ name: 'external-purchase-orders',
+ label: t`External Orders`,
+ icon: ,
content: build.pk ? (
-
+
) : (
- )
- },
- {
- name: 'consumed-stock',
- label: t`Consumed Stock`,
- icon: ,
- content: (
-
- )
+ ),
+ 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' }}
+ />,
+
];
}, [build, instanceQuery]);
diff --git a/src/frontend/src/pages/build/BuildIndex.tsx b/src/frontend/src/pages/build/BuildIndex.tsx
index d61a50e3dc..ff09a5ca5e 100644
--- a/src/frontend/src/pages/build/BuildIndex.tsx
+++ b/src/frontend/src/pages/build/BuildIndex.tsx
@@ -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 (
,
content: supplierPart?.pk ? (
-
+
) : (
)
diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx
index c0ffdc9472..f702df7450 100644
--- a/src/frontend/src/pages/part/PartDetail.tsx
+++ b/src/frontend/src/pages/part/PartDetail.tsx
@@ -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)
}
];
diff --git a/src/frontend/src/pages/stock/LocationDetail.tsx b/src/frontend/src/pages/stock/LocationDetail.tsx
index 72f036cc3e..dcbcaf86d8 100644
--- a/src/frontend/src/pages/stock/LocationDetail.tsx
+++ b/src/frontend/src/pages/stock/LocationDetail.tsx
@@ -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';
diff --git a/src/frontend/src/tables/InvenTreeTable.tsx b/src/frontend/src/tables/InvenTreeTable.tsx
index 6decc5e017..a61a98e607 100644
--- a/src/frontend/src/tables/InvenTreeTable.tsx
+++ b/src/frontend/src/tables/InvenTreeTable.tsx
@@ -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>({
// 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>({
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);
diff --git a/src/frontend/src/tables/build/BuildOrderTable.tsx b/src/frontend/src/tables/build/BuildOrderTable.tsx
index eb362595f3..8c407e216f 100644
--- a/src/frontend/src/tables/build/BuildOrderTable.tsx
+++ b/src/frontend/src/tables/build/BuildOrderTable.tsx
@@ -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();
diff --git a/src/frontend/src/tables/build/BuildOrderTestTable.tsx b/src/frontend/src/tables/build/BuildOrderTestTable.tsx
index 8373cf53c4..dbfa6ac688 100644
--- a/src/frontend/src/tables/build/BuildOrderTestTable.tsx
+++ b/src/frontend/src/tables/build/BuildOrderTestTable.tsx
@@ -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
diff --git a/src/frontend/src/tables/build/BuildOutputTable.tsx b/src/frontend/src/tables/build/BuildOutputTable.tsx
index 9402322b90..22ebbef86b 100644
--- a/src/frontend/src/tables/build/BuildOutputTable.tsx
+++ b/src/frontend/src/tables/build/BuildOutputTable.tsx
@@ -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 [
];
- }, [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}
/>
- {
- if (hasTrackedItems && !!record.serial) {
- setSelectedOutputs([record]);
- openDrawer();
+
+ {build.external && (
+ }
+ title={t`External Build`}
+ >
+ {t`This build order is fulfilled by an external purchase order`}
+
+ )}
+ {
+ if (hasTrackedItems && !!record.serial) {
+ setSelectedOutputs([record]);
+ openDrawer();
+ }
}
- }
- }}
- />
+ }}
+ />
+
>
);
}
diff --git a/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx b/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx
index d7f508ffe0..843c87cecd 100644
--- a/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx
+++ b/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx
@@ -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 (
+
+ );
+ } else {
+ return '-';
+ }
+ }
+ },
{
accessor: 'quantity',
title: t`Quantity`,
@@ -276,7 +297,7 @@ export function PurchaseOrderLineItemTable({
const [selectedLine, setSelectedLine] = useState(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: () => {
diff --git a/src/frontend/src/tables/purchasing/PurchaseOrderTable.tsx b/src/frontend/src/tables/purchasing/PurchaseOrderTable.tsx
index 2968621288..d640389e0c 100644
--- a/src/frontend/src/tables/purchasing/PurchaseOrderTable.tsx
+++ b/src/frontend/src/tables/purchasing/PurchaseOrderTable.tsx
@@ -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,
diff --git a/src/frontend/src/tables/settings/PendingTasksTable.tsx b/src/frontend/src/tables/settings/PendingTasksTable.tsx
index 82478c1290..b276bc054b 100644
--- a/src/frontend/src/tables/settings/PendingTasksTable.tsx
+++ b/src/frontend/src/tables/settings/PendingTasksTable.tsx
@@ -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';
diff --git a/src/frontend/tests/pages/pui_build.spec.ts b/src/frontend/tests/pages/pui_build.spec.ts
index 2866a2a36c..a7c7134c99 100644
--- a/src/frontend/tests/pages/pui_build.spec.ts
+++ b/src/frontend/tests/pages/pui_build.spec.ts
@@ -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();
+});