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

Purchase Order Destination (#8403)

* Add "destination" field to PurchaseOrder

* Add 'destination' field to API

* Add location to PurchaseOrderDetail page

* Display "destination" on PurchaseOrderDetail page

* Pre-select location based on selected "destination"

* Fix order of reception priority

* Auto-expand the per-line destination field

* Add "Purchase Order" detail to StockItemDetail page

* Bug fix in PurchaseOrderForms

* Split playwright tests

* Docs updates

* Bump API version

* Unit test fixes

* Fix more tests

* Backport to CUI

* Use PurchaseOrder destination when scanning items
This commit is contained in:
Oliver 2024-11-01 11:10:25 +11:00 committed by GitHub
parent 871cd905f1
commit c4031dba7f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 240 additions and 71 deletions

View File

@ -93,6 +93,14 @@ There are two options to mark items as "received":
!!! note "Permissions" !!! note "Permissions"
Marking line items as received requires the "Purchase order" ADD permission. Marking line items as received requires the "Purchase order" ADD permission.
### Item Location
When receiving items from a purchase order, the location of the items must be specified. There are multiple ways to specify the location:
* **Order Destination**: The *destination* field of the purchase order can be set to a specific location. When receiving items, the location will default to the destination location.
* **Line Item Location**: Each line item can have a specific location set. When receiving items, the location will default to the line item location. *Note: A destination specified at the line item level will override the destination specified at the order level.*
### Received Items ### Received Items
Each item marked as "received" is automatically converted into a stock item. Each item marked as "received" is automatically converted into a stock item.

View File

@ -1,14 +1,17 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 275 INVENTREE_API_VERSION = 276
"""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 = """
v274 - 2024-10-31 : https://github.com/inventree/InvenTree/pull/8396 v276 - 2024-10-31 : https://github.com/inventree/InvenTree/pull/8403
- Adds 'destination' field to the PurchaseOrder model and API endpoints
v275 - 2024-10-31 : https://github.com/inventree/InvenTree/pull/8396
- Adds SKU and MPN fields to the StockItem serializer - Adds SKU and MPN fields to the StockItem serializer
- Additional export options for the StockItem serializer - Additional export options for the StockItem serializer

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.16 on 2024-10-31 02:52
from django.db import migrations
import django.db.models.deletion
import mptt.fields
class Migration(migrations.Migration):
dependencies = [
('stock', '0113_stockitem_status_custom_key_and_more'),
('order', '0101_purchaseorder_status_custom_key_and_more'),
]
operations = [
migrations.AddField(
model_name='purchaseorder',
name='destination',
field=mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='purchase_orders', to='stock.stocklocation', verbose_name='Destination', help_text='Destination for received items'),
),
migrations.AlterField(
model_name='purchaseorderlineitem',
name='destination',
field=mptt.fields.TreeForeignKey(blank=True, help_text='Destination for received items', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='po_lines', to='stock.stocklocation', verbose_name='Destination'),
),
]

View File

@ -543,6 +543,16 @@ class PurchaseOrder(TotalPriceMixin, Order):
help_text=_('Date order was completed'), help_text=_('Date order was completed'),
) )
destination = TreeForeignKey(
'stock.StockLocation',
on_delete=models.SET_NULL,
related_name='purchase_orders',
blank=True,
null=True,
verbose_name=_('Destination'),
help_text=_('Destination for received items'),
)
@transaction.atomic @transaction.atomic
def add_line_item( def add_line_item(
self, self,
@ -1544,7 +1554,7 @@ class PurchaseOrderLineItem(OrderLineItem):
related_name='po_lines', related_name='po_lines',
blank=True, blank=True,
null=True, null=True,
help_text=_('Where does the Purchaser want this item to be stored?'), help_text=_('Destination for received items'),
) )
def get_destination(self): def get_destination(self):

View File

@ -318,6 +318,7 @@ class PurchaseOrderSerializer(
'supplier_name', 'supplier_name',
'total_price', 'total_price',
'order_currency', 'order_currency',
'destination',
]) ])
read_only_fields = ['issue_date', 'complete_date', 'creation_date'] read_only_fields = ['issue_date', 'complete_date', 'creation_date']
@ -860,6 +861,7 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
location = serializers.PrimaryKeyRelatedField( location = serializers.PrimaryKeyRelatedField(
queryset=stock.models.StockLocation.objects.all(), queryset=stock.models.StockLocation.objects.all(),
many=False, many=False,
required=False,
allow_null=True, allow_null=True,
label=_('Location'), label=_('Location'),
help_text=_('Select destination location for received items'), help_text=_('Select destination location for received items'),
@ -873,9 +875,10 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
""" """
super().validate(data) super().validate(data)
order = self.context['order']
items = data.get('items', []) items = data.get('items', [])
location = data.get('location', None) location = data.get('location', order.destination)
if len(items) == 0: if len(items) == 0:
raise ValidationError(_('Line items must be provided')) raise ValidationError(_('Line items must be provided'))
@ -919,15 +922,17 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
order = self.context['order'] order = self.context['order']
items = data['items'] items = data['items']
location = data.get('location', None)
# Location can be provided, or default to the order destination
location = data.get('location', order.destination)
# Now we can actually receive the items into stock # Now we can actually receive the items into stock
with transaction.atomic(): with transaction.atomic():
for item in items: for item in items:
# Select location (in descending order of priority) # Select location (in descending order of priority)
loc = ( loc = (
location item.get('location', None)
or item.get('location', None) or location
or item['line_item'].get_destination() or item['line_item'].get_destination()
) )

View File

@ -129,7 +129,15 @@ src="{% static 'img/blank_image.png' %}"
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
{% if order.destination %}
<tr>
<td><span class='fas fa-sitemap'></span></td>
<td>{% trans "Destination" %}</td>
<td>
<a href='{% url "stock-location-detail" order.destination.id %}'>{{ order.destination.name }}</a>
</td>
</tr>
{% endif %}
</table> </table>
{% endblock details %} {% endblock details %}

View File

@ -169,6 +169,9 @@ $('#new-po-line').click(function() {
{{ order.id }}, {{ order.id }},
items, items,
{ {
{% if order.destination %}
destination: {{ order.destination.pk }},
{% endif %}
success: function() { success: function() {
$("#po-line-table").bootstrapTable('refresh'); $("#po-line-table").bootstrapTable('refresh');
} }

View File

@ -882,7 +882,6 @@ class PurchaseOrderReceiveTest(OrderTest):
data = self.post(self.url, {}, expected_code=400).data data = self.post(self.url, {}, expected_code=400).data
self.assertIn('This field is required', str(data['items'])) self.assertIn('This field is required', str(data['items']))
self.assertIn('This field is required', str(data['location']))
# No new stock items have been created # No new stock items have been created
self.assertEqual(self.n, StockItem.objects.count()) self.assertEqual(self.n, StockItem.objects.count())
@ -1060,9 +1059,9 @@ class PurchaseOrderReceiveTest(OrderTest):
self.assertEqual(stock_1.count(), 1) self.assertEqual(stock_1.count(), 1)
self.assertEqual(stock_2.count(), 1) self.assertEqual(stock_2.count(), 1)
# Same location for each received item, as overall 'location' field is provided # Check received locations
self.assertEqual(stock_1.last().location.pk, 1) self.assertEqual(stock_1.last().location.pk, 1)
self.assertEqual(stock_2.last().location.pk, 1) self.assertEqual(stock_2.last().location.pk, 2)
# Barcodes should have been assigned to the stock items # Barcodes should have been assigned to the stock items
self.assertTrue( self.assertTrue(

View File

@ -500,6 +500,15 @@ class BarcodePOReceive(BarcodeView):
purchase_order = kwargs.get('purchase_order') purchase_order = kwargs.get('purchase_order')
location = kwargs.get('location') location = kwargs.get('location')
# Extract location from PurchaseOrder, if available
if not location and purchase_order:
try:
po = order.models.PurchaseOrder.objects.get(pk=purchase_order)
if po.destination:
location = po.destination.pk
except Exception:
pass
plugins = registry.with_mixin('barcode') plugins = registry.with_mixin('barcode')
# Look for a barcode plugin which knows how to deal with this barcode # Look for a barcode plugin which knows how to deal with this barcode

View File

@ -35,7 +35,7 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
'packagename': 'invalid_package_name-asdads-asfd-asdf-asdf-asdf', 'packagename': 'invalid_package_name-asdads-asfd-asdf-asdf-asdf',
}, },
expected_code=400, expected_code=400,
max_query_time=30, max_query_time=60,
) )
# valid - Pypi # valid - Pypi

View File

@ -111,6 +111,9 @@ function purchaseOrderFields(options={}) {
target_date: { target_date: {
icon: 'fa-calendar-alt', icon: 'fa-calendar-alt',
}, },
destination: {
icon: 'fa-sitemap'
},
link: { link: {
icon: 'fa-link', icon: 'fa-link',
}, },
@ -1361,6 +1364,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
method: 'POST', method: 'POST',
fields: { fields: {
location: { location: {
value: options.destination,
filters: { filters: {
structural: false, structural: false,
}, },

View File

@ -166,6 +166,11 @@ export function usePurchaseOrderFields({
target_date: { target_date: {
icon: <IconCalendar /> icon: <IconCalendar />
}, },
destination: {
filters: {
structural: false
}
},
link: {}, link: {},
contact: { contact: {
icon: <IconUser />, icon: <IconUser />,
@ -232,6 +237,13 @@ function LineItemFormRow({
onClose: () => props.changeFn(props.idx, 'location', undefined) onClose: () => props.changeFn(props.idx, 'location', undefined)
}); });
useEffect(() => {
if (!!record.destination) {
props.changeFn(props.idx, 'location', record.destination);
locationHandlers.open();
}
}, [record.destination]);
// Batch code generator // Batch code generator
const batchCodeGenerator = useBatchCodeGenerator((value: any) => { const batchCodeGenerator = useBatchCodeGenerator((value: any) => {
if (value) { if (value) {
@ -239,7 +251,7 @@ function LineItemFormRow({
} }
}); });
// Serial numbebr generator // Serial number generator
const serialNumberGenerator = useSerialNumberGenerator((value: any) => { const serialNumberGenerator = useSerialNumberGenerator((value: any) => {
if (value) { if (value) {
props.changeFn(props.idx, 'serial_numbers', value); props.changeFn(props.idx, 'serial_numbers', value);
@ -475,7 +487,7 @@ function LineItemFormRow({
props.changeFn(props.idx, 'location', value); props.changeFn(props.idx, 'location', value);
}, },
description: locationDescription, description: locationDescription,
value: location, value: props.item.location,
label: t`Location`, label: t`Location`,
icon: <InvenTreeIcon icon="location" /> icon: <InvenTreeIcon icon="location" />
}} }}
@ -599,6 +611,7 @@ type LineFormHandlers = {
type LineItemsForm = { type LineItemsForm = {
items: any[]; items: any[];
orderPk: number; orderPk: number;
destinationPk?: number;
formProps?: LineFormHandlers; formProps?: LineFormHandlers;
}; };
@ -674,7 +687,7 @@ export function useReceiveLineItems(props: LineItemsForm) {
title: t`Receive Line Items`, title: t`Receive Line Items`,
fields: fields, fields: fields,
initialData: { initialData: {
location: null location: props.destinationPk
}, },
size: '80%' size: '80%'
}); });

View File

@ -188,6 +188,14 @@ export default function BuildDetail() {
label: t`Completed`, label: t`Completed`,
icon: 'calendar', icon: 'calendar',
hidden: !build.completion_date hidden: !build.completion_date
},
{
type: 'text',
name: 'project_code_label',
label: t`Project Code`,
icon: 'reference',
copy: true,
hidden: !build.project_code
} }
]; ];

View File

@ -153,12 +153,19 @@ export default function PurchaseOrderDetail() {
total: order.line_items, total: order.line_items,
progress: order.completed_lines progress: order.completed_lines
}, },
{
type: 'link',
model: ModelType.stocklocation,
link: true,
name: 'destination',
label: t`Destination`,
hidden: !order.destination
},
{ {
type: 'text', type: 'text',
name: 'currency', name: 'currency',
label: t`Order Currency`, label: t`Order Currency`,
value_formatter: () => value_formatter: () => orderCurrency
order?.order_currency ?? order?.supplier_detail?.currency
}, },
{ {
type: 'text', type: 'text',
@ -190,8 +197,15 @@ export default function PurchaseOrderDetail() {
icon: 'user', icon: 'user',
copy: true, copy: true,
hidden: !order.contact hidden: !order.contact
},
{
type: 'text',
name: 'project_code_label',
label: t`Project Code`,
icon: 'reference',
copy: true,
hidden: !order.project_code
} }
// TODO: Project code
]; ];
let br: DetailsField[] = [ let br: DetailsField[] = [
@ -253,7 +267,7 @@ export default function PurchaseOrderDetail() {
<DetailsTable fields={br} item={order} /> <DetailsTable fields={br} item={order} />
</ItemDetailsGrid> </ItemDetailsGrid>
); );
}, [order, instanceQuery]); }, [order, orderCurrency, instanceQuery]);
const orderPanels: PanelType[] = useMemo(() => { const orderPanels: PanelType[] = useMemo(() => {
return [ return [

View File

@ -167,8 +167,15 @@ export default function ReturnOrderDetail() {
icon: 'user', icon: 'user',
copy: true, copy: true,
hidden: !order.contact hidden: !order.contact
},
{
type: 'text',
name: 'project_code_label',
label: t`Project Code`,
icon: 'reference',
copy: true,
hidden: !order.project_code
} }
// TODO: Project code
]; ];
let br: DetailsField[] = [ let br: DetailsField[] = [

View File

@ -179,8 +179,15 @@ export default function SalesOrderDetail() {
icon: 'user', icon: 'user',
copy: true, copy: true,
hidden: !order.contact hidden: !order.contact
},
{
type: 'text',
name: 'project_code_label',
label: t`Project Code`,
icon: 'reference',
copy: true,
hidden: !order.project_code
} }
// TODO: Project code
]; ];
let br: DetailsField[] = [ let br: DetailsField[] = [

View File

@ -247,6 +247,15 @@ export default function StockDetail() {
hidden: !stockitem.build, hidden: !stockitem.build,
model_field: 'reference' model_field: 'reference'
}, },
{
type: 'link',
name: 'purchase_order',
label: t`Purchase Order`,
model: ModelType.purchaseorder,
hidden: !stockitem.purchase_order,
icon: 'purchase_orders',
model_field: 'reference'
},
{ {
type: 'link', type: 'link',
name: 'sales_order', name: 'sales_order',

View File

@ -113,6 +113,7 @@ export function PurchaseOrderLineItemTable({
const receiveLineItems = useReceiveLineItems({ const receiveLineItems = useReceiveLineItems({
items: singleRecord ? [singleRecord] : table.selectedRecords, items: singleRecord ? [singleRecord] : table.selectedRecords,
orderPk: orderId, orderPk: orderId,
destinationPk: order.destination,
formProps: { formProps: {
// Timeout is a small hack to prevent function being called before re-render // Timeout is a small hack to prevent function being called before re-render
onClose: () => { onClose: () => {

View File

@ -0,0 +1,85 @@
import { test } from '../baseFixtures.ts';
import { doQuickLogin } from '../login.ts';
test('Purchase Orders - General', async ({ page }) => {
await doQuickLogin(page);
await page.getByRole('tab', { name: 'Purchasing' }).click();
await page.getByRole('cell', { name: 'PO0012' }).click();
await page.waitForTimeout(200);
await page.getByRole('tab', { name: 'Line Items' }).click();
await page.getByRole('tab', { name: 'Received Stock' }).click();
await page.getByRole('tab', { name: 'Attachments' }).click();
await page.getByRole('tab', { name: 'Purchasing' }).click();
await page.getByRole('tab', { name: 'Suppliers' }).click();
await page.getByText('Arrow', { exact: true }).click();
await page.waitForTimeout(200);
await page.getByRole('tab', { name: 'Supplied Parts' }).click();
await page.getByRole('tab', { name: 'Purchase Orders' }).click();
await page.getByRole('tab', { name: 'Stock Items' }).click();
await page.getByRole('tab', { name: 'Contacts' }).click();
await page.getByRole('tab', { name: 'Addresses' }).click();
await page.getByRole('tab', { name: 'Attachments' }).click();
await page.getByRole('tab', { name: 'Purchasing' }).click();
await page.getByRole('tab', { name: 'Manufacturers' }).click();
await page.getByText('AVX Corporation').click();
await page.waitForTimeout(200);
await page.getByRole('tab', { name: 'Addresses' }).click();
await page.getByRole('cell', { name: 'West Branch' }).click();
await page.locator('.mantine-ScrollArea-root').click();
await page
.getByRole('row', { name: 'West Branch Yes Surf Avenue 9' })
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
await page.getByLabel('text-field-title').waitFor();
await page.getByLabel('text-field-line2').waitFor();
// Read the current value of the cell, to ensure we always *change* it!
const value = await page.getByLabel('text-field-line2').inputValue();
await page
.getByLabel('text-field-line2')
.fill(value == 'old' ? 'new' : 'old');
await page.getByRole('button', { name: 'Submit' }).isEnabled();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('tab', { name: 'Details' }).waitFor();
});
/**
* Tests for receiving items against a purchase order
*/
test('Purchase Orders - Receive Items', async ({ page }) => {
await doQuickLogin(page);
await page.getByRole('tab', { name: 'Purchasing' }).click();
await page.getByRole('cell', { name: 'PO0014' }).click();
await page.getByRole('tab', { name: 'Order Details' }).click();
await page.getByText('0 / 3').waitFor();
// Select all line items to receive
await page.getByRole('tab', { name: 'Line Items' }).click();
await page.getByLabel('Select all records').click();
await page.waitForTimeout(200);
await page.getByLabel('action-button-receive-items').click();
// Check for display of individual locations
await page
.getByRole('cell', { name: /Choose Location/ })
.getByText('Parts Bins')
.waitFor();
await page
.getByRole('cell', { name: /Choose Location/ })
.getByText('Room 101')
.waitFor();
await page.getByText('Mechanical Lab').waitFor();
await page.getByRole('button', { name: 'Cancel' }).click();
});

View File

@ -178,53 +178,3 @@ test('Purchase Orders - Barcodes', async ({ page }) => {
await page.waitForTimeout(500); await page.waitForTimeout(500);
await page.getByRole('button', { name: 'Issue Order' }).waitFor(); await page.getByRole('button', { name: 'Issue Order' }).waitFor();
}); });
test('Purchase Orders - General', async ({ page }) => {
await doQuickLogin(page);
await page.getByRole('tab', { name: 'Purchasing' }).click();
await page.getByRole('cell', { name: 'PO0012' }).click();
await page.waitForTimeout(200);
await page.getByRole('tab', { name: 'Line Items' }).click();
await page.getByRole('tab', { name: 'Received Stock' }).click();
await page.getByRole('tab', { name: 'Attachments' }).click();
await page.getByRole('tab', { name: 'Purchasing' }).click();
await page.getByRole('tab', { name: 'Suppliers' }).click();
await page.getByText('Arrow', { exact: true }).click();
await page.waitForTimeout(200);
await page.getByRole('tab', { name: 'Supplied Parts' }).click();
await page.getByRole('tab', { name: 'Purchase Orders' }).click();
await page.getByRole('tab', { name: 'Stock Items' }).click();
await page.getByRole('tab', { name: 'Contacts' }).click();
await page.getByRole('tab', { name: 'Addresses' }).click();
await page.getByRole('tab', { name: 'Attachments' }).click();
await page.getByRole('tab', { name: 'Purchasing' }).click();
await page.getByRole('tab', { name: 'Manufacturers' }).click();
await page.getByText('AVX Corporation').click();
await page.waitForTimeout(200);
await page.getByRole('tab', { name: 'Addresses' }).click();
await page.getByRole('cell', { name: 'West Branch' }).click();
await page.locator('.mantine-ScrollArea-root').click();
await page
.getByRole('row', { name: 'West Branch Yes Surf Avenue 9' })
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
await page.getByLabel('text-field-title').waitFor();
await page.getByLabel('text-field-line2').waitFor();
// Read the current value of the cell, to ensure we always *change* it!
const value = await page.getByLabel('text-field-line2').inputValue();
await page
.getByLabel('text-field-line2')
.fill(value == 'old' ? 'new' : 'old');
await page.getByRole('button', { name: 'Submit' }).isEnabled();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('tab', { name: 'Details' }).waitFor();
});