diff --git a/CHANGELOG.md b/CHANGELOG.md index c372c0f4bc..3da80f8efb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Allow stock adjustments for "in production" items in [#10600](https://github.com/inventree/InvenTree/pull/10600) - Adds optional shipping address against individual sales order shipments in [#10650](https://github.com/inventree/InvenTree/pull/10650) - Adds UI elements to "check" and "uncheck" sales order shipments in [#10654](https://github.com/inventree/InvenTree/pull/10654) +- Allow assigning project codes to order line items in [#10657](https://github.com/inventree/InvenTree/pull/10657) ### Changed diff --git a/docs/docs/concepts/project_codes.md b/docs/docs/concepts/project_codes.md index f357149ceb..2925defdb9 100644 --- a/docs/docs/concepts/project_codes.md +++ b/docs/docs/concepts/project_codes.md @@ -6,7 +6,18 @@ title: Project Codes A project code is a unique identifier assigned to a specific project, which helps in tracking and organizing project-related activities and resources. It enables easy retrieval of project-related data and facilitates project management and reporting. -Individual orders (such as [Purchase Orders](../purchasing/purchase_order.md) or [Sales Orders](../sales/sales_order.md)) can be assigned a *Project Code* to allocate the order against a particular internal project. +### Assigning to Orders + +Project codes can be assigned to various orders within the system: + +- [Build Orders](../manufacturing/build.md) +- [Purchase Orders](../purchasing/purchase_order.md) +- [Sales Orders](../sales/sales_order.md) +- [Return Orders](../sales/return_order.md) + +By assigning a project code to an order, users can easily track and manage orders associated with specific projects, enhancing project oversight and resource allocation. + +For orders with external companies, which support individual line items, project codes can be assigned at the line item level, allowing for granular tracking of project-related activities. In such cases, the project code assigned to the order itself serves as a default for all line items, unless explicitly overridden at the line item level. ### Managing Project Codes diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 992b9d44d9..a14ba57890 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,15 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 417 +INVENTREE_API_VERSION = 418 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v418 -> 2025-10-24 : https://github.com/inventree/InvenTree/pull/10657 + - Add "project_code" field(s) to OrderLineItem API endpoint(s) + - Add "project_code" field(s) to ExtraOrderLineItem API endpoint(s) + v417 -> 2025-10-22 : https://github.com/inventree/InvenTree/pull/10654 - Adds "checked" filter to SalesOrderShipment API endpoint - Adds "order_status" filter to SalesOrdereShipment API endpoint diff --git a/src/backend/InvenTree/order/migrations/0114_purchaseorderextraline_project_code_and_more.py b/src/backend/InvenTree/order/migrations/0114_purchaseorderextraline_project_code_and_more.py new file mode 100644 index 0000000000..37447c62c8 --- /dev/null +++ b/src/backend/InvenTree/order/migrations/0114_purchaseorderextraline_project_code_and_more.py @@ -0,0 +1,87 @@ +# Generated by Django 4.2.25 on 2025-10-24 01:15 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("common", "0039_emailthread_emailmessage"), + ("order", "0113_salesordershipment_shipment_address"), + ] + + operations = [ + migrations.AddField( + model_name="purchaseorderextraline", + name="project_code", + field=models.ForeignKey( + blank=True, + help_text="Select project code for this order", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="common.projectcode", + verbose_name="Project Code", + ), + ), + migrations.AddField( + model_name="purchaseorderlineitem", + name="project_code", + field=models.ForeignKey( + blank=True, + help_text="Select project code for this order", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="common.projectcode", + verbose_name="Project Code", + ), + ), + migrations.AddField( + model_name="returnorderextraline", + name="project_code", + field=models.ForeignKey( + blank=True, + help_text="Select project code for this order", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="common.projectcode", + verbose_name="Project Code", + ), + ), + migrations.AddField( + model_name="returnorderlineitem", + name="project_code", + field=models.ForeignKey( + blank=True, + help_text="Select project code for this order", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="common.projectcode", + verbose_name="Project Code", + ), + ), + migrations.AddField( + model_name="salesorderextraline", + name="project_code", + field=models.ForeignKey( + blank=True, + help_text="Select project code for this order", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="common.projectcode", + verbose_name="Project Code", + ), + ), + migrations.AddField( + model_name="salesorderlineitem", + name="project_code", + field=models.ForeignKey( + blank=True, + help_text="Select project code for this order", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="common.projectcode", + verbose_name="Project Code", + ), + ), + ] diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index 6126830b1c..208de31872 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -1685,6 +1685,7 @@ class OrderLineItem(InvenTree.models.InvenTreeMetadataModel): Attributes: quantity: Number of items reference: Reference text (e.g. customer reference) for this line item + project_code: Project code associated with this line item (optional) note: Annotation for the item target_date: An (optional) date for expected shipment of this line item. """ @@ -1768,6 +1769,15 @@ class OrderLineItem(InvenTree.models.InvenTreeMetadataModel): ), ) + project_code = models.ForeignKey( + common_models.ProjectCode, + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_('Project Code'), + help_text=_('Select project code for this order'), + ) + class OrderExtraLine(OrderLineItem): """Abstract Model for a single ExtraLine in a Order. @@ -1992,8 +2002,6 @@ class PurchaseOrderExtraLine(OrderExtraLine): Attributes: order: Link to the PurchaseOrder that this line belongs to - title: title of line - price: The unit price for this OrderLine """ class Meta: @@ -2422,8 +2430,6 @@ class SalesOrderExtraLine(OrderExtraLine): Attributes: order: Link to the SalesOrder that this line belongs to - title: title of line - price: The unit price for this OrderLine """ class Meta: diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index 9a4666bf44..ee35d51127 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -45,6 +45,7 @@ from InvenTree.helpers import ( ) from InvenTree.mixins import DataImportExportSerializerMixin from InvenTree.serializers import ( + FilterableCharField, FilterableSerializerMixin, InvenTreeCurrencySerializer, InvenTreeDecimalField, @@ -105,7 +106,9 @@ class DuplicateOrderSerializer(serializers.Serializer): ) -class AbstractOrderSerializer(DataImportExportSerializerMixin, serializers.Serializer): +class AbstractOrderSerializer( + DataImportExportSerializerMixin, FilterableSerializerMixin, serializers.Serializer +): """Abstract serializer class which provides fields common to all order types.""" export_exclude_fields = ['notes', 'duplicate'] @@ -132,30 +135,46 @@ class AbstractOrderSerializer(DataImportExportSerializerMixin, serializers.Seria reference = serializers.CharField(required=True) # Detail for point-of-contact field - contact_detail = ContactSerializer( - source='contact', many=False, read_only=True, allow_null=True + contact_detail = enable_filter( + ContactSerializer( + source='contact', many=False, read_only=True, allow_null=True + ), + True, ) # Detail for responsible field - responsible_detail = OwnerSerializer( - source='responsible', read_only=True, allow_null=True, many=False + responsible_detail = enable_filter( + OwnerSerializer( + source='responsible', read_only=True, allow_null=True, many=False + ), + True, ) - project_code_label = serializers.CharField( - source='project_code.code', - read_only=True, - label='Project Code Label', - allow_null=True, + project_code_label = enable_filter( + FilterableCharField( + source='project_code.code', + read_only=True, + label='Project Code Label', + allow_null=True, + ), + True, + filter_name='project_code_detail', ) # Detail for project code field - project_code_detail = ProjectCodeSerializer( - source='project_code', read_only=True, many=False, allow_null=True + project_code_detail = enable_filter( + ProjectCodeSerializer( + source='project_code', read_only=True, many=False, allow_null=True + ), + True, ) # Detail for address field - address_detail = AddressBriefSerializer( - source='address', many=False, read_only=True, allow_null=True + address_detail = enable_filter( + AddressBriefSerializer( + source='address', many=False, read_only=True, allow_null=True + ), + True, ) # Boolean field indicating if this order is overdue (Note: must be annotated) @@ -204,15 +223,10 @@ class AbstractOrderSerializer(DataImportExportSerializerMixin, serializers.Seria 'completed_lines', 'link', 'project_code', - 'project_code_label', - 'project_code_detail', 'reference', 'responsible', - 'responsible_detail', 'contact', - 'contact_detail', 'address', - 'address_detail', 'status', 'status_text', 'status_custom_key', @@ -220,6 +234,12 @@ class AbstractOrderSerializer(DataImportExportSerializerMixin, serializers.Seria 'barcode_hash', 'overdue', 'duplicate', + # Extra detail fields + 'address_detail', + 'contact_detail', + 'project_code_detail', + 'project_code_label', + 'responsible_detail', *extra_fields, ] @@ -263,25 +283,103 @@ class AbstractOrderSerializer(DataImportExportSerializerMixin, serializers.Seria return instance -class AbstractLineItemSerializer: +class AbstractLineItemSerializer(FilterableSerializerMixin, serializers.Serializer): """Abstract serializer for LineItem object.""" + @staticmethod + def line_fields(extra_fields): + """Construct a set of fields for this serializer.""" + return [ + 'pk', + 'link', + 'notes', + 'order', + 'project_code', + 'quantity', + 'reference', + 'target_date', + # Filterable detail fields + 'order_detail', + 'project_code_label', + 'project_code_detail', + *extra_fields, + ] + target_date = serializers.DateField( required=False, allow_null=True, label=_('Target Date') ) + project_code_label = enable_filter( + FilterableCharField( + source='project_code.code', + read_only=True, + label='Project Code Label', + allow_null=True, + ), + True, + filter_name='project_code_detail', + ) + + project_code_detail = enable_filter( + ProjectCodeSerializer( + source='project_code', read_only=True, many=False, allow_null=True + ), + True, + ) + class AbstractExtraLineSerializer( - DataImportExportSerializerMixin, serializers.Serializer + DataImportExportSerializerMixin, FilterableSerializerMixin, serializers.Serializer ): """Abstract Serializer for a ExtraLine object.""" + @staticmethod + def extra_line_fields(extra_fields): + """Construct a set of fields for this serializer.""" + return [ + 'pk', + 'description', + 'link', + 'notes', + 'order', + 'price', + 'price_currency', + 'project_code', + 'quantity', + 'reference', + 'target_date', + # Filterable detail fields + 'order_detail', + 'project_code_label', + 'project_code_detail', + *extra_fields, + ] + quantity = serializers.FloatField() price = InvenTreeMoneySerializer(allow_null=True) price_currency = InvenTreeCurrencySerializer() + project_code_label = enable_filter( + FilterableCharField( + source='project_code.code', + read_only=True, + label='Project Code Label', + allow_null=True, + ), + True, + filter_name='project_code_detail', + ) + + # Detail for project code field + project_code_detail = enable_filter( + ProjectCodeSerializer( + source='project_code', read_only=True, many=False, allow_null=True + ), + True, + ) + class AbstractExtraLineMeta: """Abstract Meta for ExtraLine.""" @@ -303,7 +401,6 @@ class AbstractExtraLineMeta: @register_importer() class PurchaseOrderSerializer( - FilterableSerializerMixin, NotesFieldMixin, TotalPriceMixin, InvenTreeCustomStatusSerializerMixin, @@ -460,7 +557,6 @@ class PurchaseOrderIssueSerializer(OrderAdjustSerializer): @register_importer() class PurchaseOrderLineItemSerializer( - FilterableSerializerMixin, DataImportExportSerializerMixin, AbstractLineItemSerializer, InvenTreeModelSerializer, @@ -471,35 +567,28 @@ class PurchaseOrderLineItemSerializer( """Metaclass options.""" model = order.models.PurchaseOrderLineItem - fields = [ - 'pk', + fields = AbstractLineItemSerializer.line_fields([ 'part', - 'quantity', - 'reference', - 'notes', - 'order', - 'order_detail', 'build_order', - 'build_order_detail', 'overdue', - 'part_detail', - 'supplier_part_detail', 'received', 'purchase_price', 'purchase_price_currency', 'auto_pricing', 'destination', - 'destination_detail', - 'target_date', 'total_price', - 'link', 'merge_items', 'sku', 'mpn', 'ipn', 'internal_part', 'internal_part_name', - ] + # Filterable detail fields + 'build_order_detail', + 'destination_detail', + 'part_detail', + 'supplier_part_detail', + ]) def skip_create_fields(self): """Return a list of fields to skip when creating a new object.""" @@ -693,21 +782,22 @@ class PurchaseOrderLineItemSerializer( @register_importer() class PurchaseOrderExtraLineSerializer( - FilterableSerializerMixin, AbstractExtraLineSerializer, InvenTreeModelSerializer + AbstractExtraLineSerializer, InvenTreeModelSerializer ): """Serializer for a PurchaseOrderExtraLine object.""" + class Meta(AbstractExtraLineMeta): + """Metaclass options.""" + + model = order.models.PurchaseOrderExtraLine + fields = AbstractExtraLineSerializer.extra_line_fields([]) + order_detail = enable_filter( PurchaseOrderSerializer( source='order', many=False, read_only=True, allow_null=True ) ) - class Meta(AbstractExtraLineMeta): - """Metaclass options.""" - - model = order.models.PurchaseOrderExtraLine - class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer): """A serializer for receiving a single purchase order line item against a purchase order.""" @@ -956,7 +1046,6 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer): @register_importer() class SalesOrderSerializer( - FilterableSerializerMixin, NotesFieldMixin, TotalPriceMixin, InvenTreeCustomStatusSerializerMixin, @@ -1047,7 +1136,6 @@ class SalesOrderIssueSerializer(OrderAdjustSerializer): @register_importer() class SalesOrderLineItemSerializer( - FilterableSerializerMixin, DataImportExportSerializerMixin, AbstractLineItemSerializer, InvenTreeModelSerializer, @@ -1058,29 +1146,22 @@ class SalesOrderLineItemSerializer( """Metaclass options.""" model = order.models.SalesOrderLineItem - fields = [ - 'pk', + fields = AbstractLineItemSerializer.line_fields([ 'allocated', 'customer_detail', - 'quantity', - 'reference', - 'notes', - 'order', - 'order_detail', 'overdue', 'part', 'part_detail', 'sale_price', 'sale_price_currency', 'shipped', - 'target_date', - 'link', # Annotated fields for part stocking information 'available_stock', 'available_variant_stock', 'building', 'on_order', - ] + # Filterable detail fields + ]) @staticmethod def annotate_queryset(queryset): @@ -1795,7 +1876,7 @@ class SalesOrderShipmentAllocationSerializer(serializers.Serializer): @register_importer() class SalesOrderExtraLineSerializer( - FilterableSerializerMixin, AbstractExtraLineSerializer, InvenTreeModelSerializer + AbstractExtraLineSerializer, InvenTreeModelSerializer ): """Serializer for a SalesOrderExtraLine object.""" @@ -1803,6 +1884,7 @@ class SalesOrderExtraLineSerializer( """Metaclass options.""" model = order.models.SalesOrderExtraLine + fields = AbstractExtraLineSerializer.extra_line_fields([]) order_detail = enable_filter( SalesOrderSerializer( @@ -1813,7 +1895,6 @@ class SalesOrderExtraLineSerializer( @register_importer() class ReturnOrderSerializer( - FilterableSerializerMixin, NotesFieldMixin, InvenTreeCustomStatusSerializerMixin, AbstractOrderSerializer, @@ -2004,7 +2085,6 @@ class ReturnOrderReceiveSerializer(serializers.Serializer): @register_importer() class ReturnOrderLineItemSerializer( - FilterableSerializerMixin, DataImportExportSerializerMixin, AbstractLineItemSerializer, InvenTreeModelSerializer, @@ -2015,24 +2095,16 @@ class ReturnOrderLineItemSerializer( """Metaclass options.""" model = order.models.ReturnOrderLineItem - fields = [ - 'pk', - 'order', - 'order_detail', + fields = AbstractLineItemSerializer.line_fields([ 'item', - 'item_detail', - 'quantity', 'received_date', 'outcome', - 'part_detail', 'price', 'price_currency', - 'link', - 'reference', - 'notes', - 'target_date', - 'link', - ] + # Filterable detail fields + 'item_detail', + 'part_detail', + ]) order_detail = enable_filter( ReturnOrderSerializer( @@ -2062,7 +2134,7 @@ class ReturnOrderLineItemSerializer( @register_importer() class ReturnOrderExtraLineSerializer( - FilterableSerializerMixin, AbstractExtraLineSerializer, InvenTreeModelSerializer + AbstractExtraLineSerializer, InvenTreeModelSerializer ): """Serializer for a ReturnOrderExtraLine object.""" @@ -2070,6 +2142,7 @@ class ReturnOrderExtraLineSerializer( """Metaclass options.""" model = order.models.ReturnOrderExtraLine + fields = AbstractExtraLineSerializer.extra_line_fields([]) order_detail = enable_filter( ReturnOrderSerializer( diff --git a/src/frontend/src/forms/CommonForms.tsx b/src/frontend/src/forms/CommonForms.tsx index 25b8e2b81d..e8992746f3 100644 --- a/src/frontend/src/forms/CommonForms.tsx +++ b/src/frontend/src/forms/CommonForms.tsx @@ -2,6 +2,7 @@ import { IconUsers } from '@tabler/icons-react'; import { useMemo, useState } from 'react'; import type { ApiFormFieldSet } from '@lib/types/Forms'; +import { t } from '@lingui/core/macro'; import type { StatusCodeInterface, StatusCodeListInterface @@ -83,6 +84,9 @@ export function extraLineItemFields(): ApiFormFieldSet { quantity: {}, price: {}, price_currency: {}, + project_code: { + description: t`Select project code for this line item` + }, notes: {}, link: {} }; diff --git a/src/frontend/src/forms/PurchaseOrderForms.tsx b/src/frontend/src/forms/PurchaseOrderForms.tsx index 2e0311f4a0..8ac53bc0a8 100644 --- a/src/frontend/src/forms/PurchaseOrderForms.tsx +++ b/src/frontend/src/forms/PurchaseOrderForms.tsx @@ -121,6 +121,9 @@ export function usePurchaseOrderLineItemFields({ value: autoPricing, onValueChange: setAutoPricing }, + project_code: { + description: t`Select project code for this line item` + }, target_date: { icon: }, diff --git a/src/frontend/src/forms/ReturnOrderForms.tsx b/src/frontend/src/forms/ReturnOrderForms.tsx index d791344943..92f631feab 100644 --- a/src/frontend/src/forms/ReturnOrderForms.tsx +++ b/src/frontend/src/forms/ReturnOrderForms.tsx @@ -134,6 +134,9 @@ export function useReturnOrderLineItemFields({ }, price: {}, price_currency: {}, + project_code: { + description: t`Select project code for this line item` + }, target_date: {}, notes: {}, link: {} diff --git a/src/frontend/src/forms/SalesOrderForms.tsx b/src/frontend/src/forms/SalesOrderForms.tsx index ab4c123fa4..7ca5f0d840 100644 --- a/src/frontend/src/forms/SalesOrderForms.tsx +++ b/src/frontend/src/forms/SalesOrderForms.tsx @@ -157,6 +157,9 @@ export function useSalesOrderLineItemFields({ value: partCurrency, onValueChange: setPartCurrency }, + project_code: { + description: t`Select project code for this line item` + }, target_date: {}, notes: {}, link: {} diff --git a/src/frontend/src/tables/general/ExtraLineItemTable.tsx b/src/frontend/src/tables/general/ExtraLineItemTable.tsx index 03c5fabe0e..909cd4a76b 100644 --- a/src/frontend/src/tables/general/ExtraLineItemTable.tsx +++ b/src/frontend/src/tables/general/ExtraLineItemTable.tsx @@ -25,7 +25,8 @@ import { DecimalColumn, DescriptionColumn, LinkColumn, - NoteColumn + NoteColumn, + ProjectCodeColumn } from '../ColumnRenderers'; import { InvenTreeTable } from '../InvenTreeTable'; @@ -75,6 +76,7 @@ export default function ExtraLineItemTable({ multiplier: record.quantity }) }, + ProjectCodeColumn({}), NoteColumn({ accessor: 'notes' }), diff --git a/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx b/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx index d4244006c0..d7303d1fa5 100644 --- a/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx +++ b/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx @@ -45,6 +45,7 @@ import { LocationColumn, NoteColumn, PartColumn, + ProjectCodeColumn, ReferenceColumn, TargetDateColumn } from '../ColumnRenderers'; @@ -150,6 +151,7 @@ export function PurchaseOrderLineItemTable({ accessor: 'part_detail.description' }), ReferenceColumn({}), + ProjectCodeColumn({}), { accessor: 'build_order', title: t`Build Order`, diff --git a/src/frontend/src/tables/sales/ReturnOrderLineItemTable.tsx b/src/frontend/src/tables/sales/ReturnOrderLineItemTable.tsx index 569a21667a..51d01d1109 100644 --- a/src/frontend/src/tables/sales/ReturnOrderLineItemTable.tsx +++ b/src/frontend/src/tables/sales/ReturnOrderLineItemTable.tsx @@ -34,6 +34,7 @@ import { LinkColumn, NoteColumn, PartColumn, + ProjectCodeColumn, ReferenceColumn, StatusColumn } from '../ColumnRenderers'; @@ -137,6 +138,7 @@ export default function ReturnOrderLineItemTable({ title: t`Status` }), ReferenceColumn({}), + ProjectCodeColumn({}), StatusColumn({ model: ModelType.returnorderlineitem, sortable: true, diff --git a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx index 698fec0bba..e67295eaff 100644 --- a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx +++ b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx @@ -48,6 +48,7 @@ import { DecimalColumn, DescriptionColumn, LinkColumn, + ProjectCodeColumn, RenderPartColumn } from '../ColumnRenderers'; import { InvenTreeTable } from '../InvenTreeTable'; @@ -106,6 +107,7 @@ export default function SalesOrderLineItemTable({ sortable: false, switchable: true }, + ProjectCodeColumn({}), DecimalColumn({ accessor: 'quantity', sortable: true