2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-05-02 21:38:48 +00:00

Add Expiry Date on Receive Line Item (#8867)

* Add expiry on line item receive from PO

* add backend test

* reset pre-commit

* increment inventree api version

* use None as default expiry date

* check global setting STOCK_ENABLE_EXPIRY

* check for default expiry in line item receive

* use dayjs

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Jacob Felknor 2025-01-11 19:56:30 -07:00 committed by GitHub
parent c75630d1bd
commit e8c1417b15
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 82 additions and 4 deletions

View File

@ -1,13 +1,17 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """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 = """
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 v298 - 2025-01-07 - https://github.com/inventree/InvenTree/pull/8848
- Adds 'created_by' field to PurchaseOrder API endpoints - Adds 'created_by' field to PurchaseOrder API endpoints
- Adds 'created_by' field to SalesOrder API endpoints - Adds 'created_by' field to SalesOrder API endpoints

View File

@ -410,6 +410,7 @@ class PurchaseOrderReceive(PurchaseOrderContextMixin, CreateAPI):
- supplier_part: pk value of the supplier part - supplier_part: pk value of the supplier part
- quantity: quantity to receive - quantity: quantity to receive
- status: stock item status - status: stock item status
- expiry_date: stock item expiry date (optional)
- location: destination for stock item (optional) - location: destination for stock item (optional)
- batch_code: the batch code for this stock item - batch_code: the batch code for this stock item
- serial_numbers: serial numbers for this stock item - serial_numbers: serial numbers for this stock item

View File

@ -819,6 +819,9 @@ class PurchaseOrder(TotalPriceMixin, Order):
# Extract optional batch code for the new stock item # Extract optional batch code for the new stock item
batch_code = kwargs.get('batch_code', '') 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 # Extract optional list of serial numbers
serials = kwargs.get('serials') serials = kwargs.get('serials')
@ -882,6 +885,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
purchase_order=self, purchase_order=self,
status=status, status=status,
batch=batch_code, batch=batch_code,
expiry_date=expiry_date,
packaging=packaging, packaging=packaging,
serial=sn, serial=sn,
purchase_price=unit_purchase_price, purchase_price=unit_purchase_price,

View File

@ -717,6 +717,7 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
'quantity', 'quantity',
'status', 'status',
'batch_code', 'batch_code',
'expiry_date',
'serial_numbers', 'serial_numbers',
'packaging', 'packaging',
'note', 'note',
@ -765,6 +766,13 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
allow_blank=True, 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( serial_numbers = serializers.CharField(
label=_('Serial Numbers'), label=_('Serial Numbers'),
help_text=_('Enter serial numbers for incoming stock items'), help_text=_('Enter serial numbers for incoming stock items'),
@ -967,6 +975,7 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
status=item['status'], status=item['status'],
barcode=item.get('barcode', ''), barcode=item.get('barcode', ''),
batch_code=item.get('batch_code', ''), batch_code=item.get('batch_code', ''),
expiry_date=item.get('expiry_date', None),
packaging=item.get('packaging', ''), packaging=item.get('packaging', ''),
serials=item.get('serials', None), serials=item.get('serials', None),
notes=item.get('note', None), notes=item.get('note', None),

View File

@ -3,7 +3,7 @@
import base64 import base64
import io import io
import json import json
from datetime import datetime, timedelta from datetime import date, datetime, timedelta
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import connection from django.db import connection
@ -1061,12 +1061,20 @@ class PurchaseOrderReceiveTest(OrderTest):
self.assertEqual(line_1.received, 0) self.assertEqual(line_1.received, 0)
self.assertEqual(line_2.received, 50) self.assertEqual(line_2.received, 50)
one_week_from_today = date.today() + timedelta(days=7)
valid_data = { valid_data = {
'items': [ '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, 'line_item': 2,
'quantity': 200, 'quantity': 200,
'expiry_date': one_week_from_today.strftime(r'%Y-%m-%d'),
'location': 2, # Explicit location 'location': 2, # Explicit location
'barcode': 'MY-UNIQUE-BARCODE-456', 'barcode': 'MY-UNIQUE-BARCODE-456',
}, },
@ -1111,6 +1119,10 @@ class PurchaseOrderReceiveTest(OrderTest):
self.assertEqual(stock_1.last().location.pk, 1) self.assertEqual(stock_1.last().location.pk, 1)
self.assertEqual(stock_2.last().location.pk, 2) 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 # Barcodes should have been assigned to the stock items
self.assertTrue( self.assertTrue(
StockItem.objects.filter(barcode_data='MY-UNIQUE-BARCODE-123').exists() StockItem.objects.filter(barcode_data='MY-UNIQUE-BARCODE-123').exists()

View File

@ -352,6 +352,7 @@ class PartBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
'barcode_hash', 'barcode_hash',
'category_default_location', 'category_default_location',
'default_location', 'default_location',
'default_expiry',
'name', 'name',
'revision', 'revision',
'full_name', 'full_name',

View File

@ -25,6 +25,8 @@ import {
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { IconCalendarExclamation } from '@tabler/icons-react';
import dayjs from 'dayjs';
import { api } from '../App'; import { api } from '../App';
import { ActionButton } from '../components/buttons/ActionButton'; import { ActionButton } from '../components/buttons/ActionButton';
import RemoveRowButton from '../components/buttons/RemoveRowButton'; import RemoveRowButton from '../components/buttons/RemoveRowButton';
@ -49,7 +51,7 @@ import {
useSerialNumberGenerator useSerialNumberGenerator
} from '../hooks/UseGenerator'; } from '../hooks/UseGenerator';
import { apiUrl } from '../states/ApiState'; import { apiUrl } from '../states/ApiState';
import { useGlobalSettingsState } from '../states/SettingsState';
/* /*
* Construct a set of fields for creating / editing a PurchaseOrderLineItem instance * Construct a set of fields for creating / editing a PurchaseOrderLineItem instance
*/ */
@ -246,6 +248,8 @@ function LineItemFormRow({
[record] [record]
); );
const settings = useGlobalSettingsState();
useEffect(() => { useEffect(() => {
if (!!record.destination) { if (!!record.destination) {
props.changeFn(props.idx, 'location', 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 // Status value
const [statusOpen, statusHandlers] = useDisclosure(false, { const [statusOpen, statusHandlers] = useDisclosure(false, {
onClose: () => props.changeFn(props.idx, 'status', undefined) onClose: () => props.changeFn(props.idx, 'status', undefined)
@ -440,6 +461,16 @@ function LineItemFormRow({
tooltipAlignment='top' tooltipAlignment='top'
variant={batchOpen ? 'filled' : 'transparent'} variant={batchOpen ? 'filled' : 'transparent'}
/> />
{settings.isSet('STOCK_ENABLE_EXPIRY') && (
<ActionButton
size='sm'
onClick={() => expiryDateHandlers.toggle()}
icon={<IconCalendarExclamation />}
tooltip={t`Set Expiry Date`}
tooltipAlignment='top'
variant={expiryDateOpen ? 'filled' : 'transparent'}
/>
)}
<ActionButton <ActionButton
size='sm' size='sm'
icon={<InvenTreeIcon icon='packaging' />} icon={<InvenTreeIcon icon='packaging' />}
@ -586,6 +617,21 @@ function LineItemFormRow({
}} }}
error={props.rowErrors?.serial_numbers?.message} error={props.rowErrors?.serial_numbers?.message}
/> />
{settings.isSet('STOCK_ENABLE_EXPIRY') && (
<TableFieldExtraRow
visible={expiryDateOpen}
onValueChange={(value) =>
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}
/>
)}
<TableFieldExtraRow <TableFieldExtraRow
visible={packagingOpen} visible={packagingOpen}
onValueChange={(value) => props.changeFn(props.idx, 'packaging', value)} onValueChange={(value) => props.changeFn(props.idx, 'packaging', value)}
@ -672,6 +718,7 @@ export function useReceiveLineItems(props: LineItemsForm) {
line_item: elem.pk, line_item: elem.pk,
location: elem.destination ?? elem.destination_detail?.pk ?? null, location: elem.destination ?? elem.destination_detail?.pk ?? null,
quantity: elem.quantity - elem.received, quantity: elem.quantity - elem.received,
expiry_date: null,
batch_code: '', batch_code: '',
serial_numbers: '', serial_numbers: '',
status: 10, status: 10,