mirror of
https://github.com/inventree/InvenTree.git
synced 2025-05-09 16:58:49 +00:00
[Feature] Build allocation export (#7611)
* CUI: Add "allocated stock" panel to build order page * Implement CUI table for build order allocations * Add "bulk delete" option for build order allocations * Add row actions * Add extra fields for data export * Add build allocation table in PUI * Add 'batch' column * Bump API version * Add playwright tests * Fix missing renderer * Update build docs * Update playwright tests * Update playwright tests
This commit is contained in:
parent
4e6879407e
commit
6650f3e90c
BIN
docs/docs/assets/images/build/allocated_stock_table.png
Normal file
BIN
docs/docs/assets/images/build/allocated_stock_table.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 68 KiB |
21
docs/docs/build/build.md
vendored
21
docs/docs/build/build.md
vendored
@ -26,14 +26,6 @@ To navigate to the Build Order display, select *Build* from the main navigation
|
|||||||
{% include "img.html" %}
|
{% include "img.html" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
#### Tree View
|
|
||||||
|
|
||||||
*Tree View* also provides a tabulated view of Build Orders. Orders are displayed in a hierarchical manner, showing any parent / child relationships between different build orders.
|
|
||||||
|
|
||||||
{% with id="build_tree", url="build/build_tree.png", description="Build Tree" %}
|
|
||||||
{% include "img.html" %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
#### Calendar View
|
#### Calendar View
|
||||||
|
|
||||||
*Calendar View* shows a calendar display with upcoming build orders, based on the various dates specified for each build.
|
*Calendar View* shows a calendar display with upcoming build orders, based on the various dates specified for each build.
|
||||||
@ -121,9 +113,9 @@ The *Build Details* tab provides an overview of the Build Order:
|
|||||||
{% include "img.html" %}
|
{% include "img.html" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
### Allocate Stock
|
### Line Items
|
||||||
|
|
||||||
The *Allocate Stock* tab provides an interface to allocate required stock (as specified by the BOM) to the build:
|
The *Line Items* tab provides an interface to allocate required stock (as specified by the BOM) to the build:
|
||||||
|
|
||||||
{% with id="build_allocate", url="build/build_allocate.png", description="Allocation tab" %}
|
{% with id="build_allocate", url="build/build_allocate.png", description="Allocation tab" %}
|
||||||
{% include "img.html" %}
|
{% include "img.html" %}
|
||||||
@ -131,8 +123,13 @@ The *Allocate Stock* tab provides an interface to allocate required stock (as sp
|
|||||||
|
|
||||||
The allocation table (as shown above) shows the stock allocation progress for this build. In the example above, there are two BOM lines, which have been partially allocated.
|
The allocation table (as shown above) shows the stock allocation progress for this build. In the example above, there are two BOM lines, which have been partially allocated.
|
||||||
|
|
||||||
!!! info "Completed Builds"
|
### Allocated Stock
|
||||||
The *Allocate Stock* tab is not available if the build has been completed!
|
|
||||||
|
The *Allocated Stock* tab displays all stock items which have been *allocated* to this build order. These stock items are reserved for this build, and will be consumed when the build is completed:
|
||||||
|
|
||||||
|
{% with id="allocated_stock_table", url="build/allocated_stock_table.png", description="Allocated Stock Table" %}
|
||||||
|
{% include "img.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
### Consumed Stock
|
### Consumed Stock
|
||||||
|
|
||||||
|
@ -1,12 +1,16 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 218
|
INVENTREE_API_VERSION = 219
|
||||||
|
|
||||||
"""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 = """
|
||||||
|
v219 - 2024-07-11 : https://github.com/inventree/InvenTree/pull/7611
|
||||||
|
- Adds new fields to the BuildItem API endpoints
|
||||||
|
- Adds new ordering / filtering options to the BuildItem API endpoints
|
||||||
|
|
||||||
v218 - 2024-07-11 : https://github.com/inventree/InvenTree/pull/7619
|
v218 - 2024-07-11 : https://github.com/inventree/InvenTree/pull/7619
|
||||||
- Adds "can_build" field to the BomItem API
|
- Adds "can_build" field to the BomItem API
|
||||||
|
|
||||||
|
@ -8,12 +8,11 @@ from django.contrib.auth.models import User
|
|||||||
|
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
|
||||||
from django_filters import rest_framework as rest_filters
|
from django_filters import rest_framework as rest_filters
|
||||||
|
|
||||||
from importer.mixins import DataExportViewMixin
|
from importer.mixins import DataExportViewMixin
|
||||||
|
|
||||||
from InvenTree.api import MetadataView
|
from InvenTree.api import BulkDeleteMixin, MetadataView
|
||||||
from generic.states.api import StatusView
|
from generic.states.api import StatusView
|
||||||
from InvenTree.helpers import str2bool, isNull
|
from InvenTree.helpers import str2bool, isNull
|
||||||
from build.status_codes import BuildStatus, BuildStatusGroups
|
from build.status_codes import BuildStatus, BuildStatusGroups
|
||||||
@ -546,15 +545,17 @@ class BuildItemFilter(rest_filters.FilterSet):
|
|||||||
return queryset.filter(install_into=None)
|
return queryset.filter(install_into=None)
|
||||||
|
|
||||||
|
|
||||||
class BuildItemList(DataExportViewMixin, ListCreateAPI):
|
class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI):
|
||||||
"""API endpoint for accessing a list of BuildItem objects.
|
"""API endpoint for accessing a list of BuildItem objects.
|
||||||
|
|
||||||
- GET: Return list of objects
|
- GET: Return list of objects
|
||||||
- POST: Create a new BuildItem object
|
- POST: Create a new BuildItem object
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
queryset = BuildItem.objects.all()
|
||||||
serializer_class = build.serializers.BuildItemSerializer
|
serializer_class = build.serializers.BuildItemSerializer
|
||||||
filterset_class = BuildItemFilter
|
filterset_class = BuildItemFilter
|
||||||
|
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||||
|
|
||||||
def get_serializer(self, *args, **kwargs):
|
def get_serializer(self, *args, **kwargs):
|
||||||
"""Returns a BuildItemSerializer instance based on the request."""
|
"""Returns a BuildItemSerializer instance based on the request."""
|
||||||
@ -571,7 +572,7 @@ class BuildItemList(DataExportViewMixin, ListCreateAPI):
|
|||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Override the queryset method, to allow filtering by stock_item.part."""
|
"""Override the queryset method, to allow filtering by stock_item.part."""
|
||||||
queryset = BuildItem.objects.all()
|
queryset = super().get_queryset()
|
||||||
|
|
||||||
queryset = queryset.select_related(
|
queryset = queryset.select_related(
|
||||||
'build_line',
|
'build_line',
|
||||||
@ -607,8 +608,25 @@ class BuildItemList(DataExportViewMixin, ListCreateAPI):
|
|||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
filter_backends = [
|
ordering_fields = [
|
||||||
DjangoFilterBackend,
|
'part',
|
||||||
|
'sku',
|
||||||
|
'quantity',
|
||||||
|
'location',
|
||||||
|
'reference',
|
||||||
|
]
|
||||||
|
|
||||||
|
ordering_field_aliases = {
|
||||||
|
'part': 'stock_item__part__name',
|
||||||
|
'sku': 'stock_item__supplier_part__SKU',
|
||||||
|
'location': 'stock_item__location__name',
|
||||||
|
'reference': 'build_line__bom_item__reference',
|
||||||
|
}
|
||||||
|
|
||||||
|
search_fields = [
|
||||||
|
'stock_item__supplier_part__SKU',
|
||||||
|
'stock_item__part__name',
|
||||||
|
'build_line__bom_item__reference',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -26,8 +26,9 @@ from stock.serializers import StockItemSerializerBrief, LocationSerializer
|
|||||||
import common.models
|
import common.models
|
||||||
from common.serializers import ProjectCodeSerializer
|
from common.serializers import ProjectCodeSerializer
|
||||||
from importer.mixins import DataImportExportSerializerMixin
|
from importer.mixins import DataImportExportSerializerMixin
|
||||||
|
import company.serializers
|
||||||
import part.filters
|
import part.filters
|
||||||
from part.serializers import BomItemSerializer, PartSerializer, PartBriefSerializer
|
import part.serializers as part_serializers
|
||||||
from users.serializers import OwnerSerializer
|
from users.serializers import OwnerSerializer
|
||||||
|
|
||||||
from .models import Build, BuildLine, BuildItem
|
from .models import Build, BuildLine, BuildItem
|
||||||
@ -85,7 +86,7 @@ class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTre
|
|||||||
|
|
||||||
status_text = serializers.CharField(source='get_status_display', read_only=True)
|
status_text = serializers.CharField(source='get_status_display', read_only=True)
|
||||||
|
|
||||||
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
part_detail = part_serializers.PartBriefSerializer(source='part', many=False, read_only=True)
|
||||||
|
|
||||||
part_name = serializers.CharField(source='part.name', read_only=True, label=_('Part Name'))
|
part_name = serializers.CharField(source='part.name', read_only=True, label=_('Part Name'))
|
||||||
|
|
||||||
@ -1062,10 +1063,13 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
|||||||
# These fields are only used for data export
|
# These fields are only used for data export
|
||||||
export_only_fields = [
|
export_only_fields = [
|
||||||
'build_reference',
|
'build_reference',
|
||||||
'bom_reference',
|
|
||||||
'sku',
|
'sku',
|
||||||
'mpn',
|
'mpn',
|
||||||
'location_name',
|
'location_name',
|
||||||
|
'part_id',
|
||||||
|
'part_name',
|
||||||
|
'part_ipn',
|
||||||
|
'available_quantity',
|
||||||
]
|
]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -1085,6 +1089,7 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
|||||||
'location_detail',
|
'location_detail',
|
||||||
'part_detail',
|
'part_detail',
|
||||||
'stock_item_detail',
|
'stock_item_detail',
|
||||||
|
'supplier_part_detail',
|
||||||
|
|
||||||
# The following fields are only used for data export
|
# The following fields are only used for data export
|
||||||
'bom_reference',
|
'bom_reference',
|
||||||
@ -1092,27 +1097,12 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
|||||||
'location_name',
|
'location_name',
|
||||||
'mpn',
|
'mpn',
|
||||||
'sku',
|
'sku',
|
||||||
|
'part_id',
|
||||||
|
'part_name',
|
||||||
|
'part_ipn',
|
||||||
|
'available_quantity',
|
||||||
]
|
]
|
||||||
|
|
||||||
# Export-only fields
|
|
||||||
sku = serializers.CharField(source='stock_item.supplier_part.SKU', label=_('Supplier Part Number'), read_only=True)
|
|
||||||
mpn = serializers.CharField(source='stock_item.supplier_part.manufacturer_part.MPN', label=_('Manufacturer Part Number'), read_only=True)
|
|
||||||
location_name = serializers.CharField(source='stock_item.location.name', label=_('Location Name'), read_only=True)
|
|
||||||
build_reference = serializers.CharField(source='build.reference', label=_('Build Reference'), read_only=True)
|
|
||||||
bom_reference = serializers.CharField(source='build_line.bom_item.reference', label=_('BOM Reference'), read_only=True)
|
|
||||||
|
|
||||||
# Annotated fields
|
|
||||||
build = serializers.PrimaryKeyRelatedField(source='build_line.build', many=False, read_only=True)
|
|
||||||
|
|
||||||
# Extra (optional) detail fields
|
|
||||||
part_detail = PartBriefSerializer(source='stock_item.part', many=False, read_only=True, pricing=False)
|
|
||||||
stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True)
|
|
||||||
location = serializers.PrimaryKeyRelatedField(source='stock_item.location', many=False, read_only=True)
|
|
||||||
location_detail = LocationSerializer(source='stock_item.location', read_only=True)
|
|
||||||
build_detail = BuildSerializer(source='build_line.build', many=False, read_only=True)
|
|
||||||
|
|
||||||
quantity = InvenTreeDecimalField()
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
"""Determine which extra details fields should be included"""
|
"""Determine which extra details fields should be included"""
|
||||||
part_detail = kwargs.pop('part_detail', True)
|
part_detail = kwargs.pop('part_detail', True)
|
||||||
@ -1134,6 +1124,32 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
|||||||
if not build_detail:
|
if not build_detail:
|
||||||
self.fields.pop('build_detail', None)
|
self.fields.pop('build_detail', None)
|
||||||
|
|
||||||
|
# Export-only fields
|
||||||
|
sku = serializers.CharField(source='stock_item.supplier_part.SKU', label=_('Supplier Part Number'), read_only=True)
|
||||||
|
mpn = serializers.CharField(source='stock_item.supplier_part.manufacturer_part.MPN', label=_('Manufacturer Part Number'), read_only=True)
|
||||||
|
location_name = serializers.CharField(source='stock_item.location.name', label=_('Location Name'), read_only=True)
|
||||||
|
build_reference = serializers.CharField(source='build.reference', label=_('Build Reference'), read_only=True)
|
||||||
|
bom_reference = serializers.CharField(source='build_line.bom_item.reference', label=_('BOM Reference'), read_only=True)
|
||||||
|
|
||||||
|
# Part detail fields
|
||||||
|
part_id = serializers.PrimaryKeyRelatedField(source='stock_item.part', label=_('Part ID'), many=False, read_only=True)
|
||||||
|
part_name = serializers.CharField(source='stock_item.part.name', label=_('Part Name'), read_only=True)
|
||||||
|
part_ipn = serializers.CharField(source='stock_item.part.IPN', label=_('Part IPN'), read_only=True)
|
||||||
|
|
||||||
|
# Annotated fields
|
||||||
|
build = serializers.PrimaryKeyRelatedField(source='build_line.build', many=False, read_only=True)
|
||||||
|
|
||||||
|
# Extra (optional) detail fields
|
||||||
|
part_detail = part_serializers.PartBriefSerializer(source='stock_item.part', many=False, read_only=True, pricing=False)
|
||||||
|
stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True)
|
||||||
|
location = serializers.PrimaryKeyRelatedField(source='stock_item.location', many=False, read_only=True)
|
||||||
|
location_detail = LocationSerializer(source='stock_item.location', read_only=True)
|
||||||
|
build_detail = BuildSerializer(source='build_line.build', many=False, read_only=True)
|
||||||
|
supplier_part_detail = company.serializers.SupplierPartSerializer(source='stock_item.supplier_part', many=False, read_only=True)
|
||||||
|
|
||||||
|
quantity = InvenTreeDecimalField(label=_('Allocated Quantity'))
|
||||||
|
available_quantity = InvenTreeDecimalField(source='stock_item.quantity', read_only=True, label=_('Available Quantity'))
|
||||||
|
|
||||||
|
|
||||||
class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
|
class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
|
||||||
"""Serializer for a BuildItem object."""
|
"""Serializer for a BuildItem object."""
|
||||||
@ -1217,8 +1233,8 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
|||||||
bom_item = serializers.PrimaryKeyRelatedField(label=_('BOM Item'), read_only=True)
|
bom_item = serializers.PrimaryKeyRelatedField(label=_('BOM Item'), read_only=True)
|
||||||
|
|
||||||
# Foreign key fields
|
# Foreign key fields
|
||||||
bom_item_detail = BomItemSerializer(source='bom_item', many=False, read_only=True, pricing=False)
|
bom_item_detail = part_serializers.BomItemSerializer(source='bom_item', many=False, read_only=True, pricing=False)
|
||||||
part_detail = PartSerializer(source='bom_item.sub_part', many=False, read_only=True, pricing=False)
|
part_detail = part_serializers.PartSerializer(source='bom_item.sub_part', many=False, read_only=True, pricing=False)
|
||||||
allocations = BuildItemSerializer(many=True, read_only=True)
|
allocations = BuildItemSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
# Annotated (calculated) fields
|
# Annotated (calculated) fields
|
||||||
|
@ -174,7 +174,7 @@
|
|||||||
<div class='panel panel-hidden' id='panel-allocate'>
|
<div class='panel panel-hidden' id='panel-allocate'>
|
||||||
<div class='panel-heading'>
|
<div class='panel-heading'>
|
||||||
<div class='d-flex flex-wrap'>
|
<div class='d-flex flex-wrap'>
|
||||||
<h4>{% trans "Allocate Stock to Build" %}</h4>
|
<h4>{% trans "Build Order Line Items" %}</h4>
|
||||||
{% include "spacer.html" %}
|
{% include "spacer.html" %}
|
||||||
<div class='btn-group' role='group'>
|
<div class='btn-group' role='group'>
|
||||||
{% if roles.build.add and build.active %}
|
{% if roles.build.add and build.active %}
|
||||||
@ -231,6 +231,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class='panel panel-hidden' id='panel-allocated'>
|
||||||
|
<div class='panel-heading'>
|
||||||
|
<h4>{% trans "Allocated Stock" %}</h4>
|
||||||
|
</div>
|
||||||
|
<div class='panel-content'>
|
||||||
|
<div id='build-allocated-stock-toolbar'>
|
||||||
|
{% include "filter_list.html" with id='buildorderallocatedstock' %}
|
||||||
|
</div>
|
||||||
|
<table class='table table-striped table-condensed' id='allocated-stock-table' data-toolbar='#build-allocated-stock-toolbar'></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class='panel panel-hidden' id='panel-consumed'>
|
<div class='panel panel-hidden' id='panel-consumed'>
|
||||||
<div class='panel-heading'>
|
<div class='panel-heading'>
|
||||||
<h4>
|
<h4>
|
||||||
@ -290,6 +302,10 @@
|
|||||||
{% block js_ready %}
|
{% block js_ready %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
||||||
|
onPanelLoad('allocated', function() {
|
||||||
|
loadBuildOrderAllocatedStockTable($('#allocated-stock-table'), {{ build.pk }});
|
||||||
|
});
|
||||||
|
|
||||||
onPanelLoad('consumed', function() {
|
onPanelLoad('consumed', function() {
|
||||||
loadStockTable($('#consumed-stock-table'), {
|
loadStockTable($('#consumed-stock-table'), {
|
||||||
filterTarget: '#filter-list-consumed-stock',
|
filterTarget: '#filter-list-consumed-stock',
|
||||||
|
@ -5,15 +5,19 @@
|
|||||||
{% trans "Build Order Details" as text %}
|
{% trans "Build Order Details" as text %}
|
||||||
{% include "sidebar_item.html" with label='details' text=text icon="fa-info-circle" %}
|
{% include "sidebar_item.html" with label='details' text=text icon="fa-info-circle" %}
|
||||||
{% if build.is_active %}
|
{% if build.is_active %}
|
||||||
{% trans "Allocate Stock" as text %}
|
{% trans "Line Items" as text %}
|
||||||
{% include "sidebar_item.html" with label='allocate' text=text icon="fa-tasks" %}
|
{% include "sidebar_item.html" with label='allocate' text=text icon="fa-list-ol" %}
|
||||||
{% trans "Incomplete Outputs" as text %}
|
{% trans "Incomplete Outputs" as text %}
|
||||||
{% include "sidebar_item.html" with label='outputs' text=text icon="fa-tools" %}
|
{% include "sidebar_item.html" with label='outputs' text=text icon="fa-tools" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% trans "Completed Outputs" as text %}
|
{% trans "Completed Outputs" as text %}
|
||||||
{% include "sidebar_item.html" with label='completed' text=text icon="fa-boxes" %}
|
{% include "sidebar_item.html" with label='completed' text=text icon="fa-boxes" %}
|
||||||
|
{% if build.is_active %}
|
||||||
|
{% trans "Allocated Stock" as text %}
|
||||||
|
{% include "sidebar_item.html" with label='allocated' text=text icon="fa-list" %}
|
||||||
|
{% endif %}
|
||||||
{% trans "Consumed Stock" as text %}
|
{% trans "Consumed Stock" as text %}
|
||||||
{% include "sidebar_item.html" with label='consumed' text=text icon="fa-list" %}
|
{% include "sidebar_item.html" with label='consumed' text=text icon="fa-tasks" %}
|
||||||
{% trans "Child Build Orders" as text %}
|
{% trans "Child Build Orders" as text %}
|
||||||
{% include "sidebar_item.html" with label='children' text=text icon="fa-sitemap" %}
|
{% include "sidebar_item.html" with label='children' text=text icon="fa-sitemap" %}
|
||||||
{% trans "Attachments" as text %}
|
{% trans "Attachments" as text %}
|
||||||
|
@ -58,6 +58,7 @@
|
|||||||
duplicateBuildOrder,
|
duplicateBuildOrder,
|
||||||
editBuildOrder,
|
editBuildOrder,
|
||||||
loadBuildLineTable,
|
loadBuildLineTable,
|
||||||
|
loadBuildOrderAllocatedStockTable,
|
||||||
loadBuildOrderAllocationTable,
|
loadBuildOrderAllocationTable,
|
||||||
loadBuildOutputTable,
|
loadBuildOutputTable,
|
||||||
loadBuildTable,
|
loadBuildTable,
|
||||||
@ -933,6 +934,180 @@ function deleteBuildOutputs(build_id, outputs, options={}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a table showing all stock allocated to a given Build Order
|
||||||
|
*/
|
||||||
|
function loadBuildOrderAllocatedStockTable(table, buildId) {
|
||||||
|
|
||||||
|
let params = {
|
||||||
|
build: buildId,
|
||||||
|
part_detail: true,
|
||||||
|
location_detail: true,
|
||||||
|
stock_detail: true,
|
||||||
|
supplier_detail: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let filters = loadTableFilters('buildorderallocatedstock', params);
|
||||||
|
setupFilterList(
|
||||||
|
'buildorderallocatedstock',
|
||||||
|
$(table),
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
download: true,
|
||||||
|
custom_actions: [{
|
||||||
|
label: 'actions',
|
||||||
|
actions: [{
|
||||||
|
label: 'delete',
|
||||||
|
title: '{% trans "Delete allocations" %}',
|
||||||
|
icon: 'fa-trash-alt icon-red',
|
||||||
|
permission: 'build.delete',
|
||||||
|
callback: function(data) {
|
||||||
|
constructForm('{% url "api-build-item-list" %}', {
|
||||||
|
method: 'DELETE',
|
||||||
|
multi_delete: true,
|
||||||
|
title: '{% trans "Delete Stock Allocations" %}',
|
||||||
|
form_data: {
|
||||||
|
items: data.map(item => item.pk),
|
||||||
|
},
|
||||||
|
onSuccess: function() {
|
||||||
|
$(table).bootstrapTable('refresh');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
$(table).inventreeTable({
|
||||||
|
url: '{% url "api-build-item-list" %}',
|
||||||
|
queryParams: filters,
|
||||||
|
original: params,
|
||||||
|
sortable: true,
|
||||||
|
search: true,
|
||||||
|
groupBy: false,
|
||||||
|
sidePagination: 'server',
|
||||||
|
formatNoMatches: function() {
|
||||||
|
return '{% trans "No allocated stock" %}';
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
visible: true,
|
||||||
|
checkbox: true,
|
||||||
|
switchable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'part',
|
||||||
|
sortable: true,
|
||||||
|
switchable: false,
|
||||||
|
title: '{% trans "Part" %}',
|
||||||
|
formatter: function(value, row) {
|
||||||
|
return imageHoverIcon(row.part_detail.thumbnail) + renderLink(row.part_detail.full_name, `/part/${row.part_detail.pk}/`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'bom_reference',
|
||||||
|
sortable: true,
|
||||||
|
switchable: true,
|
||||||
|
title: '{% trans "Reference" %}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'quantity',
|
||||||
|
sortable: true,
|
||||||
|
switchable: false,
|
||||||
|
title: '{% trans "Allocated Quantity" %}',
|
||||||
|
formatter: function(value, row) {
|
||||||
|
let stock_item = row.stock_item_detail;
|
||||||
|
let text = value;
|
||||||
|
|
||||||
|
if (stock_item.serial && stock_item.quantity == 1) {
|
||||||
|
text = `# ${stock_item.serial}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderLink(text, `/stock/item/${stock_item.pk}/`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'location',
|
||||||
|
sortable: true,
|
||||||
|
title: '{% trans "Location" %}',
|
||||||
|
formatter: function(value, row) {
|
||||||
|
if (row.location_detail) {
|
||||||
|
return locationDetail(row, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'install_into',
|
||||||
|
sortable: true,
|
||||||
|
title: '{% trans "Build Output" %}',
|
||||||
|
formatter: function(value, row) {
|
||||||
|
if (value) {
|
||||||
|
return renderLink(`{% trans "Stock item" %}: ${value}`, `/stock/item/${value}/`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'sku',
|
||||||
|
sortable: true,
|
||||||
|
title: '{% trans "Supplier Part" %}',
|
||||||
|
formatter: function(value, row) {
|
||||||
|
if (row.supplier_part_detail) {
|
||||||
|
let text = row.supplier_part_detail.SKU;
|
||||||
|
|
||||||
|
return renderLink(text, `/supplier-part/${row.supplier_part_detail.pk}/`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'pk',
|
||||||
|
title: '{% trans "Actions" %}',
|
||||||
|
visible: true,
|
||||||
|
switchable: false,
|
||||||
|
sortable: false,
|
||||||
|
formatter: function(value, row) {
|
||||||
|
let buttons = '';
|
||||||
|
|
||||||
|
buttons += makeEditButton('build-item-edit', row.pk, '{% trans "Edit build allocation" %}');
|
||||||
|
buttons += makeDeleteButton('build-item-delete', row.pk, '{% trans "Delete build allocation" %}');
|
||||||
|
|
||||||
|
return wrapButtons(buttons);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add row callbacks
|
||||||
|
$(table).on('click', '.build-item-edit', function() {
|
||||||
|
let pk = $(this).attr('pk');
|
||||||
|
|
||||||
|
constructForm(
|
||||||
|
`/api/build/item/${pk}/`,
|
||||||
|
{
|
||||||
|
fields: {
|
||||||
|
quantity: {},
|
||||||
|
},
|
||||||
|
title: '{% trans "Edit Build Allocation" %}',
|
||||||
|
refreshTable: table
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
$(table).on('click', '.build-item-delete', function() {
|
||||||
|
let pk = $(this).attr('pk');
|
||||||
|
|
||||||
|
constructForm(
|
||||||
|
`/api/build/item/${pk}/`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
title: '{% trans "Delete Build Allocation" %}',
|
||||||
|
refreshTable: table,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load a table showing all the BuildOrder allocations for a given part
|
* Load a table showing all the BuildOrder allocations for a given part
|
||||||
*/
|
*/
|
||||||
|
@ -46,3 +46,9 @@ export function RenderBuildLine({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RenderBuildItem({
|
||||||
|
instance
|
||||||
|
}: Readonly<InstanceRenderInterface>): ReactNode {
|
||||||
|
return <RenderInlineModel primary={instance.pk} />;
|
||||||
|
}
|
||||||
|
@ -8,7 +8,7 @@ import { ModelType } from '../../enums/ModelType';
|
|||||||
import { navigateToLink } from '../../functions/navigation';
|
import { navigateToLink } from '../../functions/navigation';
|
||||||
import { apiUrl } from '../../states/ApiState';
|
import { apiUrl } from '../../states/ApiState';
|
||||||
import { Thumbnail } from '../images/Thumbnail';
|
import { Thumbnail } from '../images/Thumbnail';
|
||||||
import { RenderBuildLine, RenderBuildOrder } from './Build';
|
import { RenderBuildItem, RenderBuildLine, RenderBuildOrder } from './Build';
|
||||||
import {
|
import {
|
||||||
RenderAddress,
|
RenderAddress,
|
||||||
RenderCompany,
|
RenderCompany,
|
||||||
@ -59,6 +59,7 @@ const RendererLookup: EnumDictionary<
|
|||||||
[ModelType.address]: RenderAddress,
|
[ModelType.address]: RenderAddress,
|
||||||
[ModelType.build]: RenderBuildOrder,
|
[ModelType.build]: RenderBuildOrder,
|
||||||
[ModelType.buildline]: RenderBuildLine,
|
[ModelType.buildline]: RenderBuildLine,
|
||||||
|
[ModelType.builditem]: RenderBuildItem,
|
||||||
[ModelType.company]: RenderCompany,
|
[ModelType.company]: RenderCompany,
|
||||||
[ModelType.contact]: RenderContact,
|
[ModelType.contact]: RenderContact,
|
||||||
[ModelType.manufacturerpart]: RenderManufacturerPart,
|
[ModelType.manufacturerpart]: RenderManufacturerPart,
|
||||||
|
@ -113,6 +113,11 @@ export const ModelInformationDict: ModelDict = {
|
|||||||
cui_detail: '/build/line/:pk/',
|
cui_detail: '/build/line/:pk/',
|
||||||
api_endpoint: ApiEndpoints.build_line_list
|
api_endpoint: ApiEndpoints.build_line_list
|
||||||
},
|
},
|
||||||
|
builditem: {
|
||||||
|
label: t`Build Item`,
|
||||||
|
label_multiple: t`Build Items`,
|
||||||
|
api_endpoint: ApiEndpoints.build_item_list
|
||||||
|
},
|
||||||
company: {
|
company: {
|
||||||
label: t`Company`,
|
label: t`Company`,
|
||||||
label_multiple: t`Companies`,
|
label_multiple: t`Companies`,
|
||||||
|
@ -65,6 +65,7 @@ export enum ApiEndpoints {
|
|||||||
build_output_scrap = 'build/:id/scrap-outputs/',
|
build_output_scrap = 'build/:id/scrap-outputs/',
|
||||||
build_output_delete = 'build/:id/delete-outputs/',
|
build_output_delete = 'build/:id/delete-outputs/',
|
||||||
build_line_list = 'build/line/',
|
build_line_list = 'build/line/',
|
||||||
|
build_item_list = 'build/item/',
|
||||||
|
|
||||||
bom_list = 'bom/',
|
bom_list = 'bom/',
|
||||||
bom_item_validate = 'bom/:id/validate/',
|
bom_item_validate = 'bom/:id/validate/',
|
||||||
|
@ -15,6 +15,7 @@ export enum ModelType {
|
|||||||
stockhistory = 'stockhistory',
|
stockhistory = 'stockhistory',
|
||||||
build = 'build',
|
build = 'build',
|
||||||
buildline = 'buildline',
|
buildline = 'buildline',
|
||||||
|
builditem = 'builditem',
|
||||||
company = 'company',
|
company = 'company',
|
||||||
purchaseorder = 'purchaseorder',
|
purchaseorder = 'purchaseorder',
|
||||||
purchaseorderline = 'purchaseorderline',
|
purchaseorderline = 'purchaseorderline',
|
||||||
|
@ -7,6 +7,7 @@ import {
|
|||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
IconList,
|
IconList,
|
||||||
IconListCheck,
|
IconListCheck,
|
||||||
|
IconListNumbers,
|
||||||
IconNotes,
|
IconNotes,
|
||||||
IconPaperclip,
|
IconPaperclip,
|
||||||
IconQrcode,
|
IconQrcode,
|
||||||
@ -45,6 +46,7 @@ import {
|
|||||||
import { useInstance } from '../../hooks/UseInstance';
|
import { useInstance } from '../../hooks/UseInstance';
|
||||||
import { apiUrl } from '../../states/ApiState';
|
import { apiUrl } from '../../states/ApiState';
|
||||||
import { useUserState } from '../../states/UserState';
|
import { useUserState } from '../../states/UserState';
|
||||||
|
import BuildAllocatedStockTable from '../../tables/build/BuildAllocatedStockTable';
|
||||||
import BuildLineTable from '../../tables/build/BuildLineTable';
|
import BuildLineTable from '../../tables/build/BuildLineTable';
|
||||||
import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
|
import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
|
||||||
import BuildOutputTable from '../../tables/build/BuildOutputTable';
|
import BuildOutputTable from '../../tables/build/BuildOutputTable';
|
||||||
@ -233,9 +235,9 @@ export default function BuildDetail() {
|
|||||||
content: detailsPanel
|
content: detailsPanel
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'allocate-stock',
|
name: 'line-items',
|
||||||
label: t`Allocate Stock`,
|
label: t`Line Items`,
|
||||||
icon: <IconListCheck />,
|
icon: <IconListNumbers />,
|
||||||
content: build?.pk ? (
|
content: build?.pk ? (
|
||||||
<BuildLineTable
|
<BuildLineTable
|
||||||
params={{
|
params={{
|
||||||
@ -268,10 +270,20 @@ export default function BuildDetail() {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'allocated-stock',
|
||||||
|
label: t`Allocated Stock`,
|
||||||
|
icon: <IconList />,
|
||||||
|
content: build.pk ? (
|
||||||
|
<BuildAllocatedStockTable buildId={build.pk} />
|
||||||
|
) : (
|
||||||
|
<Skeleton />
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'consumed-stock',
|
name: 'consumed-stock',
|
||||||
label: t`Consumed Stock`,
|
label: t`Consumed Stock`,
|
||||||
icon: <IconList />,
|
icon: <IconListCheck />,
|
||||||
content: (
|
content: (
|
||||||
<StockItemTable
|
<StockItemTable
|
||||||
allowAdd={false}
|
allowAdd={false}
|
||||||
|
158
src/frontend/src/tables/build/BuildAllocatedStockTable.tsx
Normal file
158
src/frontend/src/tables/build/BuildAllocatedStockTable.tsx
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||||
|
import { UserRoles } from '../../enums/Roles';
|
||||||
|
import {
|
||||||
|
useDeleteApiFormModal,
|
||||||
|
useEditApiFormModal
|
||||||
|
} from '../../hooks/UseForm';
|
||||||
|
import { useTable } from '../../hooks/UseTable';
|
||||||
|
import { apiUrl } from '../../states/ApiState';
|
||||||
|
import { useUserState } from '../../states/UserState';
|
||||||
|
import { TableColumn } from '../Column';
|
||||||
|
import { LocationColumn, PartColumn } from '../ColumnRenderers';
|
||||||
|
import { TableFilter } from '../Filter';
|
||||||
|
import { InvenTreeTable } from '../InvenTreeTable';
|
||||||
|
import { RowDeleteAction, RowEditAction } from '../RowActions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a table of allocated stock for a build.
|
||||||
|
*/
|
||||||
|
export default function BuildAllocatedStockTable({
|
||||||
|
buildId
|
||||||
|
}: {
|
||||||
|
buildId: number;
|
||||||
|
}) {
|
||||||
|
const user = useUserState();
|
||||||
|
const table = useTable('build-allocated-stock');
|
||||||
|
|
||||||
|
const tableFilters: TableFilter[] = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'tracked',
|
||||||
|
label: t`Allocated to Output`,
|
||||||
|
description: t`Show items allocated to a build output`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const tableColumns: TableColumn[] = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
accessor: 'part',
|
||||||
|
title: t`Part`,
|
||||||
|
sortable: true,
|
||||||
|
switchable: false,
|
||||||
|
render: (record: any) => PartColumn(record.part_detail)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessor: 'bom_reference',
|
||||||
|
title: t`Reference`,
|
||||||
|
sortable: true,
|
||||||
|
switchable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessor: 'quantity',
|
||||||
|
title: t`Allocated Quantity`,
|
||||||
|
sortable: true,
|
||||||
|
switchable: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessor: 'batch',
|
||||||
|
title: t`Batch Code`,
|
||||||
|
sortable: false,
|
||||||
|
switchable: true,
|
||||||
|
render: (record: any) => record?.stock_item_detail?.batch
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessor: 'available',
|
||||||
|
title: t`Available Quantity`,
|
||||||
|
render: (record: any) => record?.stock_item_detail?.quantity
|
||||||
|
},
|
||||||
|
LocationColumn({
|
||||||
|
accessor: 'location_detail',
|
||||||
|
switchable: true,
|
||||||
|
sortable: true
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
accessor: 'install_into',
|
||||||
|
title: t`Build Output`,
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessor: 'sku',
|
||||||
|
title: t`Supplier Part`,
|
||||||
|
render: (record: any) => record?.supplier_part_detail?.SKU,
|
||||||
|
sortable: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [selectedItem, setSelectedItem] = useState<number>(0);
|
||||||
|
|
||||||
|
const editItem = useEditApiFormModal({
|
||||||
|
pk: selectedItem,
|
||||||
|
url: ApiEndpoints.build_item_list,
|
||||||
|
title: t`Edit Build Item`,
|
||||||
|
fields: {
|
||||||
|
quantity: {}
|
||||||
|
},
|
||||||
|
table: table
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteItem = useDeleteApiFormModal({
|
||||||
|
pk: selectedItem,
|
||||||
|
url: ApiEndpoints.build_item_list,
|
||||||
|
title: t`Delete Build Item`,
|
||||||
|
table: table
|
||||||
|
});
|
||||||
|
|
||||||
|
const rowActions = useCallback(
|
||||||
|
(record: any) => {
|
||||||
|
return [
|
||||||
|
RowEditAction({
|
||||||
|
hidden: !user.hasChangeRole(UserRoles.build),
|
||||||
|
onClick: () => {
|
||||||
|
setSelectedItem(record.pk);
|
||||||
|
editItem.open();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
RowDeleteAction({
|
||||||
|
hidden: !user.hasDeleteRole(UserRoles.build),
|
||||||
|
onClick: () => {
|
||||||
|
setSelectedItem(record.pk);
|
||||||
|
deleteItem.open();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
];
|
||||||
|
},
|
||||||
|
[user]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{editItem.modal}
|
||||||
|
{deleteItem.modal}
|
||||||
|
<InvenTreeTable
|
||||||
|
tableState={table}
|
||||||
|
url={apiUrl(ApiEndpoints.build_item_list)}
|
||||||
|
columns={tableColumns}
|
||||||
|
props={{
|
||||||
|
params: {
|
||||||
|
build: buildId,
|
||||||
|
part_detail: true,
|
||||||
|
location_detail: true,
|
||||||
|
stock_detail: true,
|
||||||
|
supplier_detail: true
|
||||||
|
},
|
||||||
|
enableBulkDelete: true,
|
||||||
|
enableDownload: true,
|
||||||
|
enableSelection: true,
|
||||||
|
rowActions: rowActions,
|
||||||
|
tableFilters: tableFilters
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
34
src/frontend/tests/pages/pui_build.spec.ts
Normal file
34
src/frontend/tests/pages/pui_build.spec.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { test } from '../baseFixtures.ts';
|
||||||
|
import { baseUrl } from '../defaults.ts';
|
||||||
|
import { doQuickLogin } from '../login.ts';
|
||||||
|
|
||||||
|
test('PUI - Pages - Build Order', async ({ page }) => {
|
||||||
|
await doQuickLogin(page);
|
||||||
|
|
||||||
|
await page.goto(`${baseUrl}/part/`);
|
||||||
|
|
||||||
|
// Navigate to the correct build order
|
||||||
|
await page.getByRole('tab', { name: 'Build', exact: true }).click();
|
||||||
|
await page.getByRole('cell', { name: 'BO0011' }).click();
|
||||||
|
|
||||||
|
// Click on some tabs
|
||||||
|
await page.getByRole('tab', { name: 'Attachments' }).click();
|
||||||
|
await page.getByRole('tab', { name: 'Notes' }).click();
|
||||||
|
await page.getByRole('tab', { name: 'Incomplete Outputs' }).click();
|
||||||
|
await page.getByRole('tab', { name: 'Line Items' }).click();
|
||||||
|
await page.getByRole('tab', { name: 'Allocated Stock' }).click();
|
||||||
|
|
||||||
|
// Check for expected text in the table
|
||||||
|
await page.getByText('R_10R_0402_1%').click();
|
||||||
|
await page
|
||||||
|
.getByRole('cell', { name: 'R38, R39, R40, R41, R42, R43' })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// Click through to the "parent" build
|
||||||
|
await page.getByRole('tab', { name: 'Build Details' }).click();
|
||||||
|
await page.getByRole('link', { name: 'BO0010' }).click();
|
||||||
|
await page
|
||||||
|
.getByLabel('Build Details')
|
||||||
|
.getByText('Making a high level assembly')
|
||||||
|
.waitFor();
|
||||||
|
});
|
@ -116,8 +116,6 @@ test('PUI - Pages - Part - Pricing (Variant)', async ({ page }) => {
|
|||||||
|
|
||||||
// Variant Pricing
|
// Variant Pricing
|
||||||
await page.getByRole('button', { name: 'Variant Pricing' }).click();
|
await page.getByRole('button', { name: 'Variant Pricing' }).click();
|
||||||
await page.waitForTimeout(500);
|
|
||||||
await page.getByRole('button', { name: 'Variant Part Not sorted' }).click();
|
|
||||||
|
|
||||||
// Variant Pricing - linkjumping
|
// Variant Pricing - linkjumping
|
||||||
let target = page.getByText('Green Chair').first();
|
let target = page.getByText('Green Chair').first();
|
||||||
|
@ -32,20 +32,6 @@ test('PUI - Stock', async ({ page }) => {
|
|||||||
await page.getByRole('tab', { name: 'Installed Items' }).click();
|
await page.getByRole('tab', { name: 'Installed Items' }).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('PUI - Build', async ({ page }) => {
|
|
||||||
await doQuickLogin(page);
|
|
||||||
|
|
||||||
await page.getByRole('tab', { name: 'Build' }).click();
|
|
||||||
await page.getByText('Widget Assembly Variant').click();
|
|
||||||
await page.getByRole('tab', { name: 'Allocate Stock' }).click();
|
|
||||||
await page.getByRole('tab', { name: 'Incomplete Outputs' }).click();
|
|
||||||
await page.getByRole('tab', { name: 'Completed Outputs' }).click();
|
|
||||||
await page.getByRole('tab', { name: 'Consumed Stock' }).click();
|
|
||||||
await page.getByRole('tab', { name: 'Child Build Orders' }).click();
|
|
||||||
await page.getByRole('tab', { name: 'Attachments' }).click();
|
|
||||||
await page.getByRole('tab', { name: 'Notes' }).click();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('PUI - Purchasing', async ({ page }) => {
|
test('PUI - Purchasing', async ({ page }) => {
|
||||||
await doQuickLogin(page);
|
await doQuickLogin(page);
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user