diff --git a/docs/docs/order/purchase_order.md b/docs/docs/order/purchase_order.md
index 7639ddcb039d..349a145936e9 100644
--- a/docs/docs/order/purchase_order.md
+++ b/docs/docs/order/purchase_order.md
@@ -93,6 +93,14 @@ There are two options to mark items as "received":
!!! note "Permissions"
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
Each item marked as "received" is automatically converted into a stock item.
diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py
index 219c5128dc54..fcd5a4451558 100644
--- a/src/backend/InvenTree/InvenTree/api_version.py
+++ b/src/backend/InvenTree/InvenTree/api_version.py
@@ -1,14 +1,17 @@
"""InvenTree API version information."""
# 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."""
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
- Additional export options for the StockItem serializer
diff --git a/src/backend/InvenTree/order/migrations/0102_purchaseorder_destination_and_more.py b/src/backend/InvenTree/order/migrations/0102_purchaseorder_destination_and_more.py
new file mode 100644
index 000000000000..9a9ae71d72d1
--- /dev/null
+++ b/src/backend/InvenTree/order/migrations/0102_purchaseorder_destination_and_more.py
@@ -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'),
+ ),
+ ]
diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py
index 3876822c0734..3bcdf3003777 100644
--- a/src/backend/InvenTree/order/models.py
+++ b/src/backend/InvenTree/order/models.py
@@ -543,6 +543,16 @@ def company(self):
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
def add_line_item(
self,
@@ -1544,7 +1554,7 @@ def price(self):
related_name='po_lines',
blank=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):
diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py
index ed979ac323ec..ea65d1c9be9a 100644
--- a/src/backend/InvenTree/order/serializers.py
+++ b/src/backend/InvenTree/order/serializers.py
@@ -318,6 +318,7 @@ class Meta:
'supplier_name',
'total_price',
'order_currency',
+ 'destination',
])
read_only_fields = ['issue_date', 'complete_date', 'creation_date']
@@ -860,6 +861,7 @@ class Meta:
location = serializers.PrimaryKeyRelatedField(
queryset=stock.models.StockLocation.objects.all(),
many=False,
+ required=False,
allow_null=True,
label=_('Location'),
help_text=_('Select destination location for received items'),
@@ -873,9 +875,10 @@ def validate(self, data):
"""
super().validate(data)
+ order = self.context['order']
items = data.get('items', [])
- location = data.get('location', None)
+ location = data.get('location', order.destination)
if len(items) == 0:
raise ValidationError(_('Line items must be provided'))
@@ -919,15 +922,17 @@ def save(self):
order = self.context['order']
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
with transaction.atomic():
for item in items:
# Select location (in descending order of priority)
loc = (
- location
- or item.get('location', None)
+ item.get('location', None)
+ or location
or item['line_item'].get_destination()
)
diff --git a/src/backend/InvenTree/order/templates/order/order_base.html b/src/backend/InvenTree/order/templates/order/order_base.html
index 2fb284d913ae..9576abb2c121 100644
--- a/src/backend/InvenTree/order/templates/order/order_base.html
+++ b/src/backend/InvenTree/order/templates/order/order_base.html
@@ -129,7 +129,15 @@
{% endif %}
-
+ {% if order.destination %}
+
+ |
+ {% trans "Destination" %} |
+
+ {{ order.destination.name }}
+ |
+
+ {% endif %}
{% endblock details %}
diff --git a/src/backend/InvenTree/order/templates/order/purchase_order_detail.html b/src/backend/InvenTree/order/templates/order/purchase_order_detail.html
index 78da5925ac7d..97e6829d1deb 100644
--- a/src/backend/InvenTree/order/templates/order/purchase_order_detail.html
+++ b/src/backend/InvenTree/order/templates/order/purchase_order_detail.html
@@ -169,6 +169,9 @@ {% trans "Order Notes" %}
{{ order.id }},
items,
{
+ {% if order.destination %}
+ destination: {{ order.destination.pk }},
+ {% endif %}
success: function() {
$("#po-line-table").bootstrapTable('refresh');
}
diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py
index aba949e7cb44..e82fd015015a 100644
--- a/src/backend/InvenTree/order/test_api.py
+++ b/src/backend/InvenTree/order/test_api.py
@@ -882,7 +882,6 @@ def test_empty(self):
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['location']))
# No new stock items have been created
self.assertEqual(self.n, StockItem.objects.count())
@@ -1060,9 +1059,9 @@ def test_valid(self):
self.assertEqual(stock_1.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_2.last().location.pk, 1)
+ self.assertEqual(stock_2.last().location.pk, 2)
# Barcodes should have been assigned to the stock items
self.assertTrue(
diff --git a/src/backend/InvenTree/plugin/base/barcodes/api.py b/src/backend/InvenTree/plugin/base/barcodes/api.py
index 91b64c633701..13d0baaf83f9 100644
--- a/src/backend/InvenTree/plugin/base/barcodes/api.py
+++ b/src/backend/InvenTree/plugin/base/barcodes/api.py
@@ -500,6 +500,15 @@ def handle_barcode(self, barcode: str, request, **kwargs):
purchase_order = kwargs.get('purchase_order')
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')
# Look for a barcode plugin which knows how to deal with this barcode
diff --git a/src/backend/InvenTree/plugin/test_api.py b/src/backend/InvenTree/plugin/test_api.py
index 3e0c07fc7f4a..2b73bea41675 100644
--- a/src/backend/InvenTree/plugin/test_api.py
+++ b/src/backend/InvenTree/plugin/test_api.py
@@ -35,7 +35,7 @@ def test_plugin_install(self):
'packagename': 'invalid_package_name-asdads-asfd-asdf-asdf-asdf',
},
expected_code=400,
- max_query_time=30,
+ max_query_time=60,
)
# valid - Pypi
diff --git a/src/backend/InvenTree/templates/js/translated/purchase_order.js b/src/backend/InvenTree/templates/js/translated/purchase_order.js
index 3a320e2d5baf..170234b6bbd2 100644
--- a/src/backend/InvenTree/templates/js/translated/purchase_order.js
+++ b/src/backend/InvenTree/templates/js/translated/purchase_order.js
@@ -111,6 +111,9 @@ function purchaseOrderFields(options={}) {
target_date: {
icon: 'fa-calendar-alt',
},
+ destination: {
+ icon: 'fa-sitemap'
+ },
link: {
icon: 'fa-link',
},
@@ -1361,6 +1364,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
method: 'POST',
fields: {
location: {
+ value: options.destination,
filters: {
structural: false,
},
diff --git a/src/frontend/src/forms/PurchaseOrderForms.tsx b/src/frontend/src/forms/PurchaseOrderForms.tsx
index 4564a169329d..e0f6b77c9145 100644
--- a/src/frontend/src/forms/PurchaseOrderForms.tsx
+++ b/src/frontend/src/forms/PurchaseOrderForms.tsx
@@ -166,6 +166,11 @@ export function usePurchaseOrderFields({
target_date: {
icon:
},
+ destination: {
+ filters: {
+ structural: false
+ }
+ },
link: {},
contact: {
icon: ,
@@ -232,6 +237,13 @@ function LineItemFormRow({
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
const batchCodeGenerator = useBatchCodeGenerator((value: any) => {
if (value) {
@@ -239,7 +251,7 @@ function LineItemFormRow({
}
});
- // Serial numbebr generator
+ // Serial number generator
const serialNumberGenerator = useSerialNumberGenerator((value: any) => {
if (value) {
props.changeFn(props.idx, 'serial_numbers', value);
@@ -475,7 +487,7 @@ function LineItemFormRow({
props.changeFn(props.idx, 'location', value);
},
description: locationDescription,
- value: location,
+ value: props.item.location,
label: t`Location`,
icon:
}}
@@ -599,6 +611,7 @@ type LineFormHandlers = {
type LineItemsForm = {
items: any[];
orderPk: number;
+ destinationPk?: number;
formProps?: LineFormHandlers;
};
@@ -674,7 +687,7 @@ export function useReceiveLineItems(props: LineItemsForm) {
title: t`Receive Line Items`,
fields: fields,
initialData: {
- location: null
+ location: props.destinationPk
},
size: '80%'
});
diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx
index c7c8a62b620f..75d1826bac05 100644
--- a/src/frontend/src/pages/build/BuildDetail.tsx
+++ b/src/frontend/src/pages/build/BuildDetail.tsx
@@ -188,6 +188,14 @@ export default function BuildDetail() {
label: t`Completed`,
icon: 'calendar',
hidden: !build.completion_date
+ },
+ {
+ type: 'text',
+ name: 'project_code_label',
+ label: t`Project Code`,
+ icon: 'reference',
+ copy: true,
+ hidden: !build.project_code
}
];
diff --git a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx
index b7631b984662..36054a07483e 100644
--- a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx
+++ b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx
@@ -153,12 +153,19 @@ export default function PurchaseOrderDetail() {
total: order.line_items,
progress: order.completed_lines
},
+ {
+ type: 'link',
+ model: ModelType.stocklocation,
+ link: true,
+ name: 'destination',
+ label: t`Destination`,
+ hidden: !order.destination
+ },
{
type: 'text',
name: 'currency',
label: t`Order Currency`,
- value_formatter: () =>
- order?.order_currency ?? order?.supplier_detail?.currency
+ value_formatter: () => orderCurrency
},
{
type: 'text',
@@ -190,8 +197,15 @@ export default function PurchaseOrderDetail() {
icon: 'user',
copy: true,
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[] = [
@@ -253,7 +267,7 @@ export default function PurchaseOrderDetail() {
);
- }, [order, instanceQuery]);
+ }, [order, orderCurrency, instanceQuery]);
const orderPanels: PanelType[] = useMemo(() => {
return [
diff --git a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx
index 3fd7bf5765bb..4542fde44e03 100644
--- a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx
+++ b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx
@@ -167,8 +167,15 @@ export default function ReturnOrderDetail() {
icon: 'user',
copy: true,
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[] = [
diff --git a/src/frontend/src/pages/sales/SalesOrderDetail.tsx b/src/frontend/src/pages/sales/SalesOrderDetail.tsx
index 7e5f94dc0fae..c49c2ba26333 100644
--- a/src/frontend/src/pages/sales/SalesOrderDetail.tsx
+++ b/src/frontend/src/pages/sales/SalesOrderDetail.tsx
@@ -179,8 +179,15 @@ export default function SalesOrderDetail() {
icon: 'user',
copy: true,
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[] = [
diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx
index 1a9c72368f2f..1a92ba0ec84d 100644
--- a/src/frontend/src/pages/stock/StockDetail.tsx
+++ b/src/frontend/src/pages/stock/StockDetail.tsx
@@ -247,6 +247,15 @@ export default function StockDetail() {
hidden: !stockitem.build,
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',
name: 'sales_order',
diff --git a/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx b/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx
index 60b23a196852..02ffe9797821 100644
--- a/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx
+++ b/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx
@@ -113,6 +113,7 @@ export function PurchaseOrderLineItemTable({
const receiveLineItems = useReceiveLineItems({
items: singleRecord ? [singleRecord] : table.selectedRecords,
orderPk: orderId,
+ destinationPk: order.destination,
formProps: {
// Timeout is a small hack to prevent function being called before re-render
onClose: () => {
diff --git a/src/frontend/tests/pages/pui_purchase_order.spec.ts b/src/frontend/tests/pages/pui_purchase_order.spec.ts
new file mode 100644
index 000000000000..2b94c80ac942
--- /dev/null
+++ b/src/frontend/tests/pages/pui_purchase_order.spec.ts
@@ -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();
+});
diff --git a/src/frontend/tests/pages/pui_orders.spec.ts b/src/frontend/tests/pages/pui_sales_order.spec.ts
similarity index 76%
rename from src/frontend/tests/pages/pui_orders.spec.ts
rename to src/frontend/tests/pages/pui_sales_order.spec.ts
index 167b9b7751ee..f4e80b5103b1 100644
--- a/src/frontend/tests/pages/pui_orders.spec.ts
+++ b/src/frontend/tests/pages/pui_sales_order.spec.ts
@@ -178,53 +178,3 @@ test('Purchase Orders - Barcodes', async ({ page }) => {
await page.waitForTimeout(500);
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();
-});