2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-14 19:15:41 +00:00

[Feature] External build order (#9312)

* Add BuildOrder reference to PurchaseOrderLineItem

* Add setting to enable / disable external build orders

* Fix for supplier part detail

* Update forms

* Filter build list by "external" status

* Add "external" attribute to BuildOrder

* Filter by external build when selecting against purchase order line item

* Add frontend elements

* Prevent creation of build outputs

* Tweak related model field

- Send filters when fetching initial data

* Fix migrations

* Fix some existing typos

* Add build info when receiving line items

* Logic fix

* Bump API version

* Updated relationship

* Add external orders tab for order

* Display table of external purchase orders against a build order

* Fix permissions

* Tweak field definition

* Add unit tests

* Tweak api_version.py

* Playwright testing

* Fix discrepancy in 'building' filter

* Add basic documentation

( more work required )

* Tweak docs macros

* Migration fix

* Adjust build page tabs

* Fix imports

* Fix broken import

* Update playywright tests

* Bump API version

* Handle DB issues

* Improve filter

* Cleaner code

* Fix column ordering bug

* Add filters to build output table

* Documentation

* Tweak unit test

* Add "scheduled_for_production" field

* Add helper function to part model

* Cleanup
This commit is contained in:
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

View File

@ -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

View File

@ -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 = {

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)
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'),

View File

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

View File

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

View File

@ -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': _(

View File

@ -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."""

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 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
)

View File

@ -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=_(

View File

@ -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.

View File

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

View File

@ -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')
)

View File

@ -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

View File

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

View File

@ -1,7 +1,8 @@
import { Trans } from '@lingui/react/macro';
import { forwardRef, useImperativeHandle, useState } from 'react';
import { ApiEndpoints, apiUrl } from '@lib/index';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
import { api } from '../../../../App';
import type { PreviewAreaComponent } from '../TemplateEditor';

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

View File

@ -71,24 +71,30 @@ export function RelatedModelField({
return;
}
api.get(url).then((response) => {
const pk_field = definition.pk_field ?? 'pk';
if (response.data?.[pk_field]) {
const value = {
value: response.data[pk_field],
data: response.data
};
const params = definition?.filters ?? {};
// Run custom callback for this field (if provided)
if (definition.onValueChange) {
definition.onValueChange(response.data[pk_field], response.data);
api
.get(url, {
params: params
})
.then((response) => {
const pk_field = definition.pk_field ?? 'pk';
if (response.data?.[pk_field]) {
const value = {
value: response.data[pk_field],
data: response.data
};
// Run custom callback for this field (if provided)
if (definition.onValueChange) {
definition.onValueChange(response.data[pk_field], response.data);
}
setInitialData(value);
dataRef.current = [value];
setPk(response.data[pk_field]);
}
setInitialData(value);
dataRef.current = [value];
setPk(response.data[pk_field]);
}
});
});
} else {
setPk(null);
}

View File

@ -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';

View File

@ -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]);
}

View File

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

View File

@ -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,

View File

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

View File

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

View File

@ -13,14 +13,25 @@ import PermissionDenied from '../../components/errors/PermissionDenied';
import { PageDetail } from '../../components/nav/PageDetail';
import type { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import { useGlobalSettingsState } from '../../states/SettingsState';
import { useUserState } from '../../states/UserState';
import { PartCategoryFilter } from '../../tables/Filter';
import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
function BuildOrderCalendar() {
const globalSettings = useGlobalSettingsState();
const calendarFilters: TableFilter[] = useMemo(() => {
return [PartCategoryFilter()];
}, []);
return [
{
name: 'external',
label: t`External`,
description: t`Show external build orders`,
active: globalSettings.isSet('BUILDORDER_EXTERNAL_BUILDS')
},
PartCategoryFilter()
];
}, [globalSettings]);
return (
<OrderCalendar

View File

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

View File

@ -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)
}
];

View File

@ -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';

View File

@ -34,6 +34,7 @@ import type { TableColumn } from './Column';
import InvenTreeTableHeader from './InvenTreeTableHeader';
import { type RowAction, RowActions } from './RowActions';
const ACTIONS_COLUMN_ACCESSOR: string = '--actions--';
const defaultPageSize: number = 25;
const PAGE_SIZES = [10, 15, 20, 25, 50, 100, 500];
@ -313,7 +314,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
// If row actions are available, add a column for them
if (tableProps.rowActions) {
cols.push({
accessor: '--actions--',
accessor: ACTIONS_COLUMN_ACCESSOR,
title: ' ',
hidden: false,
resizable: false,
@ -359,6 +360,23 @@ export function InvenTreeTable<T extends Record<string, any>>({
columns: dataColumns
});
// Ensure that the "actions" column is always at the end of the list
// This effect is necessary as sometimes the underlying mantine-datatable columns change
useEffect(() => {
const idx: number = tableColumns.columnsOrder.indexOf(
ACTIONS_COLUMN_ACCESSOR
);
if (idx >= 0 && idx < tableColumns.columnsOrder.length - 1) {
// Actions column is not at the end of the list - move it there
const newOrder = tableColumns.columnsOrder.filter(
(col) => col != ACTIONS_COLUMN_ACCESSOR
);
newOrder.push(ACTIONS_COLUMN_ACCESSOR);
tableColumns.setColumnsOrder(newOrder);
}
}, [tableColumns.columnsOrder]);
// Reset the pagination state when the search term changes
useEffect(() => {
tableState.setPage(1);

View File

@ -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();

View File

@ -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

View File

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

View File

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

View File

@ -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,

View File

@ -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';

View File

@ -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();
});