diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 9e3d1d5bb6..25a870de45 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,13 +1,17 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 298 +INVENTREE_API_VERSION = 299 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v299 - 2025-01-10 - https://github.com/inventree/InvenTree/pull/8867 + - Adds 'expiry_date' field to the PurchaseOrderReceive API endpoint + - Adds 'default_expiry` field to the PartBriefSerializer, affecting API endpoints which use it + v298 - 2025-01-07 - https://github.com/inventree/InvenTree/pull/8848 - Adds 'created_by' field to PurchaseOrder API endpoints - Adds 'created_by' field to SalesOrder API endpoints diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index ab4f677a56..906307c7d5 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -410,6 +410,7 @@ class PurchaseOrderReceive(PurchaseOrderContextMixin, CreateAPI): - supplier_part: pk value of the supplier part - quantity: quantity to receive - status: stock item status + - expiry_date: stock item expiry date (optional) - location: destination for stock item (optional) - batch_code: the batch code for this stock item - serial_numbers: serial numbers for this stock item diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index 634a9a1363..536489659f 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -819,6 +819,9 @@ class PurchaseOrder(TotalPriceMixin, Order): # Extract optional batch code for the new stock item batch_code = kwargs.get('batch_code', '') + # Extract optional expiry date for the new stock item + expiry_date = kwargs.get('expiry_date') + # Extract optional list of serial numbers serials = kwargs.get('serials') @@ -882,6 +885,7 @@ class PurchaseOrder(TotalPriceMixin, Order): purchase_order=self, status=status, batch=batch_code, + expiry_date=expiry_date, packaging=packaging, serial=sn, purchase_price=unit_purchase_price, diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index be0f59bd38..aaba8eba9a 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -717,6 +717,7 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer): 'quantity', 'status', 'batch_code', + 'expiry_date', 'serial_numbers', 'packaging', 'note', @@ -765,6 +766,13 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer): allow_blank=True, ) + expiry_date = serializers.DateField( + label=_('Expiry Date'), + help_text=_('Enter expiry date for incoming stock items'), + required=False, + default=None, + ) + serial_numbers = serializers.CharField( label=_('Serial Numbers'), help_text=_('Enter serial numbers for incoming stock items'), @@ -967,6 +975,7 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer): status=item['status'], barcode=item.get('barcode', ''), batch_code=item.get('batch_code', ''), + expiry_date=item.get('expiry_date', None), packaging=item.get('packaging', ''), serials=item.get('serials', None), notes=item.get('note', None), diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index 97cdc0d3ee..e0148b72f1 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -3,7 +3,7 @@ import base64 import io import json -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta from django.core.exceptions import ValidationError from django.db import connection @@ -1061,12 +1061,20 @@ class PurchaseOrderReceiveTest(OrderTest): self.assertEqual(line_1.received, 0) self.assertEqual(line_2.received, 50) + one_week_from_today = date.today() + timedelta(days=7) + valid_data = { 'items': [ - {'line_item': 1, 'quantity': 50, 'barcode': 'MY-UNIQUE-BARCODE-123'}, + { + 'line_item': 1, + 'quantity': 50, + 'expiry_date': one_week_from_today.strftime(r'%Y-%m-%d'), + 'barcode': 'MY-UNIQUE-BARCODE-123', + }, { 'line_item': 2, 'quantity': 200, + 'expiry_date': one_week_from_today.strftime(r'%Y-%m-%d'), 'location': 2, # Explicit location 'barcode': 'MY-UNIQUE-BARCODE-456', }, @@ -1111,6 +1119,10 @@ class PurchaseOrderReceiveTest(OrderTest): self.assertEqual(stock_1.last().location.pk, 1) self.assertEqual(stock_2.last().location.pk, 2) + # Expiry dates should be set + self.assertEqual(stock_1.last().expiry_date, one_week_from_today) + self.assertEqual(stock_2.last().expiry_date, one_week_from_today) + # Barcodes should have been assigned to the stock items self.assertTrue( StockItem.objects.filter(barcode_data='MY-UNIQUE-BARCODE-123').exists() diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 68ab21990a..2fcdf1206a 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -352,6 +352,7 @@ class PartBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer): 'barcode_hash', 'category_default_location', 'default_location', + 'default_expiry', 'name', 'revision', 'full_name', diff --git a/src/frontend/src/forms/PurchaseOrderForms.tsx b/src/frontend/src/forms/PurchaseOrderForms.tsx index 8523a3a28f..50e0b2c66e 100644 --- a/src/frontend/src/forms/PurchaseOrderForms.tsx +++ b/src/frontend/src/forms/PurchaseOrderForms.tsx @@ -25,6 +25,8 @@ import { import { useQuery } from '@tanstack/react-query'; import { useEffect, useMemo, useState } from 'react'; +import { IconCalendarExclamation } from '@tabler/icons-react'; +import dayjs from 'dayjs'; import { api } from '../App'; import { ActionButton } from '../components/buttons/ActionButton'; import RemoveRowButton from '../components/buttons/RemoveRowButton'; @@ -49,7 +51,7 @@ import { useSerialNumberGenerator } from '../hooks/UseGenerator'; import { apiUrl } from '../states/ApiState'; - +import { useGlobalSettingsState } from '../states/SettingsState'; /* * Construct a set of fields for creating / editing a PurchaseOrderLineItem instance */ @@ -246,6 +248,8 @@ function LineItemFormRow({ [record] ); + const settings = useGlobalSettingsState(); + useEffect(() => { if (!!record.destination) { props.changeFn(props.idx, 'location', record.destination); @@ -298,6 +302,23 @@ function LineItemFormRow({ } }); + const [expiryDateOpen, expiryDateHandlers] = useDisclosure(false, { + onOpen: () => { + // check the default part expiry. Assume expiry is relative to today + const defaultExpiry = record.part_detail?.default_expiry; + if (defaultExpiry !== undefined && defaultExpiry > 0) { + props.changeFn( + props.idx, + 'expiry_date', + dayjs().add(defaultExpiry, 'day').format('YYYY-MM-DD') + ); + } + }, + onClose: () => { + props.changeFn(props.idx, 'expiry_date', undefined); + } + }); + // Status value const [statusOpen, statusHandlers] = useDisclosure(false, { onClose: () => props.changeFn(props.idx, 'status', undefined) @@ -440,6 +461,16 @@ function LineItemFormRow({ tooltipAlignment='top' variant={batchOpen ? 'filled' : 'transparent'} /> + {settings.isSet('STOCK_ENABLE_EXPIRY') && ( + expiryDateHandlers.toggle()} + icon={} + tooltip={t`Set Expiry Date`} + tooltipAlignment='top' + variant={expiryDateOpen ? 'filled' : 'transparent'} + /> + )} } @@ -586,6 +617,21 @@ function LineItemFormRow({ }} error={props.rowErrors?.serial_numbers?.message} /> + {settings.isSet('STOCK_ENABLE_EXPIRY') && ( + + props.changeFn(props.idx, 'expiry_date', value) + } + fieldDefinition={{ + field_type: 'date', + label: t`Expiry Date`, + description: t`Enter an expiry date for received items`, + value: props.item.expiry_date + }} + error={props.rowErrors?.expiry_date?.message} + /> + )} props.changeFn(props.idx, 'packaging', value)} @@ -672,6 +718,7 @@ export function useReceiveLineItems(props: LineItemsForm) { line_item: elem.pk, location: elem.destination ?? elem.destination_detail?.pk ?? null, quantity: elem.quantity - elem.received, + expiry_date: null, batch_code: '', serial_numbers: '', status: 10,