diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index 5858ced32391..7366303ce502 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -179,9 +179,9 @@ jobs: id: breaking_changes uses: oasdiff/oasdiff-action/diff@main with: - base: 'api.yaml' - revision: 'src/backend/InvenTree/schema.yml' - format: 'html' + base: "api.yaml" + revision: "src/backend/InvenTree/schema.yml" + format: "html" - name: Echoing diff to step run: echo "${{ steps.breaking_changes.outputs.diff }}" >> $GITHUB_STEP_SUMMARY @@ -532,7 +532,7 @@ jobs: run: inv frontend-compile - name: Install Playwright Browsers run: cd src/frontend && npx playwright install --with-deps - - name: Run Playwright tests + - name: Run Non-Destructive Playwright tests id: tests run: cd src/frontend && npx nyc playwright test - uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # pin@v4 @@ -541,6 +541,15 @@ jobs: name: playwright-report path: src/frontend/playwright-report/ retention-days: 14 + - name: Run Destructive Playwright tests + id: tests-destructive + run: cd src/frontend && npx nyc playwright test -c playwright-destructive.config.ts + - uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # pin@v4 + if: ${{ !cancelled() && steps.tests-destructive.outcome == 'failure' }} + with: + name: playwright-report-destructive + path: src/frontend/playwright-report/ + retention-days: 14 - name: Report coverage if: always() run: cd src/frontend && npx nyc report --report-dir ./coverage --temp-dir .nyc_output --reporter=lcov --exclude-after-remap false diff --git a/docs/docs/assets/images/order/po_state_flow.png b/docs/docs/assets/images/order/po_state_flow.png new file mode 100755 index 000000000000..e1f7ac09c503 Binary files /dev/null and b/docs/docs/assets/images/order/po_state_flow.png differ diff --git a/docs/docs/order/purchase_order.md b/docs/docs/order/purchase_order.md index 066f64d058b0..ec2f82558a6f 100644 --- a/docs/docs/order/purchase_order.md +++ b/docs/docs/order/purchase_order.md @@ -19,6 +19,8 @@ Each Purchase Order has a specific status code which indicates the current state | Status | Description | | --- | --- | | Pending | The purchase order has been created, but has not been submitted to the supplier | +| Approval Needed | The purchase order is awaiting approval | +| Ready | The purchase order is ready to issue | | In Progress | The purchase order has been issued to the supplier, and is in progress | | Complete | The purchase order has been completed, and is now closed | | Cancelled | The purchase order was cancelled, and is now closed | @@ -37,6 +39,12 @@ Refer to the source code for the Purchase Order status codes: show_source: True members: [] +### Status flow chart + +Purchase orders support a varied workflow with ability for approvals, limiting issuing permissions, and enabling a ready state. The below chart illustrates the default flow of purchase orders with no plugins in the loop. + +![Status flow](../assets/images/order/po_state_flow.png) + ### Purchase Order Currency The currency code can be specified for an individual purchase order. If not specified, the default currency specified against the [supplier](./company.md#suppliers) will be used. @@ -74,8 +82,56 @@ It is possible to upload an exported purchase order from the supplier instead of !!! info "Supported Formats" This process only supports tabular data and the following formats are supported: CSV, TSV, XLS, XLSX, JSON and YAML +### Approvals + +Purchase orders have the ability to enable an approval process. Enabling approvals activates the "Approval needed" and "Ready" states in the workflow, as illustrated in the [workflow chart](#status-flow-chart). + +Approval settings can be found under System Settings -> Purchase Orders. +|Setting|Description| +|-|-| +|Purchase Order Approvals|This setting enables approvals for purchase orders. This setting will enable the Ready state even if the "Enable Ready Status" setting is not enabled| +|Master approval group|All members of the group name defined here will have permission to approve _all_ purchase orders, regardless or project codes| + +When active, all purchase orders will require approval from a user before being ready to issue. By default, the purchase order's Project Code responsible will be allowed to approve the order. In addition, all members of the "Master approval group" will be able to approve _all_ orders. + +Valid approvers for a given purchase order are given two choices. +- Approve: Transitions the order to "Ready", and marks the user's name as the approver on the order. +- Reject: Transitions the order back to "Pending". An optional reason may be given. This reason will be displayed on the Details panel. + +For anyone else, the Approve option is disabled, and the Reject option is replaced with the [Recall](#recall-order) option + +### Recall Order + +Recalling an order returns it to "Pending". Any order state before "Placed" in the [workflow chart](#status-flow-chart) allows for users to recall the order. + +The only exception is when an order is at the "Approval Needed" stage, and the user is a valid approver for the order. In this case, the Recall option is replaced with Reject. + +### Purchaser role + +For use cases where there are dedicated people that do purchasing, the "Purchaser Group" setting allows limiting issuing purchase orders to members of this defined group. + +- "Purchaser Group" is not defined: All users can hit "Issue order" +- "Purchaser Group" is set to a group called "buying": The "Issue order" button will be disabled for any user not in the group named "buying" + +### Ready state + +This state is enabled by the "Enable Ready Status" setting found in: System Settings -> Purchase Orders. + +This setting inserts the "Ready" state between "Pending" and "Placed" as shown in the [workflow chart](#status-flow-chart). + +!!! info "With approvals" + This setting will be enabled even if turned off if the setting "Purchase Order Approvals" is active + ### Issue Order +The requirements to issue an order varies depending on some global settings. + +|Setting|Description| +|-|-| +|Enable Ready Status|With this setting enabled, an order can only be issued after it has transitioned from "Pending" to "Ready"| +|Purchaser Group|If a group name is defined here, only members of this permission group is permitted to issue orders| +|None|Anyone can issue an order| + Once all the line items were added, click on the button on the main purchase order detail panel and confirm the order has been submitted. ### Receive Line Items diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 1082460451de..7b446cc7d228 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,12 +1,25 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 228 +INVENTREE_API_VERSION = 229 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v229 - 2024-07-27 : https://github.com/inventree/InvenTree/pull/6989 + - Adds new API endpoints for PurchaseOrder state transitions + - Request Approval + - Reject order + - Order Ready to issue + - User Order State permissions + - Recall order to Pending + - Adds fields to PurchaseOrder PUI calls: + - reject_reason + - created_by + - approved_by + - placed_by + v228 - 2024-07-18 : https://github.com/inventree/InvenTree/pull/7684 - Adds "icon" field to the PartCategory.path and StockLocation.path API - Adds icon packages API endpoint diff --git a/src/backend/InvenTree/InvenTree/helpers_model.py b/src/backend/InvenTree/InvenTree/helpers_model.py index bf814dc12a27..f4eb940bd059 100644 --- a/src/backend/InvenTree/InvenTree/helpers_model.py +++ b/src/backend/InvenTree/InvenTree/helpers_model.py @@ -302,6 +302,7 @@ def notify_users( sender, content: NotificationBody = InvenTreeNotificationBodies.NewOrder, exclude=None, + delta=None, ): """Notify all passed users or groups. @@ -335,11 +336,16 @@ def notify_users( if content.template: context['template']['html'] = content.template.format(**content_context) + excluded = exclude + if not isinstance(exclude, list): + excluded = [exclude] + # Create notification trigger_notification( instance, content.slug.format(**content_context), targets=users, - target_exclude=[exclude], + target_exclude=excluded, context=context, + delta=delta, ) diff --git a/src/backend/InvenTree/InvenTree/templatetags/inventree_extras.py b/src/backend/InvenTree/InvenTree/templatetags/inventree_extras.py index ad476ba6711d..f8a6c81f2f9c 100644 --- a/src/backend/InvenTree/InvenTree/templatetags/inventree_extras.py +++ b/src/backend/InvenTree/InvenTree/templatetags/inventree_extras.py @@ -670,3 +670,25 @@ def admin_url(user, table, pk): pass return url + + +@register.simple_tag() +def approval_allowed(user, order): + """Check that the given user is allowed to approve the order.""" + return order.approval_allowed(user) + + +@register.simple_tag() +def can_issue_order(user): + """Check if purchasing is limited to a group. + + Checks if the user is part of the purchaser group + """ + purchaser_group = common.models.InvenTreeSetting.get_setting( + 'PURCHASE_ORDER_PURCHASER_GROUP' + ) + + if purchaser_group: + return user.groups.filter(name=purchaser_group).exists() + else: + return True diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 46e38b03d816..8e3803acd2ad 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -70,6 +70,13 @@ def __class_getitem__(cls, item): return item +def all_groups(): + """Make a choice set of all groups, including an empty string for 'no group'.""" + groups = [(x.name, x.name) for x in Group.objects.all()] + groups.insert(0, ('', '(No group)')) + return groups + + class MetaMixin(models.Model): """A base class for InvenTree models to include shared meta fields. @@ -2150,6 +2157,32 @@ def save(self, *args, **kwargs): 'default': False, 'validator': bool, }, + 'ENABLE_PURCHASE_ORDER_READY_STATUS': { + 'name': _('Enable Ready Status'), + 'description': _( + 'Enable a "Ready" state, indicating an order is ready for issuing. This setting is implicitly active when approvals are active.' + ), + 'default': False, + 'validator': bool, + }, + 'PURCHASE_ORDER_PURCHASER_GROUP': { + 'name': _('Purchaser Group'), + 'description': _( + 'Limit issuing of orders to a purchaser group. If set, orders can only be issued by members of this group.' + ), + 'choices': all_groups, + }, + 'ENABLE_PURCHASE_ORDER_APPROVAL': { + 'name': _('Purchase Order Approvals'), + 'description': _('Add a required approval step to purchase orders'), + 'default': False, + 'validator': bool, + }, + 'PURCHASE_ORDER_APPROVE_ALL_GROUP': { + 'name': _('Master approval group'), + 'description': _('Set a permission group that can approve ALL orders'), + 'choices': all_groups, + }, } typ = 'inventree' @@ -2898,9 +2931,12 @@ class Meta: uid = models.IntegerField() @classmethod - def check_recent(cls, key: str, uid: int, delta: timedelta): + def check_recent(cls, key: str, uid: int, delta: timedelta, use_time: bool = False): """Test if a particular notification has been sent in the specified time period.""" - since = InvenTree.helpers.current_date() - delta + if use_time: + since = InvenTree.helpers.current_time() - delta + else: + since = InvenTree.helpers.current_date() - delta entries = cls.objects.filter(key=key, uid=uid, updated__gte=since) diff --git a/src/backend/InvenTree/common/notifications.py b/src/backend/InvenTree/common/notifications.py index 7e3c4016f1fd..b4bcbdb9aa5c 100644 --- a/src/backend/InvenTree/common/notifications.py +++ b/src/backend/InvenTree/common/notifications.py @@ -340,6 +340,34 @@ class InvenTreeNotificationBodies: template='email/return_order_received.html', ) + PurchaseOrderApprovalRequested = NotificationBody( + name=_('Approval requested'), + slug=_('purchase_order.approval_request'), + message=_('You have been requested to approve a Purchase Order'), + template='email/new_order_assigned.html', + ) + + PurchaseOrderRejected = NotificationBody( + name=_('Purchase order was rejected'), + slug=_('purchase_order.approval_rejected'), + message=_('Your approval request was rejected'), + template='email/new_order_assigned.html', + ) + + PurchaseOrderApproved = NotificationBody( + name=_('Purchase order was approved'), + slug=_('purchase_order.approved'), + message=_('Your approval request was accepted'), + template='email/new_order_assigned.html', + ) + + PurchaseOrderReady = NotificationBody( + name=_('Purchase order is ready to issue'), + slug=_('purchase_order.ready_to_issue'), + message=_('A Purchase order was just marked ready to issue.'), + template='email/new_order_assigned.html', + ) + def trigger_notification(obj, category=None, obj_ref='pk', **kwargs): """Send out a notification.""" @@ -350,6 +378,7 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs): target_exclude = kwargs.get('target_exclude', None) context = kwargs.get('context', {}) delivery_methods = kwargs.get('delivery_methods', None) + override_delta = kwargs.get('delta', None) # Check if data is importing currently if isImportingData(): @@ -369,9 +398,11 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs): ) # Check if we have notified recently... - delta = timedelta(days=1) + delta = override_delta or timedelta(days=1) - if common.models.NotificationEntry.check_recent(category, obj_ref_value, delta): + if common.models.NotificationEntry.check_recent( + category, obj_ref_value, delta, override_delta and True or False + ): logger.info( "Notification '%s' has recently been sent for '%s' - SKIPPING", category, diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index 185043960227..303fa6a72c1c 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -26,7 +26,13 @@ from InvenTree.filters import SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER_ALIAS from InvenTree.helpers import str2bool from InvenTree.helpers_model import construct_absolute_url, get_base_url -from InvenTree.mixins import CreateAPI, ListAPI, ListCreateAPI, RetrieveUpdateDestroyAPI +from InvenTree.mixins import ( + CreateAPI, + ListAPI, + ListCreateAPI, + RetrieveAPI, + RetrieveUpdateDestroyAPI, +) from order import models, serializers from order.status_codes import ( PurchaseOrderStatus, @@ -375,6 +381,36 @@ class PurchaseOrderComplete(PurchaseOrderContextMixin, CreateAPI): serializer_class = serializers.PurchaseOrderCompleteSerializer +class PurchaseOrderRequestApproval(PurchaseOrderContextMixin, CreateAPI): + """API endpoint to request approval a PurchaseOrder.""" + + serializer_class = serializers.PurchaseOrderRequestApprovalSerializer + + +class PurchaseOrderReject(PurchaseOrderContextMixin, CreateAPI): + """API endpoint to reject a PurchaseOrder that was requested approval for.""" + + serializer_class = serializers.PurchaseOrderRejectSerializer + + +class PurchaseOrderReady(PurchaseOrderContextMixin, CreateAPI): + """API endpoint to mark a PurchaseOrder as ready to issue.""" + + serializer_class = serializers.PurchaseOrderReadySerializer + + +class PurchaseOrderStatePermissions(PurchaseOrderContextMixin, RetrieveAPI): + """API endpoint indicating what limited-permission states the user is allowed to perform.""" + + serializer_class = serializers.PurchaseOrderStatePermissionsSerializer + + +class PurchaseOrderRecall(PurchaseOrderContextMixin, CreateAPI): + """API endpoint to recall an open PurchaseOrder to Pending.""" + + serializer_class = serializers.PurchaseOrderRecallSerializer + + class PurchaseOrderIssue(PurchaseOrderContextMixin, CreateAPI): """API endpoint to 'issue' (place) a PurchaseOrder.""" @@ -1476,6 +1512,23 @@ def item_link(self, item): path( '/', include([ + path( + 'request_approval/', + PurchaseOrderRequestApproval.as_view(), + name='api-po-req-approval', + ), + path( + 'reject/', PurchaseOrderReject.as_view(), name='api-po-reject' + ), + path('ready/', PurchaseOrderReady.as_view(), name='api-po-ready'), + path( + 'permissions/', + PurchaseOrderStatePermissions.as_view(), + name='api-po-permissions', + ), + path( + 'recall/', PurchaseOrderRecall.as_view(), name='api-po-recall' + ), path( 'cancel/', PurchaseOrderCancel.as_view(), name='api-po-cancel' ), diff --git a/src/backend/InvenTree/order/fixtures/order.yaml b/src/backend/InvenTree/order/fixtures/order.yaml index abf0958b8530..6f55a08774b7 100644 --- a/src/backend/InvenTree/order/fixtures/order.yaml +++ b/src/backend/InvenTree/order/fixtures/order.yaml @@ -66,6 +66,15 @@ supplier: 2 status: 10 # Pending +- model: order.purchaseorder + pk: 8 + fields: + reference: 'PO-0008' + reference_int: 8 + description: 'PO awaiting approval' + supplier: 2 + status: 70 #In approval + # Add some line items against PO 0001 # 100 x ACME0001 (M2x4 LPHS) diff --git a/src/backend/InvenTree/order/migrations/0101_purchaseorder_approved_by_and_more.py b/src/backend/InvenTree/order/migrations/0101_purchaseorder_approved_by_and_more.py new file mode 100644 index 000000000000..dd12953ca2c7 --- /dev/null +++ b/src/backend/InvenTree/order/migrations/0101_purchaseorder_approved_by_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 4.2.11 on 2024-04-09 22:10 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('order', '0100_remove_returnorderattachment_order_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='purchaseorder', + name='approved_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Approved by'), + ), + migrations.AddField( + model_name='purchaseorder', + name='approved_date', + field=models.DateField(blank=True, help_text='Date order was approved', null=True, verbose_name='Approve Date'), + ), + migrations.AddField( + model_name='purchaseorder', + name='placed_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='purchase_orders_placed', to=settings.AUTH_USER_MODEL, verbose_name='Placed by'), + ), + migrations.AddField( + model_name='purchaseorder', + name='reject_reason', + field=models.CharField(blank=True, help_text='The reason for rejecting this order', max_length=250, verbose_name='Reason for rejection'), + ), + migrations.AlterField( + model_name='purchaseorder', + name='status', + field=models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Placed'), (30, 'Complete'), (40, 'Cancelled'), (50, 'Lost'), (60, 'Returned'), (70, 'Approval needed'), (80, 'Ready')], default=10, help_text='Purchase order status'), + ), + ] diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index cfa17f4d72bc..a27adbe96803 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -3,12 +3,12 @@ import logging import os import sys -from datetime import datetime +from datetime import datetime, timedelta from decimal import Decimal from django.conf import settings -from django.contrib.auth.models import User -from django.core.exceptions import ValidationError +from django.contrib.auth.models import Group, User +from django.core.exceptions import PermissionDenied, ValidationError from django.core.validators import MinValueValidator from django.db import models, transaction from django.db.models import F, Q, Sum @@ -45,7 +45,7 @@ RoundingDecimalField, ) from InvenTree.helpers import decimal2string, pui_url -from InvenTree.helpers_model import notify_responsible +from InvenTree.helpers_model import notify_responsible, notify_users from order.status_codes import ( PurchaseOrderStatus, PurchaseOrderStatusGroups, @@ -527,6 +527,37 @@ def company(self): help_text=_('Date order was completed'), ) + approved_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_('Approved by'), + ) + + approved_date = models.DateField( + blank=True, + null=True, + verbose_name=_('Approve Date'), + help_text=_('Date order was approved'), + ) + + reject_reason = models.CharField( + max_length=250, + blank=True, + verbose_name=_('Reason for rejection'), + help_text=_('The reason for rejecting this order'), + ) + + placed_by = models.ForeignKey( + User, + related_name='purchase_orders_placed', + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_('Placed by'), + ) + @transaction.atomic def add_line_item( self, @@ -603,13 +634,183 @@ def add_line_item( return line # region state changes + + def _action_pending(self, *args, **kwargs): + """Marks the PurchaseOrder as PENDING. + + Order must be IN_APPROVAL or READY + """ + # Raise validation error if the order is in a wrong state + if not self.is_ready and not self.is_pending_approval: + raise ValidationError('Order must be Ready or In approval') + + # If approvals are active, reset all approval data. + if self.requires_approval: + self.approved_by = None + self.approved_date = None + + # If the order comes from an IN_APPROVAL state, the order was rejected. + # Populate fields and notify users of the rejection. + if self.status == PurchaseOrderStatus.IN_APPROVAL.value: + rejected = kwargs.pop('reject_reason', '') + self.reject_reason = rejected + users = [self.created_by] + if self.responsible: + users.append(self.responsible) + notify_users( + users, + self, + PurchaseOrder, + content=InvenTreeNotificationBodies.PurchaseOrderRejected, + delta=timedelta(seconds=60), + ) + trigger_event('purchaseorder.rejected') + + self.status = PurchaseOrderStatus.PENDING.value + self.save() + + trigger_event('purchaseorder.pending') + + def _action_request_approval(self, *args, **kwargs): + """Marks the PurchaseOrder as IN_APPROVAL. + + Order must currently be PENDING + """ + if not self.requires_approval: + raise ValidationError(_('Approvals not active')) + if not self.is_pending: + raise ValidationError(_('Order must be pending to request approval')) + if self.approved_by: + raise ValidationError(_('Cannot request approval of approved order')) + + master_group_name = get_global_setting('PURCHASE_ORDER_APPROVE_ALL_GROUP') + master_users = master_group_name and Group.objects.get(name=master_group_name) + approver = None + user = kwargs.pop('user', None) + + if self.project_code and hasattr(self.project_code.responsible, 'owner'): + approver = [self.project_code.responsible.owner] + + if not approver and not master_users: + extra_info = '' + if self.project_code: + extra_info = _(f'Please add a responsible to {self.project_code} or ') + extra_info += _('set a master approver group in settings.') + + raise ValidationError(_(f'No eligible approvers. {extra_info}')) + else: + self.status = PurchaseOrderStatus.IN_APPROVAL.value + self.save() + + trigger_event('purchaseorder.request_approval') + notify_users( + approver or [master_users], + self, + PurchaseOrder, + content=InvenTreeNotificationBodies.PurchaseOrderApprovalRequested, + delta=timedelta(seconds=60), + exclude=user, + ) + + def _action_approve(self, *args, **kwargs): + """Marks the PurchaseOrder as READY. + + Order must currently be IN_APPROVAL + """ + master_approvers = get_global_setting('PURCHASE_ORDER_APPROVE_ALL_GROUP') + + if not self.requires_approval: + raise ValidationError(_('Approvals are not active')) + if not self.is_pending_approval: + raise ValidationError(_('Order needs to be in "Pending approval" state')) + + if not self.approved_by: + permitted = False + approver = kwargs.pop('user', None) + + if approver.groups.filter(name=master_approvers).exists(): + permitted = True + elif hasattr(self.project_code, 'responsible'): + if self.project_code.responsible.is_user_allowed(approver): + permitted = True + + if permitted: + self.approved_by = approver + self.approved_date = InvenTree.helpers.current_date() + self.reject_reason = '' + self._action_ready() + trigger_event('purchaseorder.approved') + notify_users( + [self.created_by, self.responsible], + self, + PurchaseOrder, + InvenTreeNotificationBodies.PurchaseOrderApproved, + exclude=approver, + delta=timedelta(seconds=60), + ) + else: + raise PermissionDenied() + + else: + self._action_ready() + + def _action_ready(self, *args, **kwargs): + """Marks the PurchaseOrder as READY. + + Order must currently be PENDING or IN_APPROVAL + """ + if not self.requires_approval and not self.can_be_ready: + raise ValidationError(_('Ready state setting not active')) + + if self.is_pending or (self.is_pending_approval and self.approved_by): + self.status = PurchaseOrderStatus.READY.value + self.save() + + user = kwargs.pop('user', None) + + purchaser_group_name = get_global_setting('PURCHASE_ORDER_PURCHASER_GROUP') + if purchaser_group_name: + purchasers_group = Group.objects.filter( + name=purchaser_group_name + ).first() + targets = [purchasers_group, self.created_by] + if self.responsible: + targets.append(self.responsible) + exclude = [] + user and exclude.append(user) + self.approved_by and exclude.extend([ + self.approved_by, + self.created_by, + self.responsible, + ]) + + notify_users( + targets, + self, + PurchaseOrder, + InvenTreeNotificationBodies.PurchaseOrderReady, + delta=timedelta(seconds=60), + exclude=exclude, + ) + def _action_place(self, *args, **kwargs): """Marks the PurchaseOrder as PLACED. Order must be currently PENDING. """ - if self.is_pending: + purchaser_group = get_global_setting('PURCHASE_ORDER_PURCHASER_GROUP') + if self.is_pending or self.is_ready: + user = kwargs.pop('user', None) + if ( + purchaser_group + and not user.groups.filter(name=purchaser_group).exists() + ): + raise PermissionDenied( + _(f'User {user.username} does not have permission to issue orders') + ) + self.status = PurchaseOrderStatus.PLACED.value + self.placed_by = user self.issue_date = InvenTree.helpers.current_date() self.save() @@ -622,6 +823,8 @@ def _action_place(self, *args, **kwargs): exclude=self.created_by, content=InvenTreeNotificationBodies.NewOrder, ) + else: + raise ValidationError(_('Order must be Pending or Ready')) def _action_complete(self, *args, **kwargs): """Marks the PurchaseOrder as COMPLETE. @@ -642,10 +845,58 @@ def _action_complete(self, *args, **kwargs): trigger_event('purchaseorder.completed', id=self.pk) @transaction.atomic - def place_order(self): + def mark_order_pending(self, reason=''): + """Attempt to transition to PENDING status.""" + return self.handle_transition( + self.status, + PurchaseOrderStatus.PENDING.value, + self, + self._action_pending, + reject_reason=reason, + ) + + @transaction.atomic + def request_approval(self, user=None): + """Attempt to transition to IN_APPROVAL status.""" + return self.handle_transition( + self.status, + PurchaseOrderStatus.READY.value, + self, + self._action_request_approval, + user=user, + ) + + @transaction.atomic + def mark_order_ready(self, user): + """Attempt to transition to READY status.""" + if self.requires_approval: + return self.handle_transition( + self.status, + PurchaseOrderStatus.READY, + self, + self._action_approve, + user=user, + ) + elif self.can_be_ready: + return self.handle_transition( + self.status, + PurchaseOrderStatus.READY, + self, + self._action_ready, + user=user, + ) + else: + raise ValidationError('Ready state setting not active') + + @transaction.atomic + def place_order(self, user=None): """Attempt to transition to PLACED status.""" return self.handle_transition( - self.status, PurchaseOrderStatus.PLACED.value, self, self._action_place + self.status, + PurchaseOrderStatus.PLACED.value, + self, + self._action_place, + user=user, ) @transaction.atomic @@ -667,6 +918,16 @@ def is_pending(self): """Return True if the PurchaseOrder is 'pending'.""" return self.status == PurchaseOrderStatus.PENDING.value + @property + def is_pending_approval(self): + """Return True if the PurchaseOrder is awaiting approval.""" + return self.status == PurchaseOrderStatus.IN_APPROVAL.value + + @property + def is_ready(self): + """Return True if the PurchaseOrder is ready to issue.""" + return self.status == PurchaseOrderStatus.READY.value + @property def is_open(self): """Return True if the PurchaseOrder is 'open'.""" @@ -684,6 +945,16 @@ def can_cancel(self): PurchaseOrderStatus.PENDING.value, ] + @property + def requires_approval(self): + """Return state of ENABLE_PURCHASE_ORDER_APPROVAL setting.""" + return get_global_setting('ENABLE_PURCHASE_ORDER_APPROVAL') + + @property + def can_be_ready(self): + """Return state of ENABLE_PURCHASE_ORDER_READY_STATUS setting.""" + return get_global_setting('ENABLE_PURCHASE_ORDER_READY_STATUS') + def _action_cancel(self, *args, **kwargs): """Marks the PurchaseOrder as CANCELLED.""" if self.can_cancel: @@ -700,6 +971,35 @@ def _action_cancel(self, *args, **kwargs): content=InvenTreeNotificationBodies.OrderCanceled, ) + def approval_allowed(self, user): + """Check that the given user is allowed to approve the order.""" + active = get_global_setting('ENABLE_PURCHASE_ORDER_APPROVAL') + master_approvers = get_global_setting('PURCHASE_ORDER_APPROVE_ALL_GROUP') + + if not active: + return False + + user_has_permission = False + + if master_approvers: + user_has_permission = user.groups.filter(name=master_approvers).exists() + + if self.project_code and self.project_code.responsible: + user_has_permission = order.project_code.responsible.is_user_allowed(user) + + return user_has_permission + + def allowed_to_issue(self, user): + """Check that the given user is allowed to issue the order.""" + purchasers = get_global_setting('PURCHASE_ORDER_PURCHASER_GROUP') + + user_has_permission = True + + if purchasers: + user_has_permission = user.groups.filter(name=purchasers).exists() + + return user_has_permission + # endregion def pending_line_items(self): diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index f8ed5c6f39ce..64c45357b29f 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -234,9 +234,20 @@ class Meta: 'supplier_reference', 'total_price', 'order_currency', + 'reject_reason', + 'created_by', + 'approved_by', + 'placed_by', ]) - read_only_fields = ['issue_date', 'complete_date', 'creation_date'] + read_only_fields = [ + 'issue_date', + 'complete_date', + 'creation_date', + 'created_by', + 'approved_by', + 'placed_by', + ] extra_kwargs = { 'supplier': {'required': True}, @@ -344,6 +355,99 @@ def save(self): order.complete_order() +class PurchaseOrderRequestApprovalSerializer(serializers.Serializer): + """Serializer for requesting approval of a purchase order by eligible users.""" + + class Meta: + """Metaclass options.""" + + fields = [] + + def save(self): + """Save the serializer to request approval of the order.""" + order = self.context['order'] + request = self.context['request'] + order.request_approval(request.user) + + +class PurchaseOrderStatePermissionsSerializer(serializers.Serializer): + """Serializer indicating what permissions the user has for limited-permission states.""" + + class Meta: + """Metaclass options.""" + + model = order.models.PurchaseOrder + fields = ['can_approve', 'can_issue'] + + def get_can_approve(self, instance): + """Run model method to check approval permission.""" + request = self.context['request'] + + return instance.approval_allowed(request.user) + + def get_can_issue(self, instance): + """Run model method to check issue permission.""" + request = self.context['request'] + + return instance.allowed_to_issue(request.user) + + can_approve = serializers.SerializerMethodField() + + can_issue = serializers.SerializerMethodField() + + +class PurchaseOrderReadySerializer(serializers.Serializer): + """Serializer to mark a purchase order as ready to issue.""" + + class Meta: + """Metaclass options.""" + + fields = [] + + def save(self): + """Save the serializer mark the order as ready.""" + order = self.context['order'] + request = self.context['request'] + order.mark_order_ready(request.user) + + +class PurchaseOrderRejectSerializer(serializers.Serializer): + """Serializer for rejecting a purchase order.""" + + class Meta: + """Metaclass options.""" + + fields = ['reject_reason'] + + reject_reason = serializers.CharField( + label=_('Reason for rejection'), + help_text=_('Message outlining the reason for rejecting the order'), + default='', + required=False, + allow_blank=True, + ) + + def save(self): + """Save the serializer to 'reject' the order.""" + order = self.context['order'] + reason = self.data['reject_reason'] + order.mark_order_pending(reason) + + +class PurchaseOrderRecallSerializer(serializers.Serializer): + """Serializer for recalling a purchase order.""" + + class Meta: + """Metaclass options.""" + + fields = [] + + def save(self): + """Save the serializer to recall the order, returning it to 'pending'.""" + order = self.context['order'] + order.mark_order_pending() + + class PurchaseOrderIssueSerializer(serializers.Serializer): """Serializer for issuing (sending) a purchase order.""" @@ -355,7 +459,8 @@ class Meta: def save(self): """Save the serializer to 'place' the order.""" order = self.context['order'] - order.place_order() + request = self.context['request'] + order.place_order(request.user) @register_importer() diff --git a/src/backend/InvenTree/order/status_codes.py b/src/backend/InvenTree/order/status_codes.py index cec286dc09a7..ae3b0ab10ce6 100644 --- a/src/backend/InvenTree/order/status_codes.py +++ b/src/backend/InvenTree/order/status_codes.py @@ -15,14 +15,20 @@ class PurchaseOrderStatus(StatusCode): CANCELLED = 40, _('Cancelled'), 'danger' # Order was cancelled LOST = 50, _('Lost'), 'warning' # Order was lost RETURNED = 60, _('Returned'), 'warning' # Order was returned + IN_APPROVAL = 70, _('Approval needed'), 'warning' # Order waiting for approval + READY = 80, _('Ready'), 'primary' # Order is ready to be issued class PurchaseOrderStatusGroups: """Groups for PurchaseOrderStatus codes.""" # Open orders - OPEN = [PurchaseOrderStatus.PENDING.value, PurchaseOrderStatus.PLACED.value] - + OPEN = [ + PurchaseOrderStatus.PENDING.value, + PurchaseOrderStatus.PLACED.value, + PurchaseOrderStatus.IN_APPROVAL.value, + PurchaseOrderStatus.READY.value, + ] # Failed orders FAILED = [ PurchaseOrderStatus.CANCELLED.value, diff --git a/src/backend/InvenTree/order/templates/order/order_base.html b/src/backend/InvenTree/order/templates/order/order_base.html index ce9bc02fadf9..f24e049568cb 100644 --- a/src/backend/InvenTree/order/templates/order/order_base.html +++ b/src/backend/InvenTree/order/templates/order/order_base.html @@ -21,6 +21,8 @@ {% block actions %} {% admin_url user "order.purchaseorder" order.pk as url %} +{% approval_allowed user order as has_approval_permission %} +{% can_issue_order user as is_purchaser %} {% include "admin_button.html" with url=url %} {% if barcodes %} @@ -75,10 +77,38 @@ {% endif %} -{% if order.is_pending %} +{% if order.is_pending and order.requires_approval %} + +{% elif order.is_pending and order.can_be_ready %} + +{% elif order.is_pending %} +{% elif order.is_ready %} + + +{% elif order.is_pending_approval %} +{% if has_approval_permission %} + +{% else %} + +{% endif %} + {% elif order.is_open %} + ); +} + +function RecallButton(props: Readonly) { + const pending = apiUrl(ApiEndpoints.purchase_order_recall, null, { + id: props.orderPk + }); + return ( + + api + .post(pending) + .then(() => props.refresh()) + .catch(() => permissionDenied()) + } + icon="recall" + flipIcon + /> + ); +} + +function PendingTransitions(props: Readonly) { + const settings = useGlobalSettingsState(); + + const approvalsActive = settings.getSetting('ENABLE_PURCHASE_ORDER_APPROVAL'); + const readyStateActive = settings.getSetting( + 'ENABLE_PURCHASE_ORDER_READY_STATUS' + ); + + let color = + typeof props.status === 'object' ? props.status['PLACED'].color : 'default'; + color = colorMap[color]; + let label = t`Issue Order`; + let title = label; + let description = t`By clicking confirm, the order will be marked as issued to the supplier.`; + let icon: InvenTreeIconType = 'issue_order'; + let endpoint = ApiEndpoints.purchase_order_issue; + + if (approvalsActive === 'True') { + label = t`Request Approval`; + title = label; + description = t`Request approval of order`; + icon = 'request_approval'; + endpoint = ApiEndpoints.purchase_order_request_approval; + } else if (readyStateActive === 'True') { + label = t`Ready`; + title = t`Ready to Issue`; + description = t`Mark order as ready for issuance`; + icon = 'ready_order'; + endpoint = ApiEndpoints.purchase_order_ready; + } + + return ( + + api + .post(apiUrl(endpoint, null, { id: props.orderPk })) + .then(() => props.refresh()) + } + /> + ); +} + +function RejectReason({ + setReason +}: Readonly<{ setReason: React.Dispatch> }>) { + const [value, setValue] = useInputState(''); + + useEffect(() => { + setReason(value); + }, [value]); + + return ( + + ); +} + +type RejectModalProps = { + endpoint: ApiEndpoints; +} & TransitionProps; + +function useRejectModal(props: Readonly) { + const [value, setValue] = useState(''); + const [opened, { open, close }] = useDisclosure(false); + + const modal = ( + + + + + + + + ); + + return { + modal, + open, + close + }; +} + +function ApprovalTransitions(props: Readonly) { + const settings = useGlobalSettingsState(); + const approvalsActive = settings.getSetting('ENABLE_PURCHASE_ORDER_APPROVAL'); + + const approveEndpoint = ApiEndpoints.purchase_order_ready; + const rejectEndpoint = ApiEndpoints.purchase_order_reject; + + const user = useUserState(); + const { data } = useSuspenseQuery({ + queryKey: ['po', 'permissions', user.username(), props.orderPk], + queryFn: async () => { + const url = ApiEndpoints.purchase_order_state_permissions; + return api + .get(apiUrl(url, null, { id: props.orderPk })) + .then((result) => { + return result.data; + }); + } + }); + + const reject = useRejectModal({ + endpoint: rejectEndpoint, + ...props + }); + + if (approvalsActive !== 'True') { + return ( + + ); + } + + return ( + <> + {data.can_approve ? ( + + ) : ( + + )} + + api + .post(apiUrl(approveEndpoint, null, { id: props.orderPk })) + .then(() => props.refresh()) + } + disabled={!data.can_approve} + icon="approve_order" + /> + {reject.modal} + + ); +} + +function ReadyTransitions(props: Readonly) { + const issue = apiUrl(ApiEndpoints.purchase_order_issue, null, { + id: props.orderPk + }); + const user = useUserState(); + + const { data } = useSuspenseQuery({ + queryKey: ['po', 'permissions', user.username(), props.orderPk], + queryFn: async () => { + const url = ApiEndpoints.purchase_order_state_permissions; + return api + .get(apiUrl(url, null, { id: props.orderPk })) + .then((result) => { + return result.data; + }); + } + }); + + return ( + <> + + + api + .post(issue) + .then(() => props.refresh()) + .catch(() => permissionDenied()) + } + description={t`By clicking confirm, the order will be marked as issued to the supplier.`} + icon="issue_order" + disabled={!data.can_issue} + /> + + ); +} + +function useCompleteModal(props: Readonly>) { + const [opened, { open, close }] = useDisclosure(false); + const [selected, setSelected] = useState(props.complete); + + const endpoint = ApiEndpoints.purchase_order_complete; + + const modal = ( + + Mark this order as complete. + {props.complete ? ( + + All line items have been received + + ) : ( + <> + + + This order has line items which have not been marked as received. + Completing this order means that the order and line items will no + longer be editable. + + + + Accept incomplete + + setSelected(event.currentTarget.checked)} + label={ + + + Allow order to be closed with incomplete line items + + + } + /> + + )} + + + + + + ); + + return { + modal, + open, + close + }; +} + +function CompleteTransition(props: Readonly>) { + const { modal, open } = useCompleteModal(props); + return ( + <> + + {modal} + + ); +} + +export function OrderStatebuttons(props: Readonly) { + const user = useUserState(); + const hasAdd = user.hasAddRole(UserRoles.purchase_order); + const { data } = useSuspenseQuery({ + queryKey: ['purchase_order', 'statuses'], + queryFn: async () => { + const url = apiUrl(ApiEndpoints.purchase_order_status); + + return api.get(url).then((response) => { + return response.data; + }); + } + }); + + let statusValue; + + for (const elem of Object.values(data.values)) { + if (elem.key === props.status) { + statusValue = elem; + } + } + + return ( + <> + {hasAdd && ( + + {statusValue?.key === 10 && ( + + )} + {statusValue?.key === 20 && ( + + )} + {statusValue?.key === 70 && ( + + )} + {statusValue?.key === 80 && ( + + )} + + )} + + ); +} diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index e6a211eeffa6..64c3efecc173 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -124,6 +124,14 @@ export enum ApiEndpoints { purchase_order_list = 'order/po/', purchase_order_line_list = 'order/po-line/', purchase_order_receive = 'order/po/:id/receive/', + purchase_order_status = 'order/po/status/', + purchase_order_issue = 'order/po/:id/issue/', + purchase_order_recall = 'order/po/:id/recall/', + purchase_order_ready = 'order/po/:id/ready/', + purchase_order_reject = 'order/po/:id/reject/', + purchase_order_request_approval = 'order/po/:id/request_approval/', + purchase_order_state_permissions = 'order/po/:id/permissions/', + purchase_order_complete = 'order/po/:id/complete/', sales_order_list = 'order/so/', sales_order_line_list = 'order/so-line/', diff --git a/src/frontend/src/functions/icons.tsx b/src/frontend/src/functions/icons.tsx index 587a244eb88d..9104f8d810f2 100644 --- a/src/frontend/src/functions/icons.tsx +++ b/src/frontend/src/functions/icons.tsx @@ -1,6 +1,7 @@ import { Icon, Icon123, + IconArrowBackUp, IconArrowBigDownLineFilled, IconArrowMerge, IconBinaryTree2, @@ -20,6 +21,7 @@ import { IconCircleX, IconClipboardList, IconClipboardText, + IconClipboardX, IconCopy, IconCornerDownLeft, IconCornerDownRight, @@ -50,8 +52,10 @@ import { IconPackageImport, IconPackages, IconPaperclip, + IconPencilQuestion, IconPhone, IconPhoto, + IconPlayerSkipForwardFilled, IconPoint, IconPrinter, IconProgressCheck, @@ -59,6 +63,7 @@ import { IconQrcode, IconQuestionMark, IconRulerMeasure, + IconSend, IconShoppingCart, IconShoppingCartHeart, IconShoppingCartPlus, @@ -83,6 +88,8 @@ import { IconVersions, IconWorld, IconWorldCode, + IconWritingSign, + IconWritingSignOff, IconX } from '@tabler/icons-react'; import React from 'react'; @@ -204,7 +211,18 @@ const icons = { destination: IconFlag, repeat_destination: IconFlagShare, unlink: IconUnlink, - success: IconCircleCheck + success: IconCircleCheck, + reject_reason: IconClipboardX, + recall: IconArrowBackUp, + issue_order: IconSend, + reject_order: IconWritingSignOff, + approve_order: IconWritingSign, + ready_order: IconPlayerSkipForwardFilled, + request_approval: IconPencilQuestion, + created_by: IconUser, + approved_by: IconUser, + placed_by: IconUser, + complete: IconCircleCheck }; export type InvenTreeIconType = keyof typeof icons; diff --git a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx index 0db6114592a8..2ca3f934d5d1 100644 --- a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx +++ b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx @@ -259,7 +259,11 @@ export default function SystemSettings() { 'PURCHASEORDER_REFERENCE_PATTERN', 'PURCHASEORDER_REQUIRE_RESPONSIBLE', 'PURCHASEORDER_EDIT_COMPLETED_ORDERS', - 'PURCHASEORDER_AUTO_COMPLETE' + 'PURCHASEORDER_AUTO_COMPLETE', + 'ENABLE_PURCHASE_ORDER_APPROVAL', + 'PURCHASE_ORDER_APPROVE_ALL_GROUP', + 'ENABLE_PURCHASE_ORDER_READY_STATUS', + 'PURCHASE_ORDER_PURCHASER_GROUP' ]} /> ) diff --git a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx index 34b2e23c4e50..db7e79a21f0c 100644 --- a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx +++ b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx @@ -12,6 +12,7 @@ import { ReactNode, useMemo } from 'react'; import { useParams } from 'react-router-dom'; import AdminButton from '../../components/buttons/AdminButton'; +import { OrderStatebuttons } from '../../components/buttons/OrderStateTransition'; import { PrintingActions } from '../../components/buttons/PrintingActions'; import { DetailsField, DetailsTable } from '../../components/details/Details'; import { DetailsImage } from '../../components/details/DetailsImage'; @@ -130,6 +131,12 @@ export default function PurchaseOrderDetail() { name: 'status', label: t`Status`, model: ModelType.purchaseorder + }, + { + type: 'text', + name: 'reject_reason', + label: t`Reason for rejection`, + hidden: !order.reject_reason } ]; @@ -190,6 +197,26 @@ export default function PurchaseOrderDetail() { label: t`Created On`, icon: 'calendar' }, + { + type: 'text', + name: 'created_by', + label: t`Created by`, + badge: 'user' + }, + { + type: 'text', + name: 'approved_by', + label: t`Approved by`, + badge: 'user', + hidden: !order.approved_by + }, + { + type: 'text', + name: 'placed_by', + label: t`Issued by`, + badge: 'user', + hidden: !order.placed_by + }, { type: 'text', name: 'target_date', @@ -289,7 +316,7 @@ export default function PurchaseOrderDetail() { const poActions = useMemo(() => { return [ - , + , , , duplicatePurchaseOrder.open() }) ]} + key={3} + />, + ]; }, [id, order, user]); diff --git a/src/frontend/tests/destructive/pui_purchasing.spec.ts b/src/frontend/tests/destructive/pui_purchasing.spec.ts new file mode 100644 index 000000000000..d48de5a31fa6 --- /dev/null +++ b/src/frontend/tests/destructive/pui_purchasing.spec.ts @@ -0,0 +1,305 @@ +import { Page } from '@playwright/test'; + +import { expect, test } from '../baseFixtures'; +import { baseUrl } from '../defaults'; +import { doLogin, doQuickLogin } from '../login'; + +// CSRF token +let TOKEN: string; +let ORDER_ID: number; + +// These tests modify settings, and have to run sequentially +// Each test will reset settings with its afterEach hook +test.describe.configure({ mode: 'serial' }); + +async function setSettingAPI( + page: Page, + key: string, + value: string | boolean | null +) { + const settingResponse = await page.request.patch( + `http://localhost:8000/api/settings/global/${key}/`, + { + data: { + value: value + }, + headers: { + 'x-csrftoken': TOKEN + } + } + ); + expect(settingResponse.ok()).toBeTruthy(); + return; +} + +async function updateToken(page: Page) { + const cookies = await page.context().cookies(); + + for (const cook of cookies) { + if (cook.name === 'csrftoken') { + TOKEN = cook.value; + break; + } + } +} + +async function toggleReady(page: Page, disable = false) { + await setSettingAPI(page, 'ENABLE_PURCHASE_ORDER_READY_STATUS', !disable); +} + +async function toggleApprovals(page: Page, disable = false) { + await setSettingAPI(page, 'ENABLE_PURCHASE_ORDER_APPROVAL', !disable); +} + +async function setApprover(page: Page, group = 'all access') { + await setSettingAPI(page, 'PURCHASE_ORDER_APPROVE_ALL_GROUP', group); +} + +async function setPurchaser(page: Page, group = 'all access') { + await setSettingAPI(page, 'PURCHASE_ORDER_PURCHASER_GROUP', group); +} + +async function disableAllSettings(page: Page) { + const settings: { key: string; state: boolean | string }[] = [ + { + key: 'ENABLE_PURCHASE_ORDER_APPROVAL', + state: false + }, + { + key: 'ENABLE_PURCHASE_ORDER_READY_STATUS', + state: false + }, + { + key: 'PURCHASE_ORDER_APPROVE_ALL_GROUP', + state: '' + }, + { + key: 'PURCHASE_ORDER_PURCHASER_GROUP', + state: '' + } + ]; + for (const set of settings) { + await setSettingAPI(page, set.key, set.state); + } +} + +/** + * Creating a new Purchase Order for each test + * Orders cannot roll back state after being issued. + * To prevent problems, every test uses its own PO. + */ +test.beforeEach(async ({ page }: { page: Page }) => { + await doQuickLogin(page, 'admin', 'inventree'); + + const cookies = await page.context().cookies(); + + for (const cookie of cookies) { + if (cookie.name === 'csrftoken') { + TOKEN = cookie.value; + break; + } + } + + const order = await page.request.post('http://localhost:8000/api/order/po/', { + data: { + supplier: 1 + }, + headers: { + 'x-csrftoken': TOKEN + } + }); + expect(order.ok()).toBeTruthy(); + const pk = (await order.json()).pk; + ORDER_ID = pk; + + await page.goto(`${baseUrl}/purchasing/purchase-order/${pk}/`, { + waitUntil: 'domcontentloaded' + }); + await page.getByText(RegExp(/^Purchase Order: PO\d{4}$/)).waitFor(); +}); + +/** + * Log in as admin and disable all settings that were changed. + */ +test.afterEach(async ({ page }: { page: Page }) => { + await updateToken(page); + await disableAllSettings(page); +}); + +test('PUI - Pages - Purchasing - Pending transitions', async ({ + page +}: { + page: Page; +}) => { + // Get URL of PO generated before test + const url = page.url(); + + // All state altering settings disabled + await expect(page.getByRole('button', { name: 'Issue Order' })).toBeEnabled(); + await page.getByRole('button', { name: 'Issue Order' }).click(); + await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled(); + + // Enable Ready Setting + await toggleReady(page); + + // Check that Ready Button is present + await page.reload(); + await page.getByText(RegExp(/^Purchase Order: PO\d{4}$/)).waitFor(); + + await expect(page.getByRole('button', { name: 'Ready' })).toBeEnabled(); + await page.getByRole('button', { name: 'Ready' }).click(); + await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled(); + + // Enable Approvals (Approvals come before Ready, so Ready is not disabled) + await toggleApprovals(page); + + await page.reload(); + await page.getByText(RegExp(/^Purchase Order: PO\d{4}$/)).waitFor(); + + await expect( + page.getByRole('button', { name: 'Request Approval' }) + ).toBeEnabled(); + await page.getByRole('button', { name: 'Request Approval' }).click(); + await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled(); +}); + +test('PUI - Pages - Purchasing - In Approval transitions', async ({ + page +}: { + page: Page; +}) => { + const url = page.url(); + + // Activate Approvals and set valid approver to not be the current user + await toggleApprovals(page); + await setApprover(page, 'readers'); + + // Perform state transition through API + const response = await page.request.post( + `http://localhost:8000/api/order/po/${ORDER_ID}/request_approval/`, + { + headers: { + 'x-csrftoken': TOKEN + } + } + ); + expect(response.ok()).toBeTruthy(); + + await page.reload(); + await page.getByText(RegExp(/^Purchase Order: PO\d{4}$/)).waitFor(); + + await expect(page.getByRole('button', { name: 'Approve' })).toBeDisabled(); + await expect(page.getByRole('button', { name: 'Recall' })).toBeEnabled(); + await page.getByRole('button', { name: 'Recall' }).click(); + + const modal = page.getByRole('dialog'); + await modal.waitFor(); + await modal.getByText('Recall Purchase Order').isVisible(); + await modal.getByRole('button', { name: 'Submit' }).isEnabled(); + + // Change approver group to one that user is member of + await setApprover(page); + + await doLogin(page); + + // Check buttons when the user is permitted to approve + await page.goto(url); + + await page.getByText(RegExp(/^Purchase Order: PO\d{4}$/)).waitFor(); + await expect(page.getByRole('button', { name: 'Approve' })).toBeEnabled(); + await page.getByRole('button', { name: 'Approve' }).click(); + await modal.waitFor(); + await expect(modal.getByText('Approve Purchase Order')).toBeVisible(); + await expect(modal.getByRole('button', { name: 'Submit' })).toBeEnabled(); + await modal.getByRole('button', { name: 'Cancel' }).click(); + await expect(page.getByRole('button', { name: 'Reject' })).toBeEnabled(); + await page.getByRole('button', { name: 'Reject' }).click(); + await modal.waitFor(); + await expect(modal.getByLabel('Reject Reason')).toBeVisible(); + await doLogin(page, 'admin', 'inventree'); + await updateToken(page); +}); + +test('PUI - Pages - Purchasing - Ready Transitions', async ({ + page +}: { + page: Page; +}) => { + // Get URL of PO generated before test + const url = page.url(); + + // Standard modal locator + const modal = page.getByRole('dialog'); + + // Activate Ready state + await toggleReady(page); + + // Perform state transition through API + const response = await page.request.post( + `http://localhost:8000/api/order/po/${ORDER_ID}/ready/`, + { + headers: { + 'x-csrftoken': TOKEN + } + } + ); + expect(response.ok()).toBeTruthy(); + + await page.reload(); + await page.getByText(RegExp(/^Purchase Order: PO\d{4}$/)).waitFor(); + + await expect(page.getByRole('button', { name: 'Issue Order' })).toBeEnabled(); + await expect(page.getByRole('button', { name: 'Recall' })).toBeEnabled(); + await page.getByRole('button', { name: 'Recall' }).click(); + + await modal.waitFor(); + await expect(modal.getByText('Recall Purchase Order')).toBeVisible(); + await expect(modal.getByRole('button', { name: 'Submit' })).toBeEnabled(); + await modal.getByRole('button', { name: 'Cancel' }).click(); + + await page.getByRole('button', { name: 'Issue Order' }).click(); + + await modal.waitFor(); + await expect(modal.getByText('Issue Order')).toBeVisible(); + await expect(modal.getByRole('button', { name: 'Submit' })).toBeEnabled(); + + await setPurchaser(page, 'readers'); + await toggleReady(page); + + await page.reload(); + await page.getByText(RegExp(/^Purchase Order: PO\d{4}$/)).waitFor(); + await expect( + page.getByRole('button', { name: 'Issue Order' }) + ).toBeDisabled(); + await expect(page.getByRole('button', { name: 'Recall' })).toBeEnabled(); +}); + +test('PUI - Pages - Purchasing - Placed Transitions', async ({ + page +}: { + page: Page; +}) => { + const url = page.url(); + + const modal = page.getByRole('dialog'); + + // Perform state transition through API + const response = await page.request.post( + `http://localhost:8000/api/order/po/${ORDER_ID}/issue/`, + { + headers: { + 'x-csrftoken': TOKEN + } + } + ); + expect(response.ok()).toBeTruthy(); + + await page.reload(); + await page.getByRole('button', { name: 'Complete' }).waitFor(); + await page.getByRole('button', { name: 'Complete' }).click(); + await expect(modal.getByText('Complete Purchase Order')).toBeVisible(); + await expect( + modal.getByText('All line items have been received') + ).toBeVisible(); + await modal.getByRole('button', { name: 'Cancel' }).click(); +}); diff --git a/src/frontend/tests/pui_basic.spec.ts b/src/frontend/tests/pui_basic.spec.ts index 38534fbb923b..924db9c0fe2f 100644 --- a/src/frontend/tests/pui_basic.spec.ts +++ b/src/frontend/tests/pui_basic.spec.ts @@ -6,7 +6,7 @@ test('PUI - Basic Login Test', async ({ page }) => { await doLogin(page); // Check that the username is provided - await page.getByText(user.username); + await page.getByText(user.username).isVisible(); await expect(page).toHaveTitle(RegExp('^InvenTree')); @@ -19,7 +19,7 @@ test('PUI - Basic Login Test', async ({ page }) => { .click(); // Check that the username is provided - await page.getByText(user.username); + await page.getByText(user.username).isVisible(); await expect(page).toHaveTitle(RegExp('^InvenTree')); @@ -32,14 +32,14 @@ test('PUI - Basic Login Test', async ({ page }) => { await page.getByRole('menuitem', { name: 'Logout' }).click(); await page.waitForURL('**/platform/login'); - await page.getByLabel('username'); + await page.getByLabel('username').isVisible(); }); test('PUI - Quick Login Test', async ({ page }) => { await doQuickLogin(page); // Check that the username is provided - await page.getByText(user.username); + await page.getByText(user.username).isVisible(); await expect(page).toHaveTitle(RegExp('^InvenTree')); @@ -54,5 +54,5 @@ test('PUI - Quick Login Test', async ({ page }) => { // Logout (via URL) await page.goto(`${baseUrl}/logout/`); await page.waitForURL('**/platform/login'); - await page.getByLabel('username'); + await page.getByLabel('username').isVisible(); });