mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-11-03 22:55:43 +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:
		@@ -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.
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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'),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
@@ -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):
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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()
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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 %}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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');
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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(
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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,
 | 
				
			||||||
                },
 | 
					                },
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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%'
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    ];
 | 
					    ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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 [
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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[] = [
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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[] = [
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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',
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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: () => {
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										85
									
								
								src/frontend/tests/pages/pui_purchase_order.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								src/frontend/tests/pages/pui_purchase_order.spec.ts
									
									
									
									
									
										Normal 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();
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
@@ -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();
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
		Reference in New Issue
	
	Block a user