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(); -});