mirror of
https://github.com/inventree/InvenTree.git
synced 2025-08-19 01:45:55 +00:00
[Feature] External build order (#9312)
* Add BuildOrder reference to PurchaseOrderLineItem * Add setting to enable / disable external build orders * Fix for supplier part detail * Update forms * Filter build list by "external" status * Add "external" attribute to BuildOrder * Filter by external build when selecting against purchase order line item * Add frontend elements * Prevent creation of build outputs * Tweak related model field - Send filters when fetching initial data * Fix migrations * Fix some existing typos * Add build info when receiving line items * Logic fix * Bump API version * Updated relationship * Add external orders tab for order * Display table of external purchase orders against a build order * Fix permissions * Tweak field definition * Add unit tests * Tweak api_version.py * Playwright testing * Fix discrepancy in 'building' filter * Add basic documentation ( more work required ) * Tweak docs macros * Migration fix * Adjust build page tabs * Fix imports * Fix broken import * Update playywright tests * Bump API version * Handle DB issues * Improve filter * Cleaner code * Fix column ordering bug * Add filters to build output table * Documentation * Tweak unit test * Add "scheduled_for_production" field * Add helper function to part model * Cleanup
This commit is contained in:
@@ -1,12 +1,15 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 347
|
||||
INVENTREE_API_VERSION = 348
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
v348 -> 2025-04-22 : https://github.com/inventree/InvenTree/pull/9312
|
||||
- Adds "external" flag for BuildOrder
|
||||
- Adds link between PurchaseOrderLineItem and BuildOrder
|
||||
|
||||
v347 -> 2025-06-12 : https://github.com/inventree/InvenTree/pull/9764
|
||||
- Adds "copy_tests" field to the DuplicatePart API endpoint
|
||||
|
@@ -33,7 +33,7 @@ class BuildFilter(rest_filters.FilterSet):
|
||||
"""Metaclass options."""
|
||||
|
||||
model = Build
|
||||
fields = ['sales_order']
|
||||
fields = ['sales_order', 'external']
|
||||
|
||||
status = rest_filters.NumberFilter(label=_('Order Status'), method='filter_status')
|
||||
|
||||
@@ -355,6 +355,7 @@ class BuildList(DataExportViewMixin, BuildMixin, ListCreateAPI):
|
||||
'project_code',
|
||||
'priority',
|
||||
'level',
|
||||
'external',
|
||||
]
|
||||
|
||||
ordering_field_aliases = {
|
||||
|
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 4.2.20 on 2025-03-13 22:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("build", "0056_alter_build_link"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="build",
|
||||
name="external",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="This build order is fulfilled externally",
|
||||
verbose_name="External Build",
|
||||
),
|
||||
),
|
||||
]
|
@@ -96,6 +96,7 @@ class Build(
|
||||
sales_order: References to a SalesOrder object for which this Build is required (e.g. the output of this build will be used to fulfil a sales order)
|
||||
take_from: Location to take stock from to make this build (if blank, can take from anywhere)
|
||||
status: Build status code
|
||||
external: Set to indicate that this build order is fulfilled externally
|
||||
batch: Batch code transferred to build parts (optional)
|
||||
creation_date: Date the build was created (auto)
|
||||
target_date: Date the build will be overdue
|
||||
@@ -191,6 +192,13 @@ class Build(
|
||||
"""Validate the BuildOrder model."""
|
||||
super().clean()
|
||||
|
||||
if self.external and not self.part.purchaseable:
|
||||
raise ValidationError({
|
||||
'external': _(
|
||||
'Build orders can only be externally fulfilled for purchaseable parts'
|
||||
)
|
||||
})
|
||||
|
||||
if get_global_setting('BUILDORDER_REQUIRE_RESPONSIBLE'):
|
||||
if not self.responsible:
|
||||
raise ValidationError({
|
||||
@@ -286,6 +294,12 @@ class Build(
|
||||
),
|
||||
)
|
||||
|
||||
external = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('External Build'),
|
||||
help_text=_('This build order is fulfilled externally'),
|
||||
)
|
||||
|
||||
destination = models.ForeignKey(
|
||||
'stock.StockLocation',
|
||||
verbose_name=_('Destination Location'),
|
||||
|
@@ -73,6 +73,7 @@ class BuildSerializer(
|
||||
'completed',
|
||||
'completion_date',
|
||||
'destination',
|
||||
'external',
|
||||
'parent',
|
||||
'part',
|
||||
'part_name',
|
||||
|
@@ -9,18 +9,21 @@ from django.core.exceptions import ValidationError
|
||||
from django.db.models import Sum
|
||||
from django.test import TestCase
|
||||
from django.test.utils import override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
import structlog
|
||||
|
||||
import build.tasks
|
||||
import common.models
|
||||
import company.models
|
||||
from build.models import Build, BuildItem, BuildLine, generate_next_build_reference
|
||||
from build.status_codes import BuildStatus
|
||||
from common.settings import set_global_setting
|
||||
from InvenTree import status_codes as status
|
||||
from InvenTree.unit_test import findOffloadedEvent
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase, findOffloadedEvent
|
||||
from order.models import PurchaseOrder, PurchaseOrderLineItem
|
||||
from part.models import BomItem, BomItemSubstitute, Part, PartTestTemplate
|
||||
from stock.models import StockItem, StockItemTestResult
|
||||
from stock.models import StockItem, StockItemTestResult, StockLocation
|
||||
from users.models import Owner
|
||||
|
||||
logger = structlog.get_logger('inventree')
|
||||
@@ -809,3 +812,142 @@ class AutoAllocationTests(BuildTestBase):
|
||||
|
||||
self.assertEqual(self.line_1.unallocated_quantity(), 0)
|
||||
self.assertEqual(self.line_2.unallocated_quantity(), 0)
|
||||
|
||||
|
||||
class ExternalBuildTest(InvenTreeAPITestCase):
|
||||
"""Unit tests for external build order functionality."""
|
||||
|
||||
def test_validation(self):
|
||||
"""Test validation of external build logic."""
|
||||
part = Part.objects.create(
|
||||
name='Test part', description='A test part', purchaseable=False
|
||||
)
|
||||
|
||||
# Create a build order
|
||||
# Cannot create an external build for a non-purchaseable part
|
||||
with self.assertRaises(ValidationError) as err:
|
||||
build = Build.objects.create(
|
||||
part=part, title='Test build order', quantity=10, external=True
|
||||
)
|
||||
|
||||
build.clean()
|
||||
|
||||
self.assertIn(
|
||||
'Build orders can only be externally fulfilled for purchaseable parts',
|
||||
str(err.exception.messages),
|
||||
)
|
||||
|
||||
def test_logic(self):
|
||||
"""Test external build logic."""
|
||||
# Create a purchaseable assembly part
|
||||
assembly = Part.objects.create(
|
||||
name='Test assembly',
|
||||
description='A test assembly',
|
||||
purchaseable=True,
|
||||
assembly=True,
|
||||
active=True,
|
||||
)
|
||||
|
||||
# Create a supplier part
|
||||
supplier = company.models.Company.objects.create(
|
||||
name='Test supplier', active=True, is_supplier=True
|
||||
)
|
||||
|
||||
supplier_part = company.models.SupplierPart.objects.create(
|
||||
part=assembly, supplier=supplier, SKU='TEST-123'
|
||||
)
|
||||
|
||||
# Create a build order against the assembly
|
||||
build = Build.objects.create(
|
||||
part=assembly, title='Test build order', quantity=10, external=True
|
||||
)
|
||||
|
||||
# Order some parts
|
||||
po = PurchaseOrder.objects.create(supplier=supplier, reference='PO-9999')
|
||||
|
||||
# Create a line item to fulfil the build order
|
||||
po_line = PurchaseOrderLineItem.objects.create(
|
||||
order=po, part=supplier_part, quantity=10, build_order=build
|
||||
)
|
||||
|
||||
# Validate starting conditions
|
||||
self.assertEqual(build.quantity, 10)
|
||||
self.assertEqual(build.completed, 0)
|
||||
self.assertEqual(build.build_outputs.count(), 0)
|
||||
self.assertEqual(build.consumed_stock.count(), 0)
|
||||
|
||||
# PLACE the order
|
||||
po.place_order()
|
||||
|
||||
location = StockLocation.objects.first()
|
||||
|
||||
# Receive half the items against the purchase order
|
||||
po.receive_line_item(po_line, location, 5, self.user)
|
||||
|
||||
# As the order was incomplete, the build output has been marked as "building"
|
||||
self.assertEqual(build.quantity, 10)
|
||||
self.assertEqual(build.completed, 0)
|
||||
self.assertEqual(build.build_outputs.count(), 1)
|
||||
|
||||
output = build.build_outputs.first()
|
||||
self.assertTrue(output.is_building)
|
||||
|
||||
build.complete_build_output(output, self.user)
|
||||
build.refresh_from_db()
|
||||
self.assertEqual(build.completed, 5)
|
||||
|
||||
output.refresh_from_db()
|
||||
self.assertFalse(output.is_building)
|
||||
|
||||
# Mark the build order as completed
|
||||
build.complete_build(self.user)
|
||||
self.assertEqual(build.status, BuildStatus.COMPLETE)
|
||||
|
||||
# Receive the rest of the line item
|
||||
po.receive_line_item(po_line, location, 5, self.user)
|
||||
po_line.refresh_from_db()
|
||||
self.assertEqual(po_line.received, 10)
|
||||
|
||||
build.refresh_from_db()
|
||||
self.assertEqual(build.completed, 10)
|
||||
self.assertEqual(build.build_outputs.count(), 2)
|
||||
|
||||
# As the build was already completed, output has been marked as "complete" too
|
||||
output = build.build_outputs.order_by('-pk').first()
|
||||
self.assertFalse(output.is_building)
|
||||
|
||||
def test_api_filter(self):
|
||||
"""Test that the 'external' API filter works as expected."""
|
||||
self.assignRole('build.view')
|
||||
|
||||
# Create a purchaseable assembly part
|
||||
assembly = Part.objects.create(
|
||||
name='Test assembly',
|
||||
description='A test assembly',
|
||||
purchaseable=True,
|
||||
assembly=True,
|
||||
active=True,
|
||||
)
|
||||
|
||||
# Create some build orders
|
||||
for i in range(5):
|
||||
Build.objects.create(
|
||||
part=assembly,
|
||||
title=f'Test build order {i}',
|
||||
quantity=10,
|
||||
external=i % 2 == 0,
|
||||
)
|
||||
|
||||
url = reverse('api-build-list')
|
||||
|
||||
response = self.get(url)
|
||||
|
||||
self.assertEqual(len(response.data), 5)
|
||||
|
||||
# Filter by 'external'
|
||||
response = self.get(url, {'external': 'true'})
|
||||
self.assertEqual(len(response.data), 3)
|
||||
|
||||
# Filter by 'not external'
|
||||
response = self.get(url, {'external': 'false'})
|
||||
self.assertEqual(len(response.data), 2)
|
||||
|
@@ -756,6 +756,12 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'BUILDORDER_EXTERNAL_BUILDS': {
|
||||
'name': _('External Build Orders'),
|
||||
'description': _('Enable external build order functionality'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS': {
|
||||
'name': _('Block Until Tests Pass'),
|
||||
'description': _(
|
||||
|
@@ -19,6 +19,7 @@ from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
import build.models
|
||||
import common.models
|
||||
import common.settings
|
||||
import company.models
|
||||
@@ -324,6 +325,22 @@ class PurchaseOrderFilter(OrderFilter):
|
||||
label=_('Completed After'), field_name='complete_date', lookup_expr='gt'
|
||||
)
|
||||
|
||||
external_build = rest_filters.ModelChoiceFilter(
|
||||
queryset=build.models.Build.objects.filter(external=True),
|
||||
method='filter_external_build',
|
||||
label=_('External Build Order'),
|
||||
)
|
||||
|
||||
@extend_schema_field(
|
||||
rest_framework.serializers.IntegerField(help_text=_('External Build Order'))
|
||||
)
|
||||
def filter_external_build(self, queryset, name, build):
|
||||
"""Filter to only include orders which fill fulfil the provided Build Order.
|
||||
|
||||
To achieve this, we return any order which has a line item which is allocated to the build order.
|
||||
"""
|
||||
return queryset.filter(lines__build_order=build).distinct()
|
||||
|
||||
|
||||
class PurchaseOrderMixin:
|
||||
"""Mixin class for PurchaseOrder endpoints."""
|
||||
|
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 4.2.20 on 2025-04-11 04:59
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("build", "0057_build_external"),
|
||||
("order", "0110_salesordershipment_barcode_data_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="purchaseorderlineitem",
|
||||
name="build_order",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="External Build Order to be fulfilled by this line item",
|
||||
limit_choices_to={"external": True},
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="external_line_items",
|
||||
to="build.build",
|
||||
verbose_name="Build Order",
|
||||
),
|
||||
),
|
||||
]
|
@@ -30,6 +30,7 @@ import order.validators
|
||||
import report.mixins
|
||||
import stock.models
|
||||
import users.models as UserModels
|
||||
from build.status_codes import BuildStatus
|
||||
from common.currency import currency_code_default
|
||||
from common.notifications import InvenTreeNotificationBodies
|
||||
from common.settings import get_global_setting
|
||||
@@ -279,7 +280,7 @@ class Order(
|
||||
|
||||
Instances of this class:
|
||||
|
||||
- PuchaseOrder
|
||||
- PurchaseOrder
|
||||
- SalesOrder
|
||||
|
||||
Attributes:
|
||||
@@ -558,7 +559,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
||||
|
||||
@classmethod
|
||||
def get_status_class(cls):
|
||||
"""Return the PurchasOrderStatus class."""
|
||||
"""Return the PurchaseOrderStatus class."""
|
||||
return PurchaseOrderStatusGroups
|
||||
|
||||
@classmethod
|
||||
@@ -923,7 +924,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
||||
status: The StockStatus to assign to the item (default: StockStatus.OK)
|
||||
|
||||
Keyword Arguments:
|
||||
barch_code: Optional batch code for the new StockItem
|
||||
batch_code: Optional batch code for the new StockItem
|
||||
serials: Optional list of serial numbers to assign to the new StockItem(s)
|
||||
notes: Optional notes field for the StockItem
|
||||
packaging: Optional packaging field for the StockItem
|
||||
@@ -1005,20 +1006,61 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
||||
serialize = False
|
||||
serials = [None]
|
||||
|
||||
# Construct dataset for receiving items
|
||||
data = {
|
||||
'part': line.part.part,
|
||||
'supplier_part': line.part,
|
||||
'location': location,
|
||||
'quantity': 1 if serialize else stock_quantity,
|
||||
'purchase_order': self,
|
||||
'status': status,
|
||||
'batch': batch_code,
|
||||
'expiry_date': expiry_date,
|
||||
'packaging': packaging,
|
||||
'purchase_price': unit_purchase_price,
|
||||
}
|
||||
|
||||
if build_order := line.build_order:
|
||||
# Receiving items against an "external" build order
|
||||
|
||||
if not build_order.external:
|
||||
raise ValidationError(
|
||||
'Cannot receive items against an internal build order'
|
||||
)
|
||||
|
||||
if build_order.part != data['part']:
|
||||
raise ValidationError(
|
||||
'Cannot receive items against a build order for a different part'
|
||||
)
|
||||
|
||||
if not location and build_order.destination:
|
||||
# Override with the build order destination (if not specified)
|
||||
data['location'] = location = build_order.destination
|
||||
|
||||
if build_order.active:
|
||||
# An 'active' build order marks the items as "in production"
|
||||
data['build'] = build_order
|
||||
data['is_building'] = True
|
||||
elif build_order.status == BuildStatus.COMPLETE:
|
||||
# A 'completed' build order marks the items as "completed"
|
||||
data['build'] = build_order
|
||||
data['is_building'] = False
|
||||
|
||||
# Increase the 'completed' quantity for the build order
|
||||
build_order.completed += stock_quantity
|
||||
build_order.save()
|
||||
|
||||
elif build_order.status == BuildStatus.CANCELLED:
|
||||
# A 'cancelled' build order is ignored
|
||||
pass
|
||||
else:
|
||||
# Un-handled state - raise an error
|
||||
raise ValidationError(
|
||||
"Cannot receive items against a build order in state '{build_order.status}'"
|
||||
)
|
||||
|
||||
for sn in serials:
|
||||
item = stock.models.StockItem(
|
||||
part=line.part.part,
|
||||
supplier_part=line.part,
|
||||
location=location,
|
||||
quantity=1 if serialize else stock_quantity,
|
||||
purchase_order=self,
|
||||
status=status,
|
||||
batch=batch_code,
|
||||
expiry_date=expiry_date,
|
||||
packaging=packaging,
|
||||
serial=sn,
|
||||
purchase_price=unit_purchase_price,
|
||||
)
|
||||
item = stock.models.StockItem(serial=sn, **data)
|
||||
|
||||
# Assign the provided barcode
|
||||
if barcode:
|
||||
@@ -1639,6 +1681,11 @@ class PurchaseOrderLineItem(OrderLineItem):
|
||||
|
||||
Attributes:
|
||||
order: Reference to a PurchaseOrder object
|
||||
part: Reference to a SupplierPart object
|
||||
received: Number of items received
|
||||
purchase_price: Unit purchase price for this line item
|
||||
build_order: Link to an external BuildOrder to be fulfilled by this line item
|
||||
destination: Destination for received items
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
@@ -1670,6 +1717,25 @@ class PurchaseOrderLineItem(OrderLineItem):
|
||||
if self.part.supplier != self.order.supplier:
|
||||
raise ValidationError({'part': _('Supplier part must match supplier')})
|
||||
|
||||
if self.build_order:
|
||||
if not self.build_order.external:
|
||||
raise ValidationError({
|
||||
'build_order': _('Build order must be marked as external')
|
||||
})
|
||||
|
||||
if part := self.part.part:
|
||||
if not part.assembly:
|
||||
raise ValidationError({
|
||||
'build_order': _(
|
||||
'Build orders can only be linked to assembly parts'
|
||||
)
|
||||
})
|
||||
|
||||
if self.build_order.part != self.part.part:
|
||||
raise ValidationError({
|
||||
'build_order': _('Build order part must match line item part')
|
||||
})
|
||||
|
||||
def __str__(self):
|
||||
"""Render a string representation of a PurchaseOrderLineItem instance."""
|
||||
return '{n} x {part} - {po}'.format(
|
||||
@@ -1727,6 +1793,17 @@ class PurchaseOrderLineItem(OrderLineItem):
|
||||
"""Return the 'purchase_price' field as 'price'."""
|
||||
return self.purchase_price
|
||||
|
||||
build_order = models.ForeignKey(
|
||||
'build.Build',
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True,
|
||||
related_name='external_line_items',
|
||||
limit_choices_to={'external': True},
|
||||
null=True,
|
||||
verbose_name=_('Build Order'),
|
||||
help_text=_('External Build Order to be fulfilled by this line item'),
|
||||
)
|
||||
|
||||
destination = TreeForeignKey(
|
||||
'stock.StockLocation',
|
||||
on_delete=models.SET_NULL,
|
||||
@@ -2544,7 +2621,7 @@ class ReturnOrder(TotalPriceMixin, Order):
|
||||
|
||||
@transaction.atomic
|
||||
def hold_order(self):
|
||||
"""Attempt to tranasition to ON_HOLD status."""
|
||||
"""Attempt to transition to ON_HOLD status."""
|
||||
return self.handle_transition(
|
||||
self.status, ReturnOrderStatus.ON_HOLD.value, self, self._action_hold
|
||||
)
|
||||
|
@@ -21,6 +21,7 @@ from rest_framework import serializers
|
||||
from rest_framework.serializers import ValidationError
|
||||
from sql_util.utils import SubqueryCount, SubquerySum
|
||||
|
||||
import build.serializers
|
||||
import order.models
|
||||
import part.filters as part_filters
|
||||
import part.models as part_models
|
||||
@@ -496,6 +497,8 @@ class PurchaseOrderLineItemSerializer(
|
||||
'notes',
|
||||
'order',
|
||||
'order_detail',
|
||||
'build_order',
|
||||
'build_order_detail',
|
||||
'overdue',
|
||||
'part_detail',
|
||||
'supplier_part_detail',
|
||||
@@ -645,6 +648,10 @@ class PurchaseOrderLineItemSerializer(
|
||||
source='order', read_only=True, allow_null=True, many=False
|
||||
)
|
||||
|
||||
build_order_detail = build.serializers.BuildSerializer(
|
||||
source='build_order', read_only=True, many=False
|
||||
)
|
||||
|
||||
merge_items = serializers.BooleanField(
|
||||
label=_('Merge Items'),
|
||||
help_text=_(
|
||||
|
@@ -44,7 +44,9 @@ from order.status_codes import PurchaseOrderStatusGroups, SalesOrderStatusGroups
|
||||
def annotate_in_production_quantity(reference=''):
|
||||
"""Annotate the 'in production' quantity for each part in a queryset.
|
||||
|
||||
Sum the 'quantity' field for all stock items which are 'in production' for each part.
|
||||
- Sum the 'quantity' field for all stock items which are 'in production' for each part.
|
||||
- This is the total quantity of "incomplete build outputs" for all active builds
|
||||
- This will return the same quantity as the 'quantity_in_production' method on the Part model
|
||||
|
||||
Arguments:
|
||||
reference: Reference to the part from the current queryset (default = '')
|
||||
@@ -60,6 +62,28 @@ def annotate_in_production_quantity(reference=''):
|
||||
)
|
||||
|
||||
|
||||
def annotate_scheduled_to_build_quantity(reference: str = ''):
|
||||
"""Annotate the 'scheduled to build' quantity for each part in a queryset.
|
||||
|
||||
- This is total scheduled quantity for all build orders which are 'active'
|
||||
- This may be different to the "in production" quantity
|
||||
- This will return the same quantity as the 'quantity_being_built' method no the Part model
|
||||
"""
|
||||
building_filter = Q(status__in=BuildStatusGroups.ACTIVE_CODES)
|
||||
|
||||
return Coalesce(
|
||||
SubquerySum(
|
||||
ExpressionWrapper(
|
||||
F(f'{reference}builds__quantity') - F(f'{reference}builds__completed'),
|
||||
output_field=DecimalField(),
|
||||
),
|
||||
filter=building_filter,
|
||||
),
|
||||
Decimal(0),
|
||||
output_field=DecimalField(),
|
||||
)
|
||||
|
||||
|
||||
def annotate_on_order_quantity(reference: str = ''):
|
||||
"""Annotate the 'on order' quantity for each part in a queryset.
|
||||
|
||||
|
@@ -1627,6 +1627,24 @@ class Part(
|
||||
|
||||
return quantity
|
||||
|
||||
@property
|
||||
def quantity_in_production(self):
|
||||
"""Quantity of this part currently actively in production.
|
||||
|
||||
Note: This may return a different value to `quantity_being_built`
|
||||
"""
|
||||
quantity = 0
|
||||
|
||||
items = self.stock_items.filter(
|
||||
is_building=True, build__status__in=BuildStatusGroups.ACTIVE_CODES
|
||||
)
|
||||
|
||||
for item in items:
|
||||
# The remaining items in the build
|
||||
quantity += item.quantity
|
||||
|
||||
return quantity
|
||||
|
||||
def build_order_allocations(self, **kwargs):
|
||||
"""Return all 'BuildItem' objects which allocate this part to Build objects."""
|
||||
include_variants = kwargs.get('include_variants', True)
|
||||
|
@@ -18,7 +18,7 @@ from djmoney.contrib.exchange.exceptions import MissingRate
|
||||
from djmoney.contrib.exchange.models import convert_money
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
from sql_util.utils import SubqueryCount, SubquerySum
|
||||
from sql_util.utils import SubqueryCount
|
||||
from taggit.serializers import TagListSerializerField
|
||||
|
||||
import common.currency
|
||||
@@ -33,7 +33,6 @@ import part.stocktake
|
||||
import part.tasks
|
||||
import stock.models
|
||||
import users.models
|
||||
from build.status_codes import BuildStatusGroups
|
||||
from importer.registry import register_importer
|
||||
from InvenTree.mixins import DataImportExportSerializerMixin
|
||||
from InvenTree.ready import isGeneratingSchema
|
||||
@@ -724,6 +723,7 @@ class PartSerializer(
|
||||
'allocated_to_build_orders',
|
||||
'allocated_to_sales_orders',
|
||||
'building',
|
||||
'scheduled_to_build',
|
||||
'category_default_location',
|
||||
'in_stock',
|
||||
'ordering',
|
||||
@@ -831,16 +831,13 @@ class PartSerializer(
|
||||
)
|
||||
)
|
||||
|
||||
# Filter to limit builds to "active"
|
||||
build_filter = Q(status__in=BuildStatusGroups.ACTIVE_CODES)
|
||||
|
||||
# Annotate with the total 'building' quantity
|
||||
queryset = queryset.annotate(
|
||||
building=Coalesce(
|
||||
SubquerySum('builds__quantity', filter=build_filter),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
building=part_filters.annotate_in_production_quantity()
|
||||
)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
scheduled_to_build=part_filters.annotate_scheduled_to_build_quantity()
|
||||
)
|
||||
|
||||
# Annotate with the number of 'suppliers'
|
||||
@@ -947,42 +944,65 @@ class PartSerializer(
|
||||
# Annotated fields
|
||||
allocated_to_build_orders = serializers.FloatField(read_only=True, allow_null=True)
|
||||
allocated_to_sales_orders = serializers.FloatField(read_only=True, allow_null=True)
|
||||
|
||||
building = serializers.FloatField(
|
||||
read_only=True, allow_null=True, label=_('Building')
|
||||
read_only=True,
|
||||
allow_null=True,
|
||||
label=_('Building'),
|
||||
help_text=_('Quantity of this part currently being in production'),
|
||||
)
|
||||
|
||||
scheduled_to_build = serializers.FloatField(
|
||||
read_only=True,
|
||||
allow_null=True,
|
||||
label=_('Scheduled to Build'),
|
||||
help_text=_('Outstanding quantity of this part scheduled to be built'),
|
||||
)
|
||||
|
||||
in_stock = serializers.FloatField(
|
||||
read_only=True, allow_null=True, label=_('In Stock')
|
||||
)
|
||||
|
||||
ordering = serializers.FloatField(
|
||||
read_only=True, allow_null=True, label=_('On Order')
|
||||
)
|
||||
|
||||
required_for_build_orders = serializers.IntegerField(
|
||||
read_only=True, allow_null=True
|
||||
)
|
||||
|
||||
required_for_sales_orders = serializers.IntegerField(
|
||||
read_only=True, allow_null=True
|
||||
)
|
||||
|
||||
stock_item_count = serializers.IntegerField(
|
||||
read_only=True, allow_null=True, label=_('Stock Items')
|
||||
)
|
||||
|
||||
revision_count = serializers.IntegerField(
|
||||
read_only=True, allow_null=True, label=_('Revisions')
|
||||
)
|
||||
|
||||
suppliers = serializers.IntegerField(
|
||||
read_only=True, allow_null=True, label=_('Suppliers')
|
||||
)
|
||||
|
||||
total_in_stock = serializers.FloatField(
|
||||
read_only=True, allow_null=True, label=_('Total Stock')
|
||||
)
|
||||
|
||||
external_stock = serializers.FloatField(
|
||||
read_only=True, allow_null=True, label=_('External Stock')
|
||||
)
|
||||
|
||||
unallocated_stock = serializers.FloatField(
|
||||
read_only=True, allow_null=True, label=_('Unallocated Stock')
|
||||
)
|
||||
|
||||
category_default_location = serializers.IntegerField(
|
||||
read_only=True, allow_null=True
|
||||
)
|
||||
|
||||
variant_stock = serializers.FloatField(
|
||||
read_only=True, allow_null=True, label=_('Variant Stock')
|
||||
)
|
||||
|
@@ -181,6 +181,7 @@ class ScheduleMixin:
|
||||
obj['args'] = f"'{slug}', '{func_name}'"
|
||||
|
||||
tasks = Schedule.objects.filter(name=task_name)
|
||||
|
||||
if len(tasks) > 1:
|
||||
logger.info(
|
||||
"Found multiple tasks; Adding a new scheduled task '%s'",
|
||||
@@ -191,10 +192,11 @@ class ScheduleMixin:
|
||||
elif len(tasks) == 1:
|
||||
# Scheduled task already exists - update it!
|
||||
logger.info("Updating scheduled task '%s'", task_name)
|
||||
instance = Schedule.objects.get(name=task_name)
|
||||
for item in obj:
|
||||
setattr(instance, item, obj[item])
|
||||
instance.save()
|
||||
|
||||
if instance := tasks.first():
|
||||
for item in obj:
|
||||
setattr(instance, item, obj[item])
|
||||
instance.save()
|
||||
else:
|
||||
logger.info("Adding scheduled task '%s'", task_name)
|
||||
# Create a new scheduled task
|
||||
|
Reference in New Issue
Block a user