2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-15 19:45:46 +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:
Oliver
2025-06-12 18:27:15 +10:00
committed by GitHub
parent 5915a1e13d
commit c6848b8e87
46 changed files with 864 additions and 157 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@ -36,6 +36,8 @@ The build calendar allows the user to navigate month-by-month and display the fi
## Build Order Details ## 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 ### Build Order Reference
Each Build Order is uniquely identified by its *Reference* field. Read more about [reference fields](../settings/reference.md). 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") }} {{ 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 ## Create Build Order
To create a build order for your part, you have two options: 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 | | Name | Description | Default | Units |
| ---- | ----------- | ------- | ----- | | ---- | ----------- | ------- | ----- |
{{ globalsetting("BUILDORDER_REFERENCE_PATTERN") }} {{ globalsetting("BUILDORDER_REFERENCE_PATTERN") }}
{{ globalsetting("BUILDORDER_EXTERNAL_BUILDS") }}
{{ globalsetting("BUILDORDER_REQUIRE_RESPONSIBLE") }} {{ globalsetting("BUILDORDER_REQUIRE_RESPONSIBLE") }}
{{ globalsetting("BUILDORDER_REQUIRE_ACTIVE_PART") }} {{ globalsetting("BUILDORDER_REQUIRE_ACTIVE_PART") }}
{{ globalsetting("BUILDORDER_REQUIRE_LOCKED_PART") }} {{ globalsetting("BUILDORDER_REQUIRE_LOCKED_PART") }}

View File

@ -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") }}

View File

@ -318,8 +318,14 @@ def define_env(env):
json.dump(data, f, indent=4) json.dump(data, f, indent=4)
@env.macro @env.macro
def rendersetting(key: str, setting: dict): def rendersetting(key: str, setting: dict, short: bool = False):
"""Render a provided setting object into a table row.""" """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'] name = setting['name']
description = setting['description'] description = setting['description']
default = setting.get('default') default = setting.get('default')
@ -328,37 +334,44 @@ def define_env(env):
default = f'`{default}`' if default else '' default = f'`{default}`' if default else ''
units = f'`{units}`' if units else '' units = f'`{units}`' if units else ''
return ( if short:
f'| <div title="{key}">{name}</div> | {description} | {default} | {units} |' return f'<span title="{key}"><strong>{name}</strong></span>'
)
return f'| <div title="{key}"><strong>{name}</strong></div> | {description} | {default} | {units} |'
@env.macro @env.macro
def globalsetting(key: str): def globalsetting(key: str, short: bool = False):
"""Extract information on a particular global setting. """Extract information on a particular global setting.
Arguments: Arguments:
key: The name of the global setting to extract information for. 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 global GLOBAL_SETTINGS
setting = GLOBAL_SETTINGS[key] 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 @env.macro
def usersetting(key: str): def usersetting(key: str, short: bool = False):
"""Extract information on a particular user setting. """Extract information on a particular user setting.
Arguments: Arguments:
key: The name of the user setting to extract information for. 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 global USER_SETTINGS
setting = USER_SETTINGS[key] 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 @env.macro
def tags_and_filters(): def tags_and_filters():

View File

@ -167,6 +167,7 @@ nav:
- Build Orders: manufacturing/build.md - Build Orders: manufacturing/build.md
- Build Outputs: manufacturing/output.md - Build Outputs: manufacturing/output.md
- Allocating Stock: manufacturing/allocate.md - Allocating Stock: manufacturing/allocate.md
- External Manufacturing: manufacturing/external.md
- Example Build Order: manufacturing/example.md - Example Build Order: manufacturing/example.md
- Purchasing: - Purchasing:
- Purchasing: purchasing/index.md - Purchasing: purchasing/index.md

View File

@ -1,12 +1,15 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ 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 v347 -> 2025-06-12 : https://github.com/inventree/InvenTree/pull/9764
- Adds "copy_tests" field to the DuplicatePart API endpoint - Adds "copy_tests" field to the DuplicatePart API endpoint

View File

@ -33,7 +33,7 @@ class BuildFilter(rest_filters.FilterSet):
"""Metaclass options.""" """Metaclass options."""
model = Build model = Build
fields = ['sales_order'] fields = ['sales_order', 'external']
status = rest_filters.NumberFilter(label=_('Order Status'), method='filter_status') status = rest_filters.NumberFilter(label=_('Order Status'), method='filter_status')
@ -355,6 +355,7 @@ class BuildList(DataExportViewMixin, BuildMixin, ListCreateAPI):
'project_code', 'project_code',
'priority', 'priority',
'level', 'level',
'external',
] ]
ordering_field_aliases = { ordering_field_aliases = {

View File

@ -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",
),
),
]

View File

@ -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) 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) take_from: Location to take stock from to make this build (if blank, can take from anywhere)
status: Build status code status: Build status code
external: Set to indicate that this build order is fulfilled externally
batch: Batch code transferred to build parts (optional) batch: Batch code transferred to build parts (optional)
creation_date: Date the build was created (auto) creation_date: Date the build was created (auto)
target_date: Date the build will be overdue target_date: Date the build will be overdue
@ -191,6 +192,13 @@ class Build(
"""Validate the BuildOrder model.""" """Validate the BuildOrder model."""
super().clean() 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 get_global_setting('BUILDORDER_REQUIRE_RESPONSIBLE'):
if not self.responsible: if not self.responsible:
raise ValidationError({ 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( destination = models.ForeignKey(
'stock.StockLocation', 'stock.StockLocation',
verbose_name=_('Destination Location'), verbose_name=_('Destination Location'),

View File

@ -73,6 +73,7 @@ class BuildSerializer(
'completed', 'completed',
'completion_date', 'completion_date',
'destination', 'destination',
'external',
'parent', 'parent',
'part', 'part',
'part_name', 'part_name',

View File

@ -9,18 +9,21 @@ from django.core.exceptions import ValidationError
from django.db.models import Sum from django.db.models import Sum
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
from django.urls import reverse
import structlog import structlog
import build.tasks import build.tasks
import common.models import common.models
import company.models
from build.models import Build, BuildItem, BuildLine, generate_next_build_reference from build.models import Build, BuildItem, BuildLine, generate_next_build_reference
from build.status_codes import BuildStatus from build.status_codes import BuildStatus
from common.settings import set_global_setting from common.settings import set_global_setting
from InvenTree import status_codes as status 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 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 from users.models import Owner
logger = structlog.get_logger('inventree') logger = structlog.get_logger('inventree')
@ -809,3 +812,142 @@ class AutoAllocationTests(BuildTestBase):
self.assertEqual(self.line_1.unallocated_quantity(), 0) self.assertEqual(self.line_1.unallocated_quantity(), 0)
self.assertEqual(self.line_2.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)

View File

@ -756,6 +756,12 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': False, 'default': False,
'validator': bool, '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': { 'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS': {
'name': _('Block Until Tests Pass'), 'name': _('Block Until Tests Pass'),
'description': _( 'description': _(

View File

@ -19,6 +19,7 @@ from drf_spectacular.utils import extend_schema_field
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
import build.models
import common.models import common.models
import common.settings import common.settings
import company.models import company.models
@ -324,6 +325,22 @@ class PurchaseOrderFilter(OrderFilter):
label=_('Completed After'), field_name='complete_date', lookup_expr='gt' 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: class PurchaseOrderMixin:
"""Mixin class for PurchaseOrder endpoints.""" """Mixin class for PurchaseOrder endpoints."""

View File

@ -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",
),
),
]

View File

@ -30,6 +30,7 @@ import order.validators
import report.mixins import report.mixins
import stock.models import stock.models
import users.models as UserModels import users.models as UserModels
from build.status_codes import BuildStatus
from common.currency import currency_code_default from common.currency import currency_code_default
from common.notifications import InvenTreeNotificationBodies from common.notifications import InvenTreeNotificationBodies
from common.settings import get_global_setting from common.settings import get_global_setting
@ -279,7 +280,7 @@ class Order(
Instances of this class: Instances of this class:
- PuchaseOrder - PurchaseOrder
- SalesOrder - SalesOrder
Attributes: Attributes:
@ -558,7 +559,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
@classmethod @classmethod
def get_status_class(cls): def get_status_class(cls):
"""Return the PurchasOrderStatus class.""" """Return the PurchaseOrderStatus class."""
return PurchaseOrderStatusGroups return PurchaseOrderStatusGroups
@classmethod @classmethod
@ -923,7 +924,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
status: The StockStatus to assign to the item (default: StockStatus.OK) status: The StockStatus to assign to the item (default: StockStatus.OK)
Keyword Arguments: 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) serials: Optional list of serial numbers to assign to the new StockItem(s)
notes: Optional notes field for the StockItem notes: Optional notes field for the StockItem
packaging: Optional packaging field for the StockItem packaging: Optional packaging field for the StockItem
@ -1005,20 +1006,61 @@ class PurchaseOrder(TotalPriceMixin, Order):
serialize = False serialize = False
serials = [None] 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: for sn in serials:
item = stock.models.StockItem( item = stock.models.StockItem(serial=sn, **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,
serial=sn,
purchase_price=unit_purchase_price,
)
# Assign the provided barcode # Assign the provided barcode
if barcode: if barcode:
@ -1639,6 +1681,11 @@ class PurchaseOrderLineItem(OrderLineItem):
Attributes: Attributes:
order: Reference to a PurchaseOrder object 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: class Meta:
@ -1670,6 +1717,25 @@ class PurchaseOrderLineItem(OrderLineItem):
if self.part.supplier != self.order.supplier: if self.part.supplier != self.order.supplier:
raise ValidationError({'part': _('Supplier part must match 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): def __str__(self):
"""Render a string representation of a PurchaseOrderLineItem instance.""" """Render a string representation of a PurchaseOrderLineItem instance."""
return '{n} x {part} - {po}'.format( return '{n} x {part} - {po}'.format(
@ -1727,6 +1793,17 @@ class PurchaseOrderLineItem(OrderLineItem):
"""Return the 'purchase_price' field as 'price'.""" """Return the 'purchase_price' field as 'price'."""
return self.purchase_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( destination = TreeForeignKey(
'stock.StockLocation', 'stock.StockLocation',
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@ -2544,7 +2621,7 @@ class ReturnOrder(TotalPriceMixin, Order):
@transaction.atomic @transaction.atomic
def hold_order(self): def hold_order(self):
"""Attempt to tranasition to ON_HOLD status.""" """Attempt to transition to ON_HOLD status."""
return self.handle_transition( return self.handle_transition(
self.status, ReturnOrderStatus.ON_HOLD.value, self, self._action_hold self.status, ReturnOrderStatus.ON_HOLD.value, self, self._action_hold
) )

View File

@ -21,6 +21,7 @@ from rest_framework import serializers
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
from sql_util.utils import SubqueryCount, SubquerySum from sql_util.utils import SubqueryCount, SubquerySum
import build.serializers
import order.models import order.models
import part.filters as part_filters import part.filters as part_filters
import part.models as part_models import part.models as part_models
@ -496,6 +497,8 @@ class PurchaseOrderLineItemSerializer(
'notes', 'notes',
'order', 'order',
'order_detail', 'order_detail',
'build_order',
'build_order_detail',
'overdue', 'overdue',
'part_detail', 'part_detail',
'supplier_part_detail', 'supplier_part_detail',
@ -645,6 +648,10 @@ class PurchaseOrderLineItemSerializer(
source='order', read_only=True, allow_null=True, many=False 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( merge_items = serializers.BooleanField(
label=_('Merge Items'), label=_('Merge Items'),
help_text=_( help_text=_(

View File

@ -44,7 +44,9 @@ from order.status_codes import PurchaseOrderStatusGroups, SalesOrderStatusGroups
def annotate_in_production_quantity(reference=''): def annotate_in_production_quantity(reference=''):
"""Annotate the 'in production' quantity for each part in a queryset. """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: Arguments:
reference: Reference to the part from the current queryset (default = '') 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 = ''): def annotate_on_order_quantity(reference: str = ''):
"""Annotate the 'on order' quantity for each part in a queryset. """Annotate the 'on order' quantity for each part in a queryset.

View File

@ -1627,6 +1627,24 @@ class Part(
return quantity 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): def build_order_allocations(self, **kwargs):
"""Return all 'BuildItem' objects which allocate this part to Build objects.""" """Return all 'BuildItem' objects which allocate this part to Build objects."""
include_variants = kwargs.get('include_variants', True) include_variants = kwargs.get('include_variants', True)

View File

@ -18,7 +18,7 @@ from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.models import convert_money
from drf_spectacular.utils import extend_schema_field from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
from sql_util.utils import SubqueryCount, SubquerySum from sql_util.utils import SubqueryCount
from taggit.serializers import TagListSerializerField from taggit.serializers import TagListSerializerField
import common.currency import common.currency
@ -33,7 +33,6 @@ import part.stocktake
import part.tasks import part.tasks
import stock.models import stock.models
import users.models import users.models
from build.status_codes import BuildStatusGroups
from importer.registry import register_importer from importer.registry import register_importer
from InvenTree.mixins import DataImportExportSerializerMixin from InvenTree.mixins import DataImportExportSerializerMixin
from InvenTree.ready import isGeneratingSchema from InvenTree.ready import isGeneratingSchema
@ -724,6 +723,7 @@ class PartSerializer(
'allocated_to_build_orders', 'allocated_to_build_orders',
'allocated_to_sales_orders', 'allocated_to_sales_orders',
'building', 'building',
'scheduled_to_build',
'category_default_location', 'category_default_location',
'in_stock', 'in_stock',
'ordering', '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 # Annotate with the total 'building' quantity
queryset = queryset.annotate( queryset = queryset.annotate(
building=Coalesce( building=part_filters.annotate_in_production_quantity()
SubquerySum('builds__quantity', filter=build_filter), )
Decimal(0),
output_field=models.DecimalField(), queryset = queryset.annotate(
) scheduled_to_build=part_filters.annotate_scheduled_to_build_quantity()
) )
# Annotate with the number of 'suppliers' # Annotate with the number of 'suppliers'
@ -947,42 +944,65 @@ class PartSerializer(
# Annotated fields # Annotated fields
allocated_to_build_orders = serializers.FloatField(read_only=True, allow_null=True) allocated_to_build_orders = serializers.FloatField(read_only=True, allow_null=True)
allocated_to_sales_orders = serializers.FloatField(read_only=True, allow_null=True) allocated_to_sales_orders = serializers.FloatField(read_only=True, allow_null=True)
building = serializers.FloatField( 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( in_stock = serializers.FloatField(
read_only=True, allow_null=True, label=_('In Stock') read_only=True, allow_null=True, label=_('In Stock')
) )
ordering = serializers.FloatField( ordering = serializers.FloatField(
read_only=True, allow_null=True, label=_('On Order') read_only=True, allow_null=True, label=_('On Order')
) )
required_for_build_orders = serializers.IntegerField( required_for_build_orders = serializers.IntegerField(
read_only=True, allow_null=True read_only=True, allow_null=True
) )
required_for_sales_orders = serializers.IntegerField( required_for_sales_orders = serializers.IntegerField(
read_only=True, allow_null=True read_only=True, allow_null=True
) )
stock_item_count = serializers.IntegerField( stock_item_count = serializers.IntegerField(
read_only=True, allow_null=True, label=_('Stock Items') read_only=True, allow_null=True, label=_('Stock Items')
) )
revision_count = serializers.IntegerField( revision_count = serializers.IntegerField(
read_only=True, allow_null=True, label=_('Revisions') read_only=True, allow_null=True, label=_('Revisions')
) )
suppliers = serializers.IntegerField( suppliers = serializers.IntegerField(
read_only=True, allow_null=True, label=_('Suppliers') read_only=True, allow_null=True, label=_('Suppliers')
) )
total_in_stock = serializers.FloatField( total_in_stock = serializers.FloatField(
read_only=True, allow_null=True, label=_('Total Stock') read_only=True, allow_null=True, label=_('Total Stock')
) )
external_stock = serializers.FloatField( external_stock = serializers.FloatField(
read_only=True, allow_null=True, label=_('External Stock') read_only=True, allow_null=True, label=_('External Stock')
) )
unallocated_stock = serializers.FloatField( unallocated_stock = serializers.FloatField(
read_only=True, allow_null=True, label=_('Unallocated Stock') read_only=True, allow_null=True, label=_('Unallocated Stock')
) )
category_default_location = serializers.IntegerField( category_default_location = serializers.IntegerField(
read_only=True, allow_null=True read_only=True, allow_null=True
) )
variant_stock = serializers.FloatField( variant_stock = serializers.FloatField(
read_only=True, allow_null=True, label=_('Variant Stock') read_only=True, allow_null=True, label=_('Variant Stock')
) )

View File

@ -181,6 +181,7 @@ class ScheduleMixin:
obj['args'] = f"'{slug}', '{func_name}'" obj['args'] = f"'{slug}', '{func_name}'"
tasks = Schedule.objects.filter(name=task_name) tasks = Schedule.objects.filter(name=task_name)
if len(tasks) > 1: if len(tasks) > 1:
logger.info( logger.info(
"Found multiple tasks; Adding a new scheduled task '%s'", "Found multiple tasks; Adding a new scheduled task '%s'",
@ -191,10 +192,11 @@ class ScheduleMixin:
elif len(tasks) == 1: elif len(tasks) == 1:
# Scheduled task already exists - update it! # Scheduled task already exists - update it!
logger.info("Updating scheduled task '%s'", task_name) logger.info("Updating scheduled task '%s'", task_name)
instance = Schedule.objects.get(name=task_name)
for item in obj: if instance := tasks.first():
setattr(instance, item, obj[item]) for item in obj:
instance.save() setattr(instance, item, obj[item])
instance.save()
else: else:
logger.info("Adding scheduled task '%s'", task_name) logger.info("Adding scheduled task '%s'", task_name)
# Create a new scheduled task # Create a new scheduled task

View File

@ -410,6 +410,7 @@ function ProgressBarValue(props: Readonly<FieldProps>) {
return ( return (
<ProgressBar <ProgressBar
size='lg'
value={props.field_data.progress} value={props.field_data.progress}
maximum={props.field_data.total} maximum={props.field_data.total}
progressLabel progressLabel

View File

@ -1,7 +1,8 @@
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { forwardRef, useImperativeHandle, useState } from 'react'; 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 { api } from '../../../../App';
import type { PreviewAreaComponent } from '../TemplateEditor'; import type { PreviewAreaComponent } from '../TemplateEditor';

View File

@ -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 { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { import {

View File

@ -71,24 +71,30 @@ export function RelatedModelField({
return; return;
} }
api.get(url).then((response) => { const params = definition?.filters ?? {};
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) api
if (definition.onValueChange) { .get(url, {
definition.onValueChange(response.data[pk_field], response.data); 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 { } else {
setPk(null); setPk(null);
} }

View File

@ -1,6 +1,6 @@
import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import { UserRoles } from '@lib/index';
import type { ApiFormFieldSet } from '@lib/types/Forms'; import type { ApiFormFieldSet } from '@lib/types/Forms';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { Table } from '@mantine/core'; import { Table } from '@mantine/core';

View File

@ -126,7 +126,8 @@ export function useBuildOrderFields({
filters: { filters: {
is_active: true is_active: true
} }
} },
external: {}
}; };
if (create) { if (create) {
@ -137,6 +138,10 @@ export function useBuildOrderFields({
delete fields.project_code; delete fields.project_code;
} }
if (!globalSettings.isSet('BUILDORDER_EXTERNAL_BUILDS', true)) {
delete fields.external;
}
return fields; return fields;
}, [create, destination, batchCode, globalSettings]); }, [create, destination, batchCode, globalSettings]);
} }

View File

@ -64,9 +64,14 @@ export function usePurchaseOrderLineItemFields({
orderId?: number; orderId?: number;
create?: boolean; create?: boolean;
}) { }) {
const globalSettings = useGlobalSettingsState();
const [purchasePrice, setPurchasePrice] = useState<string>(''); const [purchasePrice, setPurchasePrice] = useState<string>('');
const [autoPricing, setAutoPricing] = useState(true); const [autoPricing, setAutoPricing] = useState(true);
// Internal part information
const [part, setPart] = useState<any>({});
useEffect(() => { useEffect(() => {
if (autoPricing) { if (autoPricing) {
setPurchasePrice(''); setPurchasePrice('');
@ -92,6 +97,9 @@ export function usePurchaseOrderLineItemFields({
active: true, active: true,
part_active: true part_active: true
}, },
onValueChange: (value, record) => {
setPart(record?.part_detail ?? {});
},
adjustFilters: (adjust: ApiFormAdjustFilterType) => { adjustFilters: (adjust: ApiFormAdjustFilterType) => {
return { return {
...adjust.filters, ...adjust.filters,
@ -119,6 +127,14 @@ export function usePurchaseOrderLineItemFields({
destination: { destination: {
icon: <IconSitemap /> icon: <IconSitemap />
}, },
build_order: {
disabled: !part?.assembly,
filters: {
external: true,
outstanding: true,
part: part?.pk
}
},
notes: { notes: {
icon: <IconNotes /> icon: <IconNotes />
}, },
@ -127,12 +143,24 @@ export function usePurchaseOrderLineItemFields({
} }
}; };
if (!globalSettings.isSet('BUILDORDER_EXTERNAL_BUILDS', false)) {
delete fields.build_order;
}
if (create) { if (create) {
fields['merge_items'] = {}; fields['merge_items'] = {};
} }
return fields; return fields;
}, [create, orderId, supplierId, autoPricing, purchasePrice]); }, [
create,
orderId,
part,
globalSettings,
supplierId,
autoPricing,
purchasePrice
]);
return fields; return fields;
} }

View File

@ -32,7 +32,7 @@ import RemoveRowButton from '../components/buttons/RemoveRowButton';
import { StandaloneField } from '../components/forms/StandaloneField'; import { StandaloneField } from '../components/forms/StandaloneField';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl } from '@lib/index'; import { getDetailUrl } from '@lib/functions/Navigation';
import type { import type {
ApiFormAdjustFilterType, ApiFormAdjustFilterType,
ApiFormFieldChoice, ApiFormFieldChoice,

View File

@ -248,6 +248,7 @@ export default function SystemSettings() {
<GlobalSettingList <GlobalSettingList
keys={[ keys={[
'BUILDORDER_REFERENCE_PATTERN', 'BUILDORDER_REFERENCE_PATTERN',
'BUILDORDER_EXTERNAL_BUILDS',
'BUILDORDER_REQUIRE_RESPONSIBLE', 'BUILDORDER_REQUIRE_RESPONSIBLE',
'BUILDORDER_REQUIRE_ACTIVE_PART', 'BUILDORDER_REQUIRE_ACTIVE_PART',
'BUILDORDER_REQUIRE_LOCKED_PART', 'BUILDORDER_REQUIRE_LOCKED_PART',

View File

@ -8,6 +8,7 @@ import {
IconList, IconList,
IconListCheck, IconListCheck,
IconListNumbers, IconListNumbers,
IconShoppingCart,
IconSitemap IconSitemap
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useMemo } from 'react'; import { useMemo } from 'react';
@ -25,6 +26,7 @@ import {
type DetailsField, type DetailsField,
DetailsTable DetailsTable
} from '../../components/details/Details'; } from '../../components/details/Details';
import DetailsBadge from '../../components/details/DetailsBadge';
import { DetailsImage } from '../../components/details/DetailsImage'; import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { import {
@ -49,12 +51,14 @@ import {
} from '../../hooks/UseForm'; } from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance'; import { useInstance } from '../../hooks/UseInstance';
import useStatusCodes from '../../hooks/UseStatusCodes'; import useStatusCodes from '../../hooks/UseStatusCodes';
import { useGlobalSettingsState } from '../../states/SettingsState';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
import BuildAllocatedStockTable from '../../tables/build/BuildAllocatedStockTable'; import BuildAllocatedStockTable from '../../tables/build/BuildAllocatedStockTable';
import BuildLineTable from '../../tables/build/BuildLineTable'; import BuildLineTable from '../../tables/build/BuildLineTable';
import { BuildOrderTable } from '../../tables/build/BuildOrderTable'; import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
import BuildOrderTestTable from '../../tables/build/BuildOrderTestTable'; import BuildOrderTestTable from '../../tables/build/BuildOrderTestTable';
import BuildOutputTable from '../../tables/build/BuildOutputTable'; import BuildOutputTable from '../../tables/build/BuildOutputTable';
import { PurchaseOrderTable } from '../../tables/purchasing/PurchaseOrderTable';
import { StockItemTable } from '../../tables/stock/StockItemTable'; import { StockItemTable } from '../../tables/stock/StockItemTable';
/** /**
@ -64,6 +68,7 @@ export default function BuildDetail() {
const { id } = useParams(); const { id } = useParams();
const user = useUserState(); const user = useUserState();
const globalSettings = useGlobalSettingsState();
const buildStatus = useStatusCodes({ modelType: ModelType.build }); const buildStatus = useStatusCodes({ modelType: ModelType.build });
@ -124,6 +129,24 @@ export default function BuildDetail() {
hidden: hidden:
!build.status_custom_key || build.status_custom_key == build.status !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', type: 'text',
name: 'reference', name: 'reference',
@ -287,10 +310,38 @@ export default function BuildDetail() {
}, },
{ {
name: 'line-items', name: 'line-items',
label: t`Line Items`, label: t`Required Stock`,
icon: <IconListNumbers />, icon: <IconListNumbers />,
content: build?.pk ? <BuildLineTable build={build} /> : <Skeleton /> 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', name: 'incomplete-outputs',
label: t`Incomplete Outputs`, label: t`Incomplete Outputs`,
@ -320,32 +371,18 @@ export default function BuildDetail() {
) )
}, },
{ {
name: 'allocated-stock', name: 'external-purchase-orders',
label: t`Allocated Stock`, label: t`External Orders`,
icon: <IconList />, icon: <IconShoppingCart />,
hidden:
build.status == buildStatus.COMPLETE ||
build.status == buildStatus.CANCELLED,
content: build.pk ? ( content: build.pk ? (
<BuildAllocatedStockTable buildId={build.pk} showPartInfo allowEdit /> <PurchaseOrderTable externalBuildId={build.pk} />
) : ( ) : (
<Skeleton /> <Skeleton />
) ),
}, hidden:
{ !user.hasViewRole(UserRoles.purchase_order) ||
name: 'consumed-stock', !build.external ||
label: t`Consumed Stock`, !globalSettings.isSet('BUILDORDER_EXTERNAL_BUILDS')
icon: <IconListCheck />,
content: (
<StockItemTable
allowAdd={false}
tableName='build-consumed'
showLocation={false}
params={{
consumed_by: id
}}
/>
)
}, },
{ {
name: 'child-orders', name: 'child-orders',
@ -377,7 +414,7 @@ export default function BuildDetail() {
model_id: build.pk model_id: build.pk
}) })
]; ];
}, [build, id, user, buildStatus]); }, [build, id, user, buildStatus, globalSettings]);
const buildOrderFields = useBuildOrderFields({ create: false }); const buildOrderFields = useBuildOrderFields({ create: false });
@ -531,6 +568,12 @@ export default function BuildDetail() {
status={build.status_custom_key} status={build.status_custom_key}
type={ModelType.build} type={ModelType.build}
options={{ size: 'lg' }} options={{ size: 'lg' }}
/>,
<DetailsBadge
label={t`External`}
color='blue'
key='external'
visible={build.external}
/> />
]; ];
}, [build, instanceQuery]); }, [build, instanceQuery]);

View File

@ -13,14 +13,25 @@ import PermissionDenied from '../../components/errors/PermissionDenied';
import { PageDetail } from '../../components/nav/PageDetail'; import { PageDetail } from '../../components/nav/PageDetail';
import type { PanelType } from '../../components/panels/Panel'; import type { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup'; import { PanelGroup } from '../../components/panels/PanelGroup';
import { useGlobalSettingsState } from '../../states/SettingsState';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
import { PartCategoryFilter } from '../../tables/Filter'; import { PartCategoryFilter } from '../../tables/Filter';
import { BuildOrderTable } from '../../tables/build/BuildOrderTable'; import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
function BuildOrderCalendar() { function BuildOrderCalendar() {
const globalSettings = useGlobalSettingsState();
const calendarFilters: TableFilter[] = useMemo(() => { 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 ( return (
<OrderCalendar <OrderCalendar

View File

@ -266,7 +266,10 @@ export default function SupplierPartDetail() {
label: t`Purchase Orders`, label: t`Purchase Orders`,
icon: <IconShoppingCart />, icon: <IconShoppingCart />,
content: supplierPart?.pk ? ( content: supplierPart?.pk ? (
<PurchaseOrderTable supplierPartId={supplierPart.pk} /> <PurchaseOrderTable
supplierId={supplierPart.supplier}
supplierPartId={supplierPart.pk}
/>
) : ( ) : (
<Skeleton /> <Skeleton />
) )

View File

@ -288,11 +288,12 @@ export default function PartDetail() {
name: 'required', name: 'required',
label: t`Required for Orders`, label: t`Required for Orders`,
hidden: part.required <= 0, hidden: part.required <= 0,
icon: 'tick_off' icon: 'stocktake'
}, },
{ {
type: 'progressbar', type: 'progressbar',
name: 'allocated_to_build_orders', name: 'allocated_to_build_orders',
icon: 'tick_off',
total: part.required_for_build_orders, total: part.required_for_build_orders,
progress: part.allocated_to_build_orders, progress: part.allocated_to_build_orders,
label: t`Allocated to Build Orders`, label: t`Allocated to Build Orders`,
@ -303,6 +304,7 @@ export default function PartDetail() {
}, },
{ {
type: 'progressbar', type: 'progressbar',
icon: 'tick_off',
name: 'allocated_to_sales_orders', name: 'allocated_to_sales_orders',
total: part.required_for_sales_orders, total: part.required_for_sales_orders,
progress: part.allocated_to_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 hidden: true // TODO: Expose "can_build" to the API
}, },
{ {
type: 'string', type: 'progressbar',
name: 'building', name: 'building',
unit: true,
label: t`In Production`, 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)
} }
]; ];

View File

@ -1,8 +1,8 @@
import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType'; import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles'; import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl } from '@lib/functions/Navigation'; import { getDetailUrl } from '@lib/functions/Navigation';
import { apiUrl } from '@lib/index';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { Group, Skeleton, Stack, Text } from '@mantine/core'; import { Group, Skeleton, Stack, Text } from '@mantine/core';
import { IconInfoCircle, IconPackages, IconSitemap } from '@tabler/icons-react'; import { IconInfoCircle, IconPackages, IconSitemap } from '@tabler/icons-react';

View File

@ -34,6 +34,7 @@ import type { TableColumn } from './Column';
import InvenTreeTableHeader from './InvenTreeTableHeader'; import InvenTreeTableHeader from './InvenTreeTableHeader';
import { type RowAction, RowActions } from './RowActions'; import { type RowAction, RowActions } from './RowActions';
const ACTIONS_COLUMN_ACCESSOR: string = '--actions--';
const defaultPageSize: number = 25; const defaultPageSize: number = 25;
const PAGE_SIZES = [10, 15, 20, 25, 50, 100, 500]; 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 row actions are available, add a column for them
if (tableProps.rowActions) { if (tableProps.rowActions) {
cols.push({ cols.push({
accessor: '--actions--', accessor: ACTIONS_COLUMN_ACCESSOR,
title: ' ', title: ' ',
hidden: false, hidden: false,
resizable: false, resizable: false,
@ -359,6 +360,23 @@ export function InvenTreeTable<T extends Record<string, any>>({
columns: dataColumns 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 // Reset the pagination state when the search term changes
useEffect(() => { useEffect(() => {
tableState.setPage(1); tableState.setPage(1);

View File

@ -12,8 +12,10 @@ import { RenderUser } from '../../components/render/User';
import { useBuildOrderFields } from '../../forms/BuildForms'; import { useBuildOrderFields } from '../../forms/BuildForms';
import { useCreateApiFormModal } from '../../hooks/UseForm'; import { useCreateApiFormModal } from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable'; import { useTable } from '../../hooks/UseTable';
import { useGlobalSettingsState } from '../../states/SettingsState';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
import { import {
BooleanColumn,
CreationDateColumn, CreationDateColumn,
DateColumn, DateColumn,
PartColumn, PartColumn,
@ -59,6 +61,7 @@ export function BuildOrderTable({
parentBuildId?: number; parentBuildId?: number;
salesOrderId?: number; salesOrderId?: number;
}>) { }>) {
const globalSettings = useGlobalSettingsState();
const table = useTable(!!partId ? 'buildorder-part' : 'buildorder-index'); const table = useTable(!!partId ? 'buildorder-part' : 'buildorder-index');
const tableColumns = useMemo(() => { const tableColumns = useMemo(() => {
@ -109,6 +112,13 @@ export function BuildOrderTable({
accessor: 'priority', accessor: 'priority',
sortable: true sortable: true
}, },
BooleanColumn({
accessor: 'external',
title: t`External`,
sortable: true,
switchable: true,
hidden: !globalSettings.isSet('BUILDORDER_EXTERNAL_BUILDS')
}),
CreationDateColumn({}), CreationDateColumn({}),
StartDateColumn({}), StartDateColumn({}),
TargetDateColumn({}), TargetDateColumn({}),
@ -126,7 +136,7 @@ export function BuildOrderTable({
}, },
ResponsibleColumn({}) ResponsibleColumn({})
]; ];
}, [parentBuildId]); }, [parentBuildId, globalSettings]);
const tableFilters: TableFilter[] = useMemo(() => { const tableFilters: TableFilter[] = useMemo(() => {
const filters: TableFilter[] = [ const filters: TableFilter[] = [
@ -160,6 +170,12 @@ export function BuildOrderTable({
HasProjectCodeFilter(), HasProjectCodeFilter(),
IssuedByFilter(), IssuedByFilter(),
ResponsibleFilter(), ResponsibleFilter(),
{
name: 'external',
label: t`External`,
description: t`Show external build orders`,
active: globalSettings.isSet('BUILDORDER_EXTERNAL_BUILDS')
},
PartCategoryFilter() PartCategoryFilter()
]; ];
@ -174,7 +190,7 @@ export function BuildOrderTable({
} }
return filters; return filters;
}, [partId]); }, [partId, globalSettings]);
const user = useUserState(); const user = useUserState();

View File

@ -2,17 +2,11 @@ import { t } from '@lingui/core/macro';
import { ActionIcon, Badge, Group, Text, Tooltip } from '@mantine/core'; import { ActionIcon, Badge, Group, Text, Tooltip } from '@mantine/core';
import { IconCirclePlus } from '@tabler/icons-react'; import { IconCirclePlus } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import { type ReactNode, useEffect, useMemo, useState } from 'react';
type ReactNode,
useCallback,
useEffect,
useMemo,
useState
} from 'react';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import { ModelType } from '@lib/index';
import type { TableFilter } from '@lib/types/Filters'; import type { TableFilter } from '@lib/types/Filters';
import type { ApiFormFieldSet } from '@lib/types/Forms'; import type { ApiFormFieldSet } from '@lib/types/Forms';
import { PassFailButton } from '../../components/buttons/YesNoButton'; import { PassFailButton } from '../../components/buttons/YesNoButton';
@ -26,7 +20,6 @@ import { useUserState } from '../../states/UserState';
import type { TableColumn } from '../Column'; import type { TableColumn } from '../Column';
import { LocationColumn } from '../ColumnRenderers'; import { LocationColumn } from '../ColumnRenderers';
import { InvenTreeTable } from '../InvenTreeTable'; import { InvenTreeTable } from '../InvenTreeTable';
import type { RowAction } from '../RowActions';
import { TableHoverCard } from '../TableHoverCard'; import { TableHoverCard } from '../TableHoverCard';
/** /**
@ -235,13 +228,6 @@ export default function BuildOrderTestTable({
return []; return [];
}, []); }, []);
const rowActions = useCallback(
(record: any): RowAction[] => {
return [];
},
[user]
);
return ( return (
<> <>
{createTestResult.modal} {createTestResult.modal}
@ -256,7 +242,6 @@ export default function BuildOrderTestTable({
tests: true, tests: true,
build: buildId build: buildId
}, },
rowActions: rowActions,
tableFilters: tableFilters, tableFilters: tableFilters,
tableActions: tableActions, tableActions: tableActions,
modelType: ModelType.stockitem modelType: ModelType.stockitem

View File

@ -6,10 +6,12 @@ import {
Group, Group,
Paper, Paper,
Space, Space,
Stack,
Text Text
} from '@mantine/core'; } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { import {
IconBuildingFactory2,
IconCircleCheck, IconCircleCheck,
IconCircleX, IconCircleX,
IconExclamationCircle IconExclamationCircle
@ -22,6 +24,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType'; import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles'; import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import type { TableFilter } from '@lib/types/Filters';
import { ActionButton } from '../../components/buttons/ActionButton'; import { ActionButton } from '../../components/buttons/ActionButton';
import { AddItemButton } from '../../components/buttons/AddItemButton'; import { AddItemButton } from '../../components/buttons/AddItemButton';
import { ProgressBar } from '../../components/items/ProgressBar'; import { ProgressBar } from '../../components/items/ProgressBar';
@ -43,6 +46,7 @@ import { useTable } from '../../hooks/UseTable';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
import type { TableColumn } from '../Column'; import type { TableColumn } from '../Column';
import { LocationColumn, PartColumn, StatusColumn } from '../ColumnRenderers'; import { LocationColumn, PartColumn, StatusColumn } from '../ColumnRenderers';
import { StatusFilterOptions } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable'; import { InvenTreeTable } from '../InvenTreeTable';
import { type RowAction, RowEditAction, RowViewAction } from '../RowActions'; import { type RowAction, RowEditAction, RowViewAction } from '../RowActions';
import { TableHoverCard } from '../TableHoverCard'; 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(() => { const tableActions = useMemo(() => {
return [ return [
<ActionButton <ActionButton
@ -373,11 +388,11 @@ export default function BuildOutputTable({
<AddItemButton <AddItemButton
key='add-build-output' key='add-build-output'
tooltip={t`Add Build Output`} tooltip={t`Add Build Output`}
hidden={!user.hasAddRole(UserRoles.build)} hidden={build.external || !user.hasAddRole(UserRoles.build)}
onClick={addBuildOutput.open} onClick={addBuildOutput.open}
/> />
]; ];
}, [user, table.selectedRecords, table.hasSelectedRecords]); }, [build, user, table.selectedRecords, table.hasSelectedRecords]);
const rowActions = useCallback( const rowActions = useCallback(
(record: any): RowAction[] => { (record: any): RowAction[] => {
@ -568,32 +583,44 @@ export default function BuildOutputTable({
opened={drawerOpen} opened={drawerOpen}
close={closeDrawer} close={closeDrawer}
/> />
<InvenTreeTable <Stack gap='xs'>
tableState={table} {build.external && (
url={apiUrl(ApiEndpoints.stock_item_list)} <Alert
columns={tableColumns} color='blue'
props={{ icon={<IconBuildingFactory2 />}
params: { title={t`External Build`}
part_detail: true, >
location_detail: true, {t`This build order is fulfilled by an external purchase order`}
tests: true, </Alert>
is_building: true, )}
build: buildId <InvenTreeTable
}, tableState={table}
enableLabels: true, url={apiUrl(ApiEndpoints.stock_item_list)}
enableReports: true, columns={tableColumns}
dataFormatter: formatRecords, props={{
tableActions: tableActions, params: {
rowActions: rowActions, part_detail: true,
enableSelection: true, location_detail: true,
onRowClick: (record: any) => { tests: true,
if (hasTrackedItems && !!record.serial) { is_building: true,
setSelectedOutputs([record]); build: buildId
openDrawer(); },
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>
</> </>
); );
} }

View File

@ -8,10 +8,12 @@ import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles'; import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import type { TableFilter } from '@lib/types/Filters'; import type { TableFilter } from '@lib/types/Filters';
import { useNavigate } from 'react-router-dom';
import { ActionButton } from '../../components/buttons/ActionButton'; import { ActionButton } from '../../components/buttons/ActionButton';
import { AddItemButton } from '../../components/buttons/AddItemButton'; import { AddItemButton } from '../../components/buttons/AddItemButton';
import ImporterDrawer from '../../components/importer/ImporterDrawer'; import ImporterDrawer from '../../components/importer/ImporterDrawer';
import { ProgressBar } from '../../components/items/ProgressBar'; import { ProgressBar } from '../../components/items/ProgressBar';
import { RenderInstance } from '../../components/render/Instance';
import { RenderStockLocation } from '../../components/render/Stock'; import { RenderStockLocation } from '../../components/render/Stock';
import { dataImporterSessionFields } from '../../forms/ImporterForms'; import { dataImporterSessionFields } from '../../forms/ImporterForms';
import { import {
@ -40,7 +42,8 @@ import {
type RowAction, type RowAction,
RowDeleteAction, RowDeleteAction,
RowDuplicateAction, RowDuplicateAction,
RowEditAction RowEditAction,
RowViewAction
} from '../RowActions'; } from '../RowActions';
import { TableHoverCard } from '../TableHoverCard'; import { TableHoverCard } from '../TableHoverCard';
@ -62,6 +65,7 @@ export function PurchaseOrderLineItemTable({
}>) { }>) {
const table = useTable('purchase-order-line-item'); const table = useTable('purchase-order-line-item');
const navigate = useNavigate();
const user = useUserState(); const user = useUserState();
// Data import // Data import
@ -142,6 +146,23 @@ export function PurchaseOrderLineItemTable({
sortable: false sortable: false
}, },
ReferenceColumn({}), 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', accessor: 'quantity',
title: t`Quantity`, title: t`Quantity`,
@ -276,7 +297,7 @@ export function PurchaseOrderLineItemTable({
const [selectedLine, setSelectedLine] = useState<number>(0); const [selectedLine, setSelectedLine] = useState<number>(0);
const editPurchaseOrderFields = usePurchaseOrderLineItemFields({ const editLineItemFields = usePurchaseOrderLineItemFields({
create: false, create: false,
orderId: orderId, orderId: orderId,
supplierId: supplierId supplierId: supplierId
@ -286,7 +307,7 @@ export function PurchaseOrderLineItemTable({
url: ApiEndpoints.purchase_order_line_list, url: ApiEndpoints.purchase_order_line_list,
pk: selectedLine, pk: selectedLine,
title: t`Edit Line Item`, title: t`Edit Line Item`,
fields: editPurchaseOrderFields, fields: editLineItemFields,
table: table table: table
}); });
@ -326,6 +347,13 @@ export function PurchaseOrderLineItemTable({
receiveLineItems.open(); receiveLineItems.open();
} }
}, },
RowViewAction({
hidden: !record.build_order,
title: t`View Build Order`,
modelType: ModelType.build,
modelId: record.build_order,
navigate: navigate
}),
RowEditAction({ RowEditAction({
hidden: !user.hasChangeRole(UserRoles.purchase_order), hidden: !user.hasChangeRole(UserRoles.purchase_order),
onClick: () => { onClick: () => {

View File

@ -53,10 +53,12 @@ import { InvenTreeTable } from '../InvenTreeTable';
*/ */
export function PurchaseOrderTable({ export function PurchaseOrderTable({
supplierId, supplierId,
supplierPartId supplierPartId,
externalBuildId
}: Readonly<{ }: Readonly<{
supplierId?: number; supplierId?: number;
supplierPartId?: number; supplierPartId?: number;
externalBuildId?: number;
}>) { }>) {
const table = useTable('purchase-order'); const table = useTable('purchase-order');
const user = useUserState(); const user = useUserState();
@ -178,7 +180,8 @@ export function PurchaseOrderTable({
params: { params: {
supplier_detail: true, supplier_detail: true,
supplier: supplierId, supplier: supplierId,
supplier_part: supplierPartId supplier_part: supplierPartId,
external_build: externalBuildId
}, },
tableFilters: tableFilters, tableFilters: tableFilters,
tableActions: tableActions, tableActions: tableActions,

View File

@ -2,8 +2,8 @@ import { t } from '@lingui/core/macro';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import { UserRoles } from '@lib/index';
import { notifications, showNotification } from '@mantine/notifications'; import { notifications, showNotification } from '@mantine/notifications';
import { IconTrashXFilled, IconX } from '@tabler/icons-react'; import { IconTrashXFilled, IconX } from '@tabler/icons-react';
import { api } from '../../App'; import { api } from '../../App';

View File

@ -3,6 +3,7 @@ import { test } from '../baseFixtures.ts';
import { import {
activateCalendarView, activateCalendarView,
clearTableFilters, clearTableFilters,
clickOnRowMenu,
getRowFromCell, getRowFromCell,
loadTab, loadTab,
navigate, navigate,
@ -65,7 +66,7 @@ test('Build Order - Basic Tests', async ({ browser }) => {
await loadTab(page, 'Attachments'); await loadTab(page, 'Attachments');
await loadTab(page, 'Notes'); await loadTab(page, 'Notes');
await loadTab(page, 'Incomplete Outputs'); await loadTab(page, 'Incomplete Outputs');
await loadTab(page, 'Line Items'); await loadTab(page, 'Required Stock');
await loadTab(page, 'Allocated Stock'); await loadTab(page, 'Allocated Stock');
// Check for expected text in the table // Check for expected text in the table
@ -373,3 +374,53 @@ test('Build Order - Duplicate', async ({ browser }) => {
await page.getByText('Pending').first().waitFor(); 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();
});