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:
BIN
docs/docs/assets/images/build/external_build_detail.png
Normal file
BIN
docs/docs/assets/images/build/external_build_detail.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 135 KiB |
BIN
docs/docs/assets/images/build/external_build_fulfilment.png
Normal file
BIN
docs/docs/assets/images/build/external_build_fulfilment.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 51 KiB |
BIN
docs/docs/assets/images/build/external_build_incomplete.png
Normal file
BIN
docs/docs/assets/images/build/external_build_incomplete.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 54 KiB |
BIN
docs/docs/assets/images/build/external_build_receive_items.png
Normal file
BIN
docs/docs/assets/images/build/external_build_receive_items.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 40 KiB |
BIN
docs/docs/assets/images/build/external_build_select_build.png
Normal file
BIN
docs/docs/assets/images/build/external_build_select_build.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 45 KiB |
@ -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") }}
|
||||||
|
72
docs/docs/manufacturing/external.md
Normal file
72
docs/docs/manufacturing/external.md
Normal 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") }}
|
35
docs/main.py
35
docs/main.py
@ -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():
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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 = {
|
||||||
|
@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 4.2.20 on 2025-03-13 22:34
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("build", "0056_alter_build_link"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="build",
|
||||||
|
name="external",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="This build order is fulfilled externally",
|
||||||
|
verbose_name="External Build",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -96,6 +96,7 @@ class Build(
|
|||||||
sales_order: References to a SalesOrder object for which this Build is required (e.g. the output of this build will be used to fulfil a sales order)
|
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'),
|
||||||
|
@ -73,6 +73,7 @@ class BuildSerializer(
|
|||||||
'completed',
|
'completed',
|
||||||
'completion_date',
|
'completion_date',
|
||||||
'destination',
|
'destination',
|
||||||
|
'external',
|
||||||
'parent',
|
'parent',
|
||||||
'part',
|
'part',
|
||||||
'part_name',
|
'part_name',
|
||||||
|
@ -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)
|
||||||
|
@ -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': _(
|
||||||
|
@ -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."""
|
||||||
|
@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 4.2.20 on 2025-04-11 04:59
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("build", "0057_build_external"),
|
||||||
|
("order", "0110_salesordershipment_barcode_data_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="purchaseorderlineitem",
|
||||||
|
name="build_order",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
help_text="External Build Order to be fulfilled by this line item",
|
||||||
|
limit_choices_to={"external": True},
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="external_line_items",
|
||||||
|
to="build.build",
|
||||||
|
verbose_name="Build Order",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -30,6 +30,7 @@ import order.validators
|
|||||||
import report.mixins
|
import 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
|
||||||
)
|
)
|
||||||
|
@ -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=_(
|
||||||
|
@ -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.
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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')
|
||||||
)
|
)
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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';
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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';
|
||||||
|
@ -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]);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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',
|
||||||
|
@ -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]);
|
||||||
|
@ -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
|
||||||
|
@ -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 />
|
||||||
)
|
)
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -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';
|
||||||
|
@ -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);
|
||||||
|
@ -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();
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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: () => {
|
||||||
|
@ -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,
|
||||||
|
@ -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';
|
||||||
|
@ -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();
|
||||||
|
});
|
||||||
|
Reference in New Issue
Block a user