2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 11:36:44 +00:00

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 information."""
# InvenTree API version # 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.""" """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 = """
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 v172 - 2024-02-20 : https://github.com/inventree/InvenTree/pull/6526
- Adds "enabled" field to the PartTestTemplate API endpoint - Adds "enabled" field to the PartTestTemplate API endpoint
- Adds "enabled" filter to the PartTestTemplate list - Adds "enabled" filter to the PartTestTemplate list

View File

@ -1,5 +1,8 @@
"""JSON API for the Order app.""" """JSON API for the Order app."""
from decimal import Decimal
from typing import cast
from django.contrib.auth import authenticate, login from django.contrib.auth import authenticate, login
from django.db import transaction from django.db import transaction
from django.db.models import F, Q from django.db.models import F, Q
@ -481,6 +484,14 @@ class PurchaseOrderLineItemMixin:
return self.serializer_class(*args, **kwargs) 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( class PurchaseOrderLineItemList(
PurchaseOrderLineItemMixin, APIDownloadMixin, ListCreateDestroyAPIView PurchaseOrderLineItemMixin, APIDownloadMixin, ListCreateDestroyAPIView
@ -493,6 +504,44 @@ class PurchaseOrderLineItemList(
filterset_class = PurchaseOrderLineItemFilter 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): def filter_queryset(self, queryset):
"""Additional filtering options.""" """Additional filtering options."""
params = self.request.query_params params = self.request.query_params

View File

@ -1439,6 +1439,17 @@ class PurchaseOrderLineItem(OrderLineItem):
r = self.quantity - self.received r = self.quantity - self.received
return max(r, 0) 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): class PurchaseOrderExtraLine(OrderExtraLine):
"""Model for a single ExtraLine in a PurchaseOrder. """Model for a single ExtraLine in a PurchaseOrder.

View File

@ -340,11 +340,13 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer):
'received', 'received',
'purchase_price', 'purchase_price',
'purchase_price_currency', 'purchase_price_currency',
'auto_pricing',
'destination', 'destination',
'destination_detail', 'destination_detail',
'target_date', 'target_date',
'total_price', 'total_price',
'link', 'link',
'merge_items',
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -362,6 +364,10 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer):
if order_detail is not True: if order_detail is not True:
self.fields.pop('order_detail') 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 @staticmethod
def annotate_queryset(queryset): def annotate_queryset(queryset):
"""Add some extra annotations to this queryset. """Add some extra annotations to this queryset.
@ -419,6 +425,14 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer):
purchase_price = InvenTreeMoneySerializer(allow_null=True) 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( destination_detail = stock.serializers.LocationBriefSerializer(
source='get_destination', read_only=True source='get_destination', read_only=True
) )
@ -429,6 +443,14 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer):
order_detail = PurchaseOrderSerializer(source='order', read_only=True, many=False) 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): def validate(self, data):
"""Custom validation for the serializer. """Custom validation for the serializer.

View File

@ -14,7 +14,7 @@ from icalendar import Calendar
from rest_framework import status from rest_framework import status
from common.settings import currency_codes from common.settings import currency_codes
from company.models import Company from company.models import Company, SupplierPart, SupplierPriceBreak
from InvenTree.status_codes import ( from InvenTree.status_codes import (
PurchaseOrderStatus, PurchaseOrderStatus,
ReturnOrderLineStatus, ReturnOrderLineStatus,
@ -675,6 +675,94 @@ class PurchaseOrderLineItemTest(OrderTest):
# We should have 2 less PurchaseOrderLineItems after deletign them # We should have 2 less PurchaseOrderLineItems after deletign them
self.assertEqual(models.PurchaseOrderLineItem.objects.count(), n - 2) 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): class PurchaseOrderDownloadTest(OrderTest):
"""Unit tests for downloading PurchaseOrder data via the API endpoint.""" """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', method: 'POST',
fields: fields, fields: fields,
groups: groups, groups: groups,
@ -268,7 +268,6 @@ function duplicatePurchaseOrder(order_id, options={}) {
/* Construct a set of fields for the PurchaseOrderLineItem form */ /* Construct a set of fields for the PurchaseOrderLineItem form */
function poLineItemFields(options={}) { function poLineItemFields(options={}) {
var fields = { var fields = {
order: { order: {
filters: { filters: {
@ -286,8 +285,6 @@ function poLineItemFields(options={}) {
// If the pack_quantity != 1, add a note to the field // If the pack_quantity != 1, add a note to the field
var pack_quantity = 1; var pack_quantity = 1;
var units = ''; var units = '';
var supplier_part_id = value;
var quantity = getFormFieldValue('quantity', {}, opts);
// Remove any existing note fields // Remove any existing note fields
$(opts.modal).find('#info-pack-size').remove(); $(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}`; 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>`); $(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: { secondary: {
@ -377,10 +343,20 @@ function poLineItemFields(options={}) {
reference: {}, reference: {},
purchase_price: { purchase_price: {
icon: 'fa-dollar-sign', icon: 'fa-dollar-sign',
onEdit: function(value, name, field, opts) {
updateFieldValue('auto_pricing', value === '', {}, opts);
}
}, },
purchase_price_currency: { purchase_price_currency: {
icon: 'fa-coins', icon: 'fa-coins',
}, },
auto_pricing: {
onEdit: function(value, name, field, opts) {
if (value) {
updateFieldValue('purchase_price', '', {}, opts);
}
}
},
target_date: { target_date: {
icon: 'fa-calendar-alt', icon: 'fa-calendar-alt',
}, },
@ -411,6 +387,10 @@ function poLineItemFields(options={}) {
fields.target_date.value = options.target_date; fields.target_date.value = options.target_date;
} }
if (options.create) {
fields.merge_items = {};
}
return fields; return fields;
} }
@ -425,6 +405,7 @@ function createPurchaseOrderLineItem(order, options={}) {
currency: options.currency, currency: options.currency,
target_date: options.target_date, target_date: options.target_date,
update_pricing: true, update_pricing: true,
create: true,
}); });
constructForm('{% url "api-po-line-list" %}', { 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 = ''; let buttons = '';
if (parts.length > 1) { 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_supplier_part_${pk}'>${supplier_part_input}</td>
<td id='td_order_${pk}'>${purchase_order_input}</td> <td id='td_order_${pk}'>${purchase_order_input}</td>
<td id='td_quantity_${pk}'>${quantity_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> <td id='td_actions_${pk}'>${buttons}</td>
</tr>`; </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 "Supplier Part" %}</th>
<th style='min-width: 300px;'>{% trans "Purchase Order" %}</th> <th style='min-width: 300px;'>{% trans "Purchase Order" %}</th>
<th style='min-width: 50px;'>{% trans "Quantity" %}</th> <th style='min-width: 50px;'>{% trans "Quantity" %}</th>
<th style='min-width: 50px;'>{% trans "Merge" %}</th>
<th><!-- Actions --></th> <th><!-- Actions --></th>
</tr> </tr>
</thead> </thead>
@ -838,6 +830,10 @@ function orderParts(parts_list, options={}) {
success: function(response) { success: function(response) {
pack_quantity = response.pack_quantity_native || 1; pack_quantity = response.pack_quantity_native || 1;
units = response.part_detail.units || ''; units = response.part_detail.units || '';
if(response.supplier) {
order_filters.supplier = response.supplier;
options.supplier = response.supplier;
}
} }
} }
).then(function() { ).then(function() {
@ -926,6 +922,7 @@ function orderParts(parts_list, options={}) {
quantity: getFormFieldValue(`quantity_${pk}`, {type: 'decimal'}, opts), quantity: getFormFieldValue(`quantity_${pk}`, {type: 'decimal'}, opts),
part: getFormFieldValue(`part_${pk}`, {}, opts), part: getFormFieldValue(`part_${pk}`, {}, opts),
order: getFormFieldValue(`order_${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 // Duplicate the form options, to prevent 'field_suffix' override
@ -984,7 +981,7 @@ function orderParts(parts_list, options={}) {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
// Launch dialog to create new purchase order // Launch dialog to create new purchase order
createPurchaseOrder({ const poOptions = {
onSuccess: function(response) { onSuccess: function(response) {
setRelatedFieldData( setRelatedFieldData(
`order_${pk}`, `order_${pk}`,
@ -992,7 +989,14 @@ function orderParts(parts_list, options={}) {
opts 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 // Coerce the value to a numerical value
const numericalValue: number | undefined = useMemo(() => { const numericalValue: number | '' = useMemo(() => {
let val = 0; let val: number | '' = 0;
switch (definition.field_type) { switch (definition.field_type) {
case 'integer': case 'integer':
val = parseInt(value) ?? 0; val = parseInt(value) ?? '';
break; break;
case 'decimal': case 'decimal':
case 'float': case 'float':
case 'number': case 'number':
val = parseFloat(value) ?? 0; val = parseFloat(value) ?? '';
break; break;
default: default:
break; break;
} }
if (isNaN(val) || !isFinite(val)) { if (isNaN(val) || !isFinite(val)) {
val = 0; val = '';
} }
return val; return val;

View File

@ -11,6 +11,7 @@ import {
IconUser, IconUser,
IconUsers IconUsers
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useEffect, useMemo, useState } from 'react';
import { import {
ApiFormAdjustFilterType, ApiFormAdjustFilterType,
@ -20,45 +21,76 @@ import {
/* /*
* Construct a set of fields for creating / editing a PurchaseOrderLineItem instance * Construct a set of fields for creating / editing a PurchaseOrderLineItem instance
*/ */
export function purchaseOrderLineItemFields() { export function usePurchaseOrderLineItemFields({
let fields: ApiFormFieldSet = { create
order: { }: {
filters: { create?: boolean;
supplier_detail: true }) {
}, const [purchasePrice, setPurchasePrice] = useState<string>('');
hidden: true const [autoPricing, setAutoPricing] = useState(true);
},
part: { useEffect(() => {
filters: { if (autoPricing) {
part_detail: true, setPurchasePrice('');
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 />
} }
}; }, [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; return fields;
} }

View File

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