Added various PO fixes (#6483)

* Added various PO fixes

* Add auto-pricing and merge items functionality to PurchaseOrderLineItem

* Bump api version to v173

* Add po line item create/update tests
This commit is contained in:
Lukas 2024-02-20 23:03:32 +01:00 committed by GitHub
parent 55c64b546f
commit 7694092935
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 295 additions and 85 deletions

View File

@ -1,11 +1,15 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 172
INVENTREE_API_VERSION = 173
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v173 - 2024-02-20 : https://github.com/inventree/InvenTree/pull/6483
- Adds "merge_items" to the PurchaseOrderLine create API endpoint
- Adds "auto_pricing" to the PurchaseOrderLine create/update API endpoint
v172 - 2024-02-20 : https://github.com/inventree/InvenTree/pull/6526
- Adds "enabled" field to the PartTestTemplate API endpoint
- Adds "enabled" filter to the PartTestTemplate list

View File

@ -1,5 +1,8 @@
"""JSON API for the Order app."""
from decimal import Decimal
from typing import cast
from django.contrib.auth import authenticate, login
from django.db import transaction
from django.db.models import F, Q
@ -481,6 +484,14 @@ class PurchaseOrderLineItemMixin:
return self.serializer_class(*args, **kwargs)
def perform_update(self, serializer):
"""Override the perform_update method to auto-update pricing if required."""
super().perform_update(serializer)
# possibly auto-update pricing based on the supplier part pricing data
if serializer.validated_data.get('auto_pricing', True):
serializer.instance.update_pricing()
class PurchaseOrderLineItemList(
PurchaseOrderLineItemMixin, APIDownloadMixin, ListCreateDestroyAPIView
@ -493,6 +504,44 @@ class PurchaseOrderLineItemList(
filterset_class = PurchaseOrderLineItemFilter
def create(self, request, *args, **kwargs):
"""Create or update a new PurchaseOrderLineItem object."""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = cast(dict, serializer.validated_data)
# possibly merge duplicate items
line_item = None
if data.get('merge_items', True):
other_line = models.PurchaseOrderLineItem.objects.filter(
part=data.get('part'),
order=data.get('order'),
target_date=data.get('target_date'),
destination=data.get('destination'),
).first()
if other_line is not None:
other_line.quantity += Decimal(data.get('quantity', 0))
other_line.save()
line_item = other_line
# otherwise create a new line item
if line_item is None:
line_item = serializer.save()
# possibly auto-update pricing based on the supplier part pricing data
if data.get('auto_pricing', True) and isinstance(
line_item, models.PurchaseOrderLineItem
):
line_item.update_pricing()
serializer = serializers.PurchaseOrderLineItemSerializer(line_item)
headers = self.get_success_headers(serializer.data)
return Response(
serializer.data, status=status.HTTP_201_CREATED, headers=headers
)
def filter_queryset(self, queryset):
"""Additional filtering options."""
params = self.request.query_params

View File

@ -1439,6 +1439,17 @@ class PurchaseOrderLineItem(OrderLineItem):
r = self.quantity - self.received
return max(r, 0)
def update_pricing(self):
"""Update pricing information based on the supplier part data."""
if self.part:
price = self.part.get_price(self.quantity)
if price is None:
return
self.purchase_price = Decimal(price) / Decimal(self.quantity)
self.save()
class PurchaseOrderExtraLine(OrderExtraLine):
"""Model for a single ExtraLine in a PurchaseOrder.

View File

@ -340,11 +340,13 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer):
'received',
'purchase_price',
'purchase_price_currency',
'auto_pricing',
'destination',
'destination_detail',
'target_date',
'total_price',
'link',
'merge_items',
]
def __init__(self, *args, **kwargs):
@ -362,6 +364,10 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer):
if order_detail is not True:
self.fields.pop('order_detail')
def skip_create_fields(self):
"""Return a list of fields to skip when creating a new object."""
return ['auto_pricing', 'merge_items'] + super().skip_create_fields()
@staticmethod
def annotate_queryset(queryset):
"""Add some extra annotations to this queryset.
@ -419,6 +425,14 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer):
purchase_price = InvenTreeMoneySerializer(allow_null=True)
auto_pricing = serializers.BooleanField(
label=_('Auto Pricing'),
help_text=_(
'Automatically calculate purchase price based on supplier part data'
),
default=True,
)
destination_detail = stock.serializers.LocationBriefSerializer(
source='get_destination', read_only=True
)
@ -429,6 +443,14 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer):
order_detail = PurchaseOrderSerializer(source='order', read_only=True, many=False)
merge_items = serializers.BooleanField(
label=_('Merge Items'),
help_text=_(
'Merge items with the same part, destination and target date into one line item'
),
default=True,
)
def validate(self, data):
"""Custom validation for the serializer.

View File

@ -14,7 +14,7 @@ from icalendar import Calendar
from rest_framework import status
from common.settings import currency_codes
from company.models import Company
from company.models import Company, SupplierPart, SupplierPriceBreak
from InvenTree.status_codes import (
PurchaseOrderStatus,
ReturnOrderLineStatus,
@ -675,6 +675,94 @@ class PurchaseOrderLineItemTest(OrderTest):
# We should have 2 less PurchaseOrderLineItems after deletign them
self.assertEqual(models.PurchaseOrderLineItem.objects.count(), n - 2)
def test_po_line_merge_pricing(self):
"""Test that we can create a new PurchaseOrderLineItem via the API."""
self.assignRole('purchase_order.add')
self.generate_exchange_rates()
su = Company.objects.get(pk=1)
sp = SupplierPart.objects.get(pk=1)
po = models.PurchaseOrder.objects.create(supplier=su, reference='PO-1234567890')
SupplierPriceBreak.objects.create(part=sp, quantity=1, price=Money(1, 'USD'))
SupplierPriceBreak.objects.create(part=sp, quantity=10, price=Money(0.5, 'USD'))
li1 = self.post(
reverse('api-po-line-list'),
{
'order': po.pk,
'part': sp.pk,
'quantity': 1,
'auto_pricing': True,
'merge_items': False,
},
expected_code=201,
).json()
self.assertEqual(float(li1['purchase_price']), 1)
li2 = self.post(
reverse('api-po-line-list'),
{
'order': po.pk,
'part': sp.pk,
'quantity': 10,
'auto_pricing': True,
'merge_items': False,
},
expected_code=201,
).json()
self.assertEqual(float(li2['purchase_price']), 0.5)
# test that items where not merged
self.assertNotEqual(li1['pk'], li2['pk'])
li3 = self.post(
reverse('api-po-line-list'),
{
'order': po.pk,
'part': sp.pk,
'quantity': 9,
'auto_pricing': True,
'merge_items': True,
},
expected_code=201,
).json()
# test that items where merged
self.assertEqual(li1['pk'], li3['pk'])
# test that price was recalculated
self.assertEqual(float(li3['purchase_price']), 0.5)
# test that pricing will be not recalculated if auto_pricing is False
li4 = self.post(
reverse('api-po-line-list'),
{
'order': po.pk,
'part': sp.pk,
'quantity': 1,
'auto_pricing': False,
'purchase_price': 0.5,
'merge_items': False,
},
expected_code=201,
).json()
self.assertEqual(float(li4['purchase_price']), 0.5)
# test that pricing is correctly recalculated if auto_pricing is True for update
li5 = self.patch(
reverse('api-po-line-detail', kwargs={'pk': li4['pk']}),
{**li4, 'quantity': 5, 'auto_pricing': False},
expected_code=200,
).json()
self.assertEqual(float(li5['purchase_price']), 0.5)
li5 = self.patch(
reverse('api-po-line-detail', kwargs={'pk': li4['pk']}),
{**li4, 'quantity': 5, 'auto_pricing': True},
expected_code=200,
).json()
self.assertEqual(float(li5['purchase_price']), 1)
class PurchaseOrderDownloadTest(OrderTest):
"""Unit tests for downloading PurchaseOrder data via the API endpoint."""

View File

@ -225,7 +225,7 @@ function createPurchaseOrder(options={}) {
};
}
constructForm('{% url "api-po-list" %}', {
constructForm('{% url "api-po-list" %}?supplier_detail=true', {
method: 'POST',
fields: fields,
groups: groups,
@ -268,7 +268,6 @@ function duplicatePurchaseOrder(order_id, options={}) {
/* Construct a set of fields for the PurchaseOrderLineItem form */
function poLineItemFields(options={}) {
var fields = {
order: {
filters: {
@ -286,8 +285,6 @@ function poLineItemFields(options={}) {
// If the pack_quantity != 1, add a note to the field
var pack_quantity = 1;
var units = '';
var supplier_part_id = value;
var quantity = getFormFieldValue('quantity', {}, opts);
// Remove any existing note fields
$(opts.modal).find('#info-pack-size').remove();
@ -314,37 +311,6 @@ function poLineItemFields(options={}) {
var txt = `<span class='fas fa-info-circle icon-blue'></span> {% trans "Pack Quantity" %}: ${formatDecimal(pack_quantity)} ${units}`;
$(opts.modal).find('#hint_id_quantity').after(`<div class='form-info-message' id='info-pack-size'>${txt}</div>`);
}
}).then(function() {
// Update pricing data (if available)
if (options.update_pricing) {
inventreeGet(
'{% url "api-part-supplier-price-list" %}',
{
part: supplier_part_id,
ordering: 'quantity',
},
{
success: function(response) {
// Returned prices are in increasing order of quantity
if (response.length > 0) {
let index = 0;
for (var idx = 0; idx < response.length; idx++) {
if (response[idx].quantity > quantity) {
break;
}
index = idx;
}
// Update price and currency data in the form
updateFieldValue('purchase_price', response[index].price, {}, opts);
updateFieldValue('purchase_price_currency', response[index].price_currency, {}, opts);
}
}
}
);
}
});
},
secondary: {
@ -377,10 +343,20 @@ function poLineItemFields(options={}) {
reference: {},
purchase_price: {
icon: 'fa-dollar-sign',
onEdit: function(value, name, field, opts) {
updateFieldValue('auto_pricing', value === '', {}, opts);
}
},
purchase_price_currency: {
icon: 'fa-coins',
},
auto_pricing: {
onEdit: function(value, name, field, opts) {
if (value) {
updateFieldValue('purchase_price', '', {}, opts);
}
}
},
target_date: {
icon: 'fa-calendar-alt',
},
@ -411,6 +387,10 @@ function poLineItemFields(options={}) {
fields.target_date.value = options.target_date;
}
if (options.create) {
fields.merge_items = {};
}
return fields;
}
@ -425,6 +405,7 @@ function createPurchaseOrderLineItem(order, options={}) {
currency: options.currency,
target_date: options.target_date,
update_pricing: true,
create: true,
});
constructForm('{% url "api-po-line-list" %}', {
@ -697,6 +678,15 @@ function orderParts(parts_list, options={}) {
}
);
const merge_item_input = constructField(
`merge_item_${pk}`,
{
type: 'boolean',
value: true,
},
{ hideLabels: true },
);
let buttons = '';
if (parts.length > 1) {
@ -723,6 +713,7 @@ function orderParts(parts_list, options={}) {
<td id='td_supplier_part_${pk}'>${supplier_part_input}</td>
<td id='td_order_${pk}'>${purchase_order_input}</td>
<td id='td_quantity_${pk}'>${quantity_input}</td>
<td id='td_merge_item_${pk}'>${merge_item_input}</td>
<td id='td_actions_${pk}'>${buttons}</td>
</tr>`;
@ -761,6 +752,7 @@ function orderParts(parts_list, options={}) {
<th style='min-width: 300px;'>{% trans "Supplier Part" %}</th>
<th style='min-width: 300px;'>{% trans "Purchase Order" %}</th>
<th style='min-width: 50px;'>{% trans "Quantity" %}</th>
<th style='min-width: 50px;'>{% trans "Merge" %}</th>
<th><!-- Actions --></th>
</tr>
</thead>
@ -838,6 +830,10 @@ function orderParts(parts_list, options={}) {
success: function(response) {
pack_quantity = response.pack_quantity_native || 1;
units = response.part_detail.units || '';
if(response.supplier) {
order_filters.supplier = response.supplier;
options.supplier = response.supplier;
}
}
}
).then(function() {
@ -926,6 +922,7 @@ function orderParts(parts_list, options={}) {
quantity: getFormFieldValue(`quantity_${pk}`, {type: 'decimal'}, opts),
part: getFormFieldValue(`part_${pk}`, {}, opts),
order: getFormFieldValue(`order_${pk}`, {}, opts),
merge_items: getFormFieldValue(`merge_item_${pk}`, {type: 'boolean'}, opts),
};
// Duplicate the form options, to prevent 'field_suffix' override
@ -984,7 +981,7 @@ function orderParts(parts_list, options={}) {
var pk = $(this).attr('pk');
// Launch dialog to create new purchase order
createPurchaseOrder({
const poOptions = {
onSuccess: function(response) {
setRelatedFieldData(
`order_${pk}`,
@ -992,7 +989,14 @@ function orderParts(parts_list, options={}) {
opts
);
}
});
}
if(options.supplier) {
poOptions.supplier = options.supplier;
poOptions.hide_supplier = true;
}
createPurchaseOrder(poOptions);
});
}
});

View File

@ -144,24 +144,24 @@ export function ApiFormField({
);
// Coerce the value to a numerical value
const numericalValue: number | undefined = useMemo(() => {
let val = 0;
const numericalValue: number | '' = useMemo(() => {
let val: number | '' = 0;
switch (definition.field_type) {
case 'integer':
val = parseInt(value) ?? 0;
val = parseInt(value) ?? '';
break;
case 'decimal':
case 'float':
case 'number':
val = parseFloat(value) ?? 0;
val = parseFloat(value) ?? '';
break;
default:
break;
}
if (isNaN(val) || !isFinite(val)) {
val = 0;
val = '';
}
return val;

View File

@ -11,6 +11,7 @@ import {
IconUser,
IconUsers
} from '@tabler/icons-react';
import { useEffect, useMemo, useState } from 'react';
import {
ApiFormAdjustFilterType,
@ -20,45 +21,76 @@ import {
/*
* Construct a set of fields for creating / editing a PurchaseOrderLineItem instance
*/
export function purchaseOrderLineItemFields() {
let fields: ApiFormFieldSet = {
order: {
filters: {
supplier_detail: true
},
hidden: true
},
part: {
filters: {
part_detail: true,
supplier_detail: true
},
adjustFilters: (value: ApiFormAdjustFilterType) => {
// TODO: Adjust part based on the supplier associated with the supplier
return value.filters;
}
},
quantity: {},
reference: {},
purchase_price: {
icon: <IconCurrencyDollar />
},
purchase_price_currency: {
icon: <IconCoins />
},
target_date: {
icon: <IconCalendar />
},
destination: {
icon: <IconSitemap />
},
notes: {
icon: <IconNotes />
},
link: {
icon: <IconLink />
export function usePurchaseOrderLineItemFields({
create
}: {
create?: boolean;
}) {
const [purchasePrice, setPurchasePrice] = useState<string>('');
const [autoPricing, setAutoPricing] = useState(true);
useEffect(() => {
if (autoPricing) {
setPurchasePrice('');
}
};
}, [autoPricing]);
useEffect(() => {
setAutoPricing(purchasePrice === '');
}, [purchasePrice]);
const fields = useMemo(() => {
const fields: ApiFormFieldSet = {
order: {
filters: {
supplier_detail: true
},
hidden: true
},
part: {
filters: {
part_detail: true,
supplier_detail: true
},
adjustFilters: (value: ApiFormAdjustFilterType) => {
// TODO: Adjust part based on the supplier associated with the supplier
return value.filters;
}
},
quantity: {},
reference: {},
purchase_price: {
icon: <IconCurrencyDollar />,
value: purchasePrice,
onValueChange: setPurchasePrice
},
purchase_price_currency: {
icon: <IconCoins />
},
auto_pricing: {
value: autoPricing,
onValueChange: setAutoPricing
},
target_date: {
icon: <IconCalendar />
},
destination: {
icon: <IconSitemap />
},
notes: {
icon: <IconNotes />
},
link: {
icon: <IconLink />
}
};
if (create) {
fields['merge_items'] = {};
}
return fields;
}, [create, autoPricing, purchasePrice]);
return fields;
}

View File

@ -12,7 +12,7 @@ import { RenderStockLocation } from '../../components/render/Stock';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { purchaseOrderLineItemFields } from '../../forms/PurchaseOrderForms';
import { usePurchaseOrderLineItemFields } from '../../forms/PurchaseOrderForms';
import { getDetailUrl } from '../../functions/urls';
import {
useCreateApiFormModal,
@ -178,7 +178,7 @@ export function PurchaseOrderLineItemTable({
const newLine = useCreateApiFormModal({
url: ApiEndpoints.purchase_order_line_list,
title: t`Add Line Item`,
fields: purchaseOrderLineItemFields(),
fields: usePurchaseOrderLineItemFields({ create: true }),
initialData: {
order: orderId
},
@ -193,7 +193,7 @@ export function PurchaseOrderLineItemTable({
url: ApiEndpoints.purchase_order_line_list,
pk: selectedLine,
title: t`Edit Line Item`,
fields: purchaseOrderLineItemFields(),
fields: usePurchaseOrderLineItemFields({}),
onFormSuccess: table.refreshTable
});