[feature] Break apart assemblies (#12310)
* Implement API endpoint for stock disassembly * Add basic frontend implementation * Adjust required user permission * Add preFormContent * Read-only if serialized * Adjust location and status of each subcomponent * Handle null value * display installed items in frontend form * More unit tests * Traceability * Add docs * Exclude virtual / consumable stock * Additional docs * Added bundled items docs * More docs * more docs tweaks * Updated part docs * Add playwright tests * Robustify test * suppress certain warnings in schema * Adjust playwright tests * bug fix * Tweak playwright tests
This commit is contained in:
parent
35eb839f08
commit
4e00e2d0c6
|
|
@ -23,3 +23,9 @@ Read more about build orders in the [Build Order documentation](./build.md).
|
|||
InvenTree allows users to allocate stock items to specific build orders, ensuring that the required components are reserved for production. This helps to prevent stock shortages and ensures that the right parts are available when needed.
|
||||
|
||||
Read more about stock allocation in the [Stock Allocation documentation](./allocate.md).
|
||||
|
||||
### Disassembly
|
||||
|
||||
The reverse process is also supported - an assembled stock item can be broken back down into its component parts, based on its BOM. This is useful for reworking or scrapping an assembly, or for splitting a bundled "kit" product purchased from a supplier into its individual components.
|
||||
|
||||
Read more about this process in the [Stock Disassembly documentation](../stock/disassemble.md).
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ A *Template* part is one which can have *variants* which exist underneath it. [R
|
|||
|
||||
If a part is designated as an *Assembly* it can be created (or built) from other component parts. As an example, a circuit board assembly is made using multiple electronic components, which are tracked in the system. An *Assembly* Part has a Bill of Materials (BOM) which lists all the required sub-components. [Read further information about BOM management here](../manufacturing/bom.md).
|
||||
|
||||
An assembled stock item can also be broken back down into its component parts, using the [disassembly](../stock/disassemble.md) process.
|
||||
|
||||
### Component
|
||||
|
||||
If a part is designated as a *Component* it can be used as a sub-component of an *Assembly*. [Read further information about BOM management here](../manufacturing/bom.md)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ title: Part Notifications
|
|||
Users can select to receive notifications when certain events occur.
|
||||
|
||||
!!! warning "Email Configuration Required"
|
||||
External notifications require correct [email configuration](../start/config.md#email-settings). They also need to be enabled in the settings under notifications`.
|
||||
External notifications require correct [email configuration](../start/config.md#email-settings). They also need to be enabled in the settings under *Notifications*.
|
||||
|
||||
!!! warning "Valid Email Address"
|
||||
Each user must have a valid email address associated with their account to receive email notifications
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ Each price range is calculated in the [Default Currency](../concepts/pricing.md#
|
|||
Price range data is [cached in the database](#price-data-caching) when underlying pricing information changes.
|
||||
|
||||
!!! tip "Refresh Pricing"
|
||||
While pricing data is [automatically updated](#data-updates), the user can also manually refresh the pricing calculations manually, by pressing the "Refresh" button in the overview section.
|
||||
While pricing data is [automatically updated](#pricing-updates), the user can also manually refresh the pricing calculations manually, by pressing the "Refresh" button in the overview section.
|
||||
|
||||
#### Overall Pricing
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ If this tab is not visible, ensure that the *Enable Stock History* [user setting
|
|||
|
||||
### Stocktake Entry Generation
|
||||
|
||||
By default, stocktake entries are generated automatically at regular intervals (see [settings](#stock-history-settings) below). However, users can generate a stocktake entry on demand, using the *Generate Stocktake Entry* button in the *Stock History* tab:
|
||||
By default, stocktake entries are generated automatically at regular intervals (see [settings](#stocktake-settings) below). However, users can generate a stocktake entry on demand, using the *Generate Stocktake Entry* button in the *Stock History* tab:
|
||||
|
||||
{{ image("part/part_stocktake_manual.png", "Generate stocktake entry") }}
|
||||
|
||||
|
|
@ -95,7 +95,7 @@ Enable or disable stocktake functionality. Note that by default, stocktake funct
|
|||
|
||||
### Automatic Stocktake Period
|
||||
|
||||
Configure the number of days between generation of [automatic stocktake reports](#automatic-stocktake). If this value is set to zero, automatic stocktake reports will not be generated.
|
||||
Configure the number of days between generation of [automatic stocktake entries](#stocktake-entry-generation). If this value is set to zero, automatic stocktake entries will not be generated.
|
||||
|
||||
### Delete Old Stocktake Entries
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ The Part detail view page provides a detailed view of a single part in the syste
|
|||
|
||||
### Category Breadcrumb List
|
||||
|
||||
The categories of each part is displayed on the top navigation bar as show in the above screenshot.
|
||||
The categories of each part is displayed on the top navigation bar.
|
||||
[Click here](./index.md#part-category) for more information about categories.
|
||||
|
||||
## Part Details
|
||||
|
|
@ -83,6 +83,10 @@ The *Build Orders* tab shows a list of the builds for this part. It provides a v
|
|||
|
||||
The *Used In* tab displays a list of other parts that this part is used to make. This tab is only visible if the Part is a *component*.
|
||||
|
||||
### Part Pricing
|
||||
|
||||
The *Part Pricing* tab displays all available pricing information for the part, aggregated from multiple sources (internal pricing, supplier pricing, purchase history, BOM pricing, sale pricing, etc). Refer to the [part pricing documentation](./pricing.md) for further information.
|
||||
|
||||
### Suppliers
|
||||
|
||||
The *Suppliers* tab displays all the *Part Suppliers* and *Part Manufacturers* for the selected *Part*.
|
||||
|
|
@ -101,6 +105,14 @@ This tab is only displayed if the part is marked as *Purchaseable*.
|
|||
|
||||
The *Sales Orders* tab shows a list of the sales orders for this part. It provides a view for important sales order information like customer, status, creation and shipment dates.
|
||||
|
||||
### Return Orders
|
||||
|
||||
The *Return Orders* tab shows a list of the [return orders](../sales/return_order.md) which reference this part. This tab is only visible if the Part is marked as *Salable*, and the return order feature is enabled.
|
||||
|
||||
### Transfer Orders
|
||||
|
||||
The *Transfer Orders* tab shows a list of the [transfer orders](../stock/transfer_order.md) which reference this part. This tab is hidden if the Part is marked as *Virtual*, or the transfer order feature is not enabled.
|
||||
|
||||
### Stock History
|
||||
|
||||
The *Stock History* tab provide historical stock level information. Refer to the [stock history documentation](./stocktake.md) for further information.
|
||||
|
|
@ -109,6 +121,10 @@ The *Stock History* tab provide historical stock level information. Refer to the
|
|||
|
||||
If a part is marked as *testable*, the user can define tests which must be performed on any stock items which are instances of this part. [Read more about testing](./test.md).
|
||||
|
||||
### Test Results
|
||||
|
||||
The *Test Results* tab displays [test result](../stock/test.md) data uploaded against *any* stock item of this part, aggregated into a single table. This differs from the *Test Templates* tab, which configures the tests themselves rather than displaying recorded results. This tab is only visible if the part is marked as *testable*.
|
||||
|
||||
### Related Parts
|
||||
|
||||
Related Part denotes a relationship between two parts, when users want to show their usage is "related" to another part or simply emphasize a link between two parts.
|
||||
|
|
|
|||
|
|
@ -135,6 +135,22 @@ The unit cost of the purchase order line item is transferred across to the creat
|
|||
|
||||
However, if the [Convert Currency](#purchase-order-settings) setting is enabled, the currency of the stock item will be converted to the [default currency](../concepts/pricing.md#default-currency) of the system. This may be useful when ordering stock in a different currency, to ensure that the unit cost of the stock item is converted to the base currency at the time of receipt.
|
||||
|
||||
## Bundled Items
|
||||
|
||||
Some suppliers only sell a group of components as a single bundled or "kit" product, rather than as individual purchasable line items - for example, a fastener kit containing an assortment of different screws, or a "starter kit" containing several components required for a particular use case.
|
||||
|
||||
Rather than receiving the bundle as a single opaque stock quantity, InvenTree allows the bundle to be modelled as an assembly, so that it can be broken apart into its individual components once required:
|
||||
|
||||
1. Create a part to represent the bundle itself, and mark it as an [assembly](../part/index.md#assembly)
|
||||
2. Link a [supplier part](./supplier.md#supplier-parts) to the bundle part, representing how it is purchased from the supplier
|
||||
3. Define a [Bill of Materials](../manufacturing/bom.md) for the bundle part, listing each of the individual components and the quantity contained within a single bundle
|
||||
4. Create and receive a purchase order against the bundle's supplier part, as normal - a single stock item is created for the bundle, retaining the purchase price and source purchase order of the order as a whole
|
||||
|
||||
Once the individual components are actually required, the received bundle stock item can be [disassembled](../stock/disassemble.md) into its component parts. The purchase price and traceability data (batch code, source purchase order) of the original bundle are automatically apportioned across the newly generated component stock items.
|
||||
|
||||
!!! tip "Pack Size vs Bundled Items"
|
||||
A supplier part with a [pack size](./supplier.md#supplier-part-pack-size) greater than one still represents multiple units of the *same* part - the pack size simply determines how many physical units are added to stock per unit ordered. A *bundled* item is different: a single supplier part represents an assortment of *different* components, which must be disassembled before the individual components can be used or sold separately.
|
||||
|
||||
## Complete Order
|
||||
|
||||
Once the quantity of all __received__ items is equal or above the quantity of all line items, the order will be automatically marked as __complete__.
|
||||
|
|
|
|||
|
|
@ -89,3 +89,6 @@ Supplier parts can have a pack size defined. This value is defined when creating
|
|||
When buying parts, they are bought in packs. This is taken into account in Purchase Orders: if a supplier part with a pack size of 5 is bought in a quantity of 4, 20 parts will be added to stock when the parts are received.
|
||||
|
||||
When adding stock manually, the supplier part can be added in packs or in individual parts. This is to allow the addition of items in opened packages. Set the flag "Use pack size" (`use_pack_size` in the API) to True in order to add parts in packs.
|
||||
|
||||
!!! tip "Bundled Items"
|
||||
A pack size only ever represents multiple units of the *same* part. If a supplier instead sells a kit or assortment of *different* components as a single purchasable item, refer to the [bundled items](./purchase_order.md#bundled-items) documentation instead.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
---
|
||||
title: Stock Disassembly
|
||||
---
|
||||
|
||||
## Stock Disassembly
|
||||
|
||||
A stock item of an [assembly](../part/index.md#assembly) part can be *disassembled* back into its component parts, based on the [Bill of Materials](../manufacturing/bom.md) (BOM) for that part. This is the reverse of building an assembly - instead of consuming components to create an assembled item, the assembled item is broken back down into its constituent components.
|
||||
|
||||
This is useful in a number of scenarios, for example:
|
||||
|
||||
- An assembly is being reworked or scrapped, and the still-usable components need to be returned to stock
|
||||
- A supplier ships a "bundled" or "kit" product as a single line item, which needs to be split apart into its individual components before the parts can be used or sold separately (see below)
|
||||
- Correcting an assembly which was built or received in error
|
||||
|
||||
Disassembly is only available for a stock item whose part is marked as an *assembly*, and requires that the part has at least one [BOM line item](../manufacturing/bom.md#bom-line-items) defined.
|
||||
|
||||
To disassemble a stock item, navigate to the stock item detail page and select the *Disassemble* option from the actions menu. This requires that the user has the *Stock: Add* permission, and that the stock item is currently [in stock](./status.md).
|
||||
|
||||
{{ image("stock/stock_options.png", "Stock Options") }}
|
||||
|
||||
### Disassembly Form
|
||||
|
||||
The disassembly form is pre-populated with one line per BOM line item defined for the part, based on the quantity of assemblies being disassembled. Any line item marked as [consumable](../manufacturing/bom.md#consumable-bom-line-items) (whether the BOM line itself or its underlying part is marked consumable), or which points to a [virtual](../part/index.md) part, is excluded, as these components are not expected to be tracked as physical stock. This exclusion is enforced by the API - such a BOM line cannot be submitted for disassembly, even if referenced directly.
|
||||
|
||||
For each line, the following values may be adjusted:
|
||||
|
||||
| Field | Description |
|
||||
| --- | --- |
|
||||
| Quantity | The total quantity of the component part to generate. This is automatically scaled as the top-level *Quantity* field is changed, unless the user has manually edited it |
|
||||
| Location | An optional destination location for the generated stock item. If not specified, the component is placed in the same location as the disassembled item (or the default location specified at the top of the form) |
|
||||
| Status | An optional [stock status](./status.md) to apply to the generated stock item. If not specified, the component is created with the default *OK* status |
|
||||
| Unit Price | An optional purchase price to record against the generated stock item. If left blank, a price is calculated automatically (see below) |
|
||||
|
||||
A line item can be removed from the form entirely if that particular component is not required to be split out - for example, if it is being scrapped rather than returned to stock. A line cannot be removed if it has installed items associated with it (see below).
|
||||
|
||||
### Quantity
|
||||
|
||||
Only the *available* quantity of a stock item can be disassembled. A [serialized](./traceability.md#serial-numbers) stock item does not have an adjustable quantity - since it represents a single physical unit, it must always be disassembled in its entirety.
|
||||
|
||||
The original stock item is never deleted as a result of disassembly - its quantity is reduced by the disassembled amount, in order to preserve traceability. If a stock item is disassembled down to a zero quantity, it is retained in the database (in an *unavailable* state) rather than removed, even if the item has the [Delete on Deplete](./availability.md#delete-on-deplete) flag set. A serialized item cannot be reduced to a zero quantity, so in this case the original item is instead marked with a *Destroyed* status.
|
||||
|
||||
### Accounting for Installed Items
|
||||
|
||||
If the stock item being disassembled has other stock items [installed](../manufacturing/allocate.md#allocating-tracked-stock) within it (for example, tracked components that were installed during a build order), these installed items **must** be accounted for during disassembly:
|
||||
|
||||
- Each installed item is matched against a BOM line item, based on the component part (including any [substitute](../manufacturing/bom.md#substitute-bom-line-items) or [variant](../part/index.md#assembly) parts allowed for that line)
|
||||
- Matched installed items are *uninstalled* directly, rather than being discarded and re-created - this preserves the original stock item, including its own tracking history, batch code, and purchase price
|
||||
- The quantity requested for the matching BOM line is reduced by the quantity already covered by the installed item(s). A new stock item is only created for any remaining quantity - if the installed items fully cover the required quantity, no new stock item is created for that line
|
||||
- Any installed item which does *not* match one of the selected BOM lines is still uninstalled (it cannot be left "installed" inside a smaller or non-existent parent), but its quantity is **not** subtracted from any line
|
||||
|
||||
Because installed items cannot be partially accounted for, **a stock item with any installed items must be disassembled in its entirety** - a partial disassembly (disassembling less than the full available quantity) is rejected if any items are currently installed.
|
||||
|
||||
The disassembly form displays a count of installed items against each matching BOM line, and lists any "leftover" installed items (which do not match a BOM line) in a separate warning panel.
|
||||
|
||||
### Automatic Cost Allocation
|
||||
|
||||
If the original stock item has a recorded purchase price, and no explicit *Unit Price* has been entered for the generated lines, InvenTree attempts to automatically apportion that cost across the newly generated components:
|
||||
|
||||
- The total cost (unit purchase price × disassembled quantity) is split across the lines, weighted by the existing [pricing](../part/pricing.md) data (average of minimum and maximum overall price) for each component part
|
||||
- If pricing data is not available for *every* line, the cost is instead split evenly on a per-unit basis across all generated units
|
||||
- Cost is only allocated across newly *created* stock items - any matched installed items retain their own existing purchase price, and are excluded from the cost split entirely
|
||||
- If any line has an explicit *Unit Price* provided by the user, automatic cost allocation is skipped entirely, and prices are only applied where explicitly set
|
||||
|
||||
### Traceability
|
||||
|
||||
Disassembling a stock item generates a full audit trail:
|
||||
|
||||
- A `Disassembled into components` entry is added to the tracking history of the original stock item
|
||||
- Each newly created component stock item receives a `Created from disassembly` tracking entry, referencing the original stock item
|
||||
- The *batch code* and source *purchase order* of the original stock item are copied directly to each generated component
|
||||
- If the original stock item was generated by a build order, that build order cannot be directly copied to the new component (since the component was not actually built by that order) - instead, it is recorded as a reference within the `Created from disassembly` tracking entry
|
||||
|
||||
### Purchasing Bundled Items
|
||||
|
||||
Some suppliers only sell a group of components as a single bundled or "kit" product, rather than as individual purchasable line items. Such a bundle can be modelled as an assembly part, purchased and received as a single stock item, and later disassembled into its individual components - with purchase price and traceability data automatically apportioned across the generated components, as described above.
|
||||
|
||||
Refer to the [Bundled Items](../purchasing/purchase_order.md#bundled-items) documentation for a full description of how to set this up.
|
||||
|
||||
### Enforced Limitations
|
||||
|
||||
The following limitations are enforced when disassembling a stock item:
|
||||
|
||||
- The part associated with the stock item must be marked as an *assembly*
|
||||
- The stock item must currently be [in stock](./status.md) - for example, it cannot be allocated to a sales order, installed in another assembly, or already fully consumed
|
||||
- At least one BOM line item must be selected for disassembly
|
||||
- The disassembly quantity cannot exceed the available quantity of the stock item
|
||||
- A serialized stock item must be disassembled in its entirety (its quantity cannot be partially reduced)
|
||||
- Each BOM line may only be referenced once per disassembly operation
|
||||
- A selected BOM line must be a valid line item for the part associated with the stock item
|
||||
- If the stock item has any installed items, it must be disassembled in its entirety
|
||||
|
|
@ -40,6 +40,31 @@ This view displays all tracking entries associated with any stock item linked to
|
|||
!!! info "Deleted Stock Items"
|
||||
Even if a stock item is deleted from the system, the associated stock tracking entries are retained for historical reference. They will be visible in the part tracking history, but not in the stock item tracking history (as the stock item itself has been deleted).
|
||||
|
||||
## Installed Stock Items
|
||||
|
||||
A stock item can be *installed* inside another stock item, forming a parent/child relationship between the two. This is used to represent physical assembly - for example, a serialized PCB assembly which has been fitted with a tracked sub-component, such as a wireless module or a pre-programmed IC.
|
||||
|
||||
An installed stock item is no longer available for regular stock actions (it cannot be moved, allocated to an order, or built into another assembly) while it remains installed - it is only accessible "through" its parent item.
|
||||
|
||||
### How Items Become Installed
|
||||
|
||||
There are two ways that a stock item can become installed inside another:
|
||||
|
||||
- **Tracked build allocation** - when a [tracked BOM item](../manufacturing/allocate.md#allocating-tracked-stock) is allocated to a build order and the build output is completed, the allocated stock item is automatically installed into the completed build output
|
||||
- **Manual installation** - from the *Stock Item Detail* page, a user can manually install one stock item into another, using the *Install Item* action. By default, the selected item's part must appear in the [Bill of Materials](../manufacturing/bom.md) of the parent item's part - this check can be disabled using the {{ globalsetting("STOCK_ENFORCE_BOM_INSTALLATION", short=True) }} setting
|
||||
|
||||
### Viewing Installed Items
|
||||
|
||||
Any stock items installed within a particular stock item are displayed on the *Installed Items* tab of the *Stock Item Detail* page.
|
||||
|
||||
By default, installed stock items are hidden from general stock item tables (as they are not directly available for use) - this can be changed using the {{ globalsetting("STOCK_SHOW_INSTALLED_ITEMS", short=True) }} setting.
|
||||
|
||||
### Removing Installed Items
|
||||
|
||||
An installed stock item can be removed (uninstalled) from its parent using the *Uninstall Item* action, which returns the item to a selected stock location and makes it available for regular stock actions once more.
|
||||
|
||||
Additionally, installed items are automatically uninstalled when the parent item is [disassembled](./disassemble.md#accounting-for-installed-items) into its component parts - refer to that page for a detailed description of how installed items are matched against Bill of Materials line items during disassembly.
|
||||
|
||||
## Stock Tracking Settings
|
||||
|
||||
There are a number of configuration options available for controlling the behavior of stock tracking functionality in the [system settings view](../settings/global.md):
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ nav:
|
|||
- Stock Tracking: stock/tracking.md
|
||||
- Stock Status: stock/status.md
|
||||
- Adjusting Stock: stock/adjust.md
|
||||
- Stock Disassembly: stock/disassemble.md
|
||||
- Stock Expiry: stock/expiry.md
|
||||
- Stock Ownership: stock/owner.md
|
||||
- Test Results: stock/test.md
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 519
|
||||
INVENTREE_API_VERSION = 520
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v520 -> 2026-07-11 : https://github.com/inventree/InvenTree/pull/xxxx
|
||||
- Adds new "disassemble" API endpoint for stock items
|
||||
- Allows a stock item to be broken down into component parts, based on its Bill of Materials
|
||||
|
||||
v519 -> 2026-07-09 : https://github.com/inventree/InvenTree/pull/TODO
|
||||
- Adds optional "roles" and "permissions" fields to the /user/me/ API endpoint, via the "?roles=true" query parameter
|
||||
|
||||
|
|
|
|||
|
|
@ -223,12 +223,18 @@ def postprocess_schema_enums(result, generator, **kwargs):
|
|||
"""Custom patch to ignore some drf-spectacular warnings.
|
||||
|
||||
- Some warnings are unavoidable due to the way that InvenTree implements generic relationships (via ContentType).
|
||||
- Some warnings are unavoidable due to the way that InvenTree implements custom (database-editable) status codes:
|
||||
multiple serializers legitimately expose a 'status' field backed by the same dynamic StockStatus choice set
|
||||
(e.g. stock adjustment, receiving a purchase order line, disassembling a stock item), and drf-spectacular
|
||||
cannot settle on a single stable name for the shared, runtime-dependent choice set.
|
||||
- The cleanest way to handle this appears to be to override the 'warn' function from drf-spectacular.
|
||||
|
||||
Ref: https://github.com/inventree/InvenTree/pull/10699
|
||||
"""
|
||||
ignore_patterns = [
|
||||
'enum naming encountered a non-optimally resolvable collision for fields named "model_type"'
|
||||
'enum naming encountered a non-optimally resolvable collision for fields named "model_type"',
|
||||
'enum naming encountered a non-optimally resolvable collision for fields named "status"',
|
||||
'encountered multiple names for the same choice set (StatusCustomKeyEnum)',
|
||||
]
|
||||
|
||||
if any(pattern in msg for pattern in ignore_patterns):
|
||||
|
|
|
|||
|
|
@ -173,6 +173,36 @@ class StockItemConvert(StockItemContextMixin, CreateAPI):
|
|||
serializer_class = StockSerializers.ConvertStockItemSerializer
|
||||
|
||||
|
||||
@extend_schema(responses={201: StockSerializers.StockItemSerializer(many=True)})
|
||||
class StockItemDisassemble(StockItemContextMixin, CreateAPI):
|
||||
"""API endpoint for disassembling a stock item into its component parts.
|
||||
|
||||
The components are generated based on the BOM lines provided in the request,
|
||||
which must be valid BOM lines for the part associated with the stock item.
|
||||
"""
|
||||
|
||||
serializer_class = StockSerializers.DisassembleStockItemSerializer
|
||||
pagination_class = None
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""Disassemble the provided StockItem."""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Perform the actual disassembly step
|
||||
items = serializer.save()
|
||||
|
||||
queryset = StockSerializers.StockItemSerializer.annotate_queryset(
|
||||
StockItem.objects.filter(pk__in=[item.pk for item in items])
|
||||
)
|
||||
|
||||
response = StockSerializers.StockItemSerializer(
|
||||
queryset, many=True, context=self.get_serializer_context()
|
||||
)
|
||||
|
||||
return Response(response.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class StockAdjustView(CreateAPI):
|
||||
"""A generic class for handling stocktake actions.
|
||||
|
||||
|
|
@ -1771,6 +1801,11 @@ stock_api_urls = [
|
|||
'<int:pk>/',
|
||||
include([
|
||||
path('convert/', StockItemConvert.as_view(), name='api-stock-item-convert'),
|
||||
path(
|
||||
'disassemble/',
|
||||
StockItemDisassemble.as_view(),
|
||||
name='api-stock-item-disassemble',
|
||||
),
|
||||
path('install/', StockItemInstall.as_view(), name='api-stock-item-install'),
|
||||
meta_path(StockItem),
|
||||
path(
|
||||
|
|
|
|||
|
|
@ -15,5 +15,6 @@ class StockEvents(BaseEventEnum):
|
|||
ITEM_COUNTED = 'stockitem.counted'
|
||||
ITEM_QUANTITY_UPDATED = 'stockitem.quantityupdated'
|
||||
ITEM_INSTALLED_INTO_ASSEMBLY = 'stockitem.installed'
|
||||
ITEM_DISASSEMBLED = 'stockitem.disassembled'
|
||||
|
||||
ITEMS_CREATED = 'stockitem.created_items'
|
||||
|
|
|
|||
|
|
@ -1813,6 +1813,276 @@ class StockItem(
|
|||
|
||||
self.save()
|
||||
|
||||
def allocate_disassembly_pricing(self, quantity, lines: list) -> list:
|
||||
"""Allocate the purchase price of this stock item across disassembly lines.
|
||||
|
||||
Automatic cost allocation is only performed if:
|
||||
- This stock item has a recorded purchase price
|
||||
- None of the provided lines specify an explicit purchase price
|
||||
|
||||
The total cost is apportioned across the lines pro-rata,
|
||||
weighted by existing pricing data for each component part.
|
||||
If pricing data is not available for *every* line,
|
||||
the cost is split evenly across the generated units.
|
||||
|
||||
Args:
|
||||
quantity: The number of units of this stock item being disassembled
|
||||
lines: A list of dict objects (see the disassemble method)
|
||||
|
||||
Returns:
|
||||
The list of lines, with 'purchase_price' values filled in where possible
|
||||
"""
|
||||
if not self.purchase_price:
|
||||
return lines
|
||||
|
||||
if any(line.get('purchase_price') is not None for line in lines):
|
||||
# Explicit pricing has been provided - do not override
|
||||
return lines
|
||||
|
||||
# Calculate a relative pricing "weight" for each line
|
||||
weights = []
|
||||
|
||||
for line in lines:
|
||||
pricing = line['bom_item'].sub_part.pricing
|
||||
|
||||
price_range = [
|
||||
price
|
||||
for price in [pricing.overall_min, pricing.overall_max]
|
||||
if price is not None
|
||||
]
|
||||
|
||||
weight = None
|
||||
|
||||
if price_range:
|
||||
mid = sum(price_range[1:], price_range[0]) / len(price_range)
|
||||
|
||||
if mid.amount > 0:
|
||||
weight = Decimal(mid.amount) * Decimal(line['quantity'])
|
||||
|
||||
weights.append(weight)
|
||||
|
||||
if any(weight is None for weight in weights):
|
||||
# Insufficient pricing data - fall back to an even (per unit) split
|
||||
weights = [Decimal(line['quantity']) for line in lines]
|
||||
|
||||
total_weight = sum(weights)
|
||||
|
||||
if total_weight <= 0:
|
||||
return lines
|
||||
|
||||
# Total cost of the disassembled units
|
||||
total_cost = self.purchase_price * quantity
|
||||
|
||||
for line, weight in zip(lines, weights, strict=True):
|
||||
line_quantity = Decimal(line['quantity'])
|
||||
|
||||
if line_quantity > 0:
|
||||
if weight is None:
|
||||
# Fallback to an even split
|
||||
line['purchase_price'] = total_cost / len(lines) / line_quantity
|
||||
else:
|
||||
line['purchase_price'] = (
|
||||
total_cost * (weight / total_weight) / line_quantity
|
||||
)
|
||||
|
||||
return lines
|
||||
|
||||
@transaction.atomic
|
||||
def disassemble(
|
||||
self, quantity, lines: list, user, location=None, notes: str = ''
|
||||
) -> list[StockItem]:
|
||||
"""Disassemble this stock item into its component parts.
|
||||
|
||||
The provided BOM lines determine which component parts are "broken out"
|
||||
of this stock item. A new stock item is created for each line,
|
||||
and the quantity of this item is reduced accordingly.
|
||||
|
||||
Args:
|
||||
quantity: The number of units of this stock item to disassemble
|
||||
lines: A list of dict objects, each containing:
|
||||
bom_item: The BomItem instance defining the component to break out
|
||||
quantity: The total quantity of the component to create
|
||||
location: Optional destination StockLocation for the component
|
||||
status: Optional StockStatus code for the component (default: OK)
|
||||
purchase_price: Optional unit purchase price for the component
|
||||
user: The user performing the operation
|
||||
location: Default destination location for the generated components
|
||||
notes: Optional transaction notes
|
||||
|
||||
Returns:
|
||||
A list of the component StockItem objects (created or uninstalled)
|
||||
|
||||
Note:
|
||||
This stock item is *retained* (with reduced quantity) even if the
|
||||
quantity reaches zero, to preserve traceability of the disassembly.
|
||||
|
||||
Note:
|
||||
Any stock items which are *installed* within this stock item are
|
||||
broken out (uninstalled) as part of the disassembly operation,
|
||||
rather than creating new stock items for them.
|
||||
|
||||
Note:
|
||||
Traceability data is passed down from this stock item to the
|
||||
generated components: the batch code and source purchase order
|
||||
are copied across, while any source build order is recorded
|
||||
in the stock tracking history of each component.
|
||||
"""
|
||||
try:
|
||||
quantity = Decimal(quantity)
|
||||
except (InvalidOperation, ValueError):
|
||||
raise ValidationError({'quantity': _('Invalid quantity provided')})
|
||||
|
||||
if quantity <= 0:
|
||||
raise ValidationError({'quantity': _('Quantity must be greater than zero')})
|
||||
|
||||
if quantity > self.quantity:
|
||||
raise ValidationError({
|
||||
'quantity': _('Quantity must not exceed available stock quantity')
|
||||
})
|
||||
|
||||
if self.serialized and quantity != self.quantity:
|
||||
raise ValidationError({
|
||||
'quantity': _(
|
||||
'Serialized stock item must be disassembled in its entirety'
|
||||
)
|
||||
})
|
||||
|
||||
if len(lines) == 0:
|
||||
raise ValidationError(_('No BOM lines provided'))
|
||||
|
||||
# Any items installed within this stock item *must* be broken out,
|
||||
# which requires that the entire quantity is disassembled
|
||||
installed_items = list(self.installed_parts.all())
|
||||
|
||||
if len(installed_items) > 0 and quantity != self.quantity:
|
||||
raise ValidationError({
|
||||
'quantity': _(
|
||||
'Stock item with installed items must be disassembled in its entirety'
|
||||
)
|
||||
})
|
||||
|
||||
# Cache the custom status options for the StockItem model
|
||||
custom_status_values = StockItem.STATUS_CLASS.custom_values()
|
||||
|
||||
items = []
|
||||
|
||||
# Match installed items against the provided BOM lines,
|
||||
# so that the correct location and status values can be applied
|
||||
line_map = {}
|
||||
|
||||
for line in lines:
|
||||
for part in line['bom_item'].get_valid_parts_for_allocation():
|
||||
line_map.setdefault(part.pk, line)
|
||||
|
||||
for installed_item in installed_items:
|
||||
line = line_map.get(installed_item.part.pk)
|
||||
|
||||
destination = (
|
||||
(line.get('location') if line else None) or location or self.location
|
||||
)
|
||||
|
||||
if line:
|
||||
# The uninstalled quantity reduces the quantity of new items
|
||||
# to be created against the matching BOM line
|
||||
line['quantity'] = max(
|
||||
Decimal(0), Decimal(line['quantity']) - installed_item.quantity
|
||||
)
|
||||
|
||||
if status := line.get('status'):
|
||||
installed_item.set_status(
|
||||
status, custom_values=custom_status_values
|
||||
)
|
||||
|
||||
# Break the installed item out into the destination location
|
||||
# Note: this also saves any status change applied above
|
||||
installed_item.uninstall_into_location(destination, user, notes)
|
||||
|
||||
items.append(installed_item)
|
||||
|
||||
# Allocate pricing data across the generated components.
|
||||
# Note: Deliberately performed *after* the installed items are uninstalled,
|
||||
# so that the cost is only spread across newly created stock items
|
||||
lines = self.allocate_disassembly_pricing(quantity, lines)
|
||||
|
||||
for line in lines:
|
||||
bom_item = line['bom_item']
|
||||
line_quantity = Decimal(line['quantity'])
|
||||
|
||||
if line_quantity <= 0:
|
||||
continue
|
||||
|
||||
destination = line.get('location') or location or self.location
|
||||
|
||||
new_item = StockItem(
|
||||
part=bom_item.sub_part,
|
||||
quantity=line_quantity,
|
||||
location=destination,
|
||||
parent=self,
|
||||
batch=self.batch,
|
||||
purchase_order=self.purchase_order,
|
||||
purchase_price=line.get('purchase_price', None),
|
||||
)
|
||||
|
||||
if status := line.get('status'):
|
||||
new_item.set_status(status, custom_values=custom_status_values)
|
||||
|
||||
# Ensure the tree structure is observed
|
||||
new_item.tree_id = None
|
||||
new_item.save(add_note=False)
|
||||
|
||||
deltas = {'stockitem': self.pk, 'quantity': float(line_quantity)}
|
||||
|
||||
# Record traceability links against the disassembled assembly
|
||||
if self.build:
|
||||
deltas['buildorder'] = self.build.pk
|
||||
|
||||
if self.purchase_order:
|
||||
deltas['purchaseorder'] = self.purchase_order.pk
|
||||
|
||||
new_item.add_tracking_entry(
|
||||
StockHistoryCode.CREATED_FROM_DISASSEMBLY,
|
||||
user,
|
||||
notes=notes,
|
||||
deltas=deltas,
|
||||
location=destination,
|
||||
)
|
||||
|
||||
items.append(new_item)
|
||||
|
||||
# Consume this stock item.
|
||||
# Note: the item is deliberately retained (not deleted) at zero quantity,
|
||||
# to preserve the disassembly history
|
||||
if self.serialized:
|
||||
# A serialized item cannot be reduced to zero quantity,
|
||||
# so instead it is marked as "destroyed"
|
||||
deltas = {
|
||||
'removed': float(quantity),
|
||||
'status': StockStatus.DESTROYED.value,
|
||||
'old_status': self.status,
|
||||
}
|
||||
|
||||
self.set_status(StockStatus.DESTROYED.value)
|
||||
else:
|
||||
deltas = {
|
||||
'removed': float(quantity),
|
||||
'quantity': float(self.quantity - quantity),
|
||||
}
|
||||
|
||||
self.quantity = self.quantity - quantity
|
||||
|
||||
self.save(add_note=False)
|
||||
|
||||
self.add_tracking_entry(
|
||||
StockHistoryCode.DISASSEMBLED, user, notes=notes, deltas=deltas
|
||||
)
|
||||
|
||||
# Rebuild the stock tree for this item
|
||||
stock.tasks.rebuild_stock_item_tree(self.tree_id)
|
||||
|
||||
trigger_event(StockEvents.ITEM_DISASSEMBLED, id=self.pk)
|
||||
|
||||
return items
|
||||
|
||||
@property
|
||||
def children(self):
|
||||
"""Return a list of the child items which have been split from this stock item."""
|
||||
|
|
@ -1922,7 +2192,7 @@ class StockItem(
|
|||
return None
|
||||
|
||||
# Extract any additional information from the kwargs
|
||||
self._apply_model_reference_fields(deltas, kwargs)
|
||||
self._apply_model_reference_fields(kwargs, deltas)
|
||||
|
||||
# Quantity specified?
|
||||
quantity = kwargs.get('quantity')
|
||||
|
|
|
|||
|
|
@ -945,6 +945,227 @@ class UninstallStockItemSerializer(serializers.Serializer):
|
|||
item.uninstall_into_location(location, request.user, note)
|
||||
|
||||
|
||||
class StockStatusCustomSerializer(serializers.ChoiceField):
|
||||
"""Serializer to allow annotating the schema to use int where custom values may be entered."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize the status selector."""
|
||||
if 'choices' not in kwargs:
|
||||
kwargs['choices'] = stock.status_codes.StockStatus.items(custom=True)
|
||||
|
||||
if 'label' not in kwargs:
|
||||
kwargs['label'] = _('Status')
|
||||
|
||||
if 'help_text' not in kwargs:
|
||||
kwargs['help_text'] = _('Stock item status code')
|
||||
|
||||
if InvenTree.ready.isGeneratingSchema():
|
||||
kwargs['help_text'] = (
|
||||
kwargs['help_text']
|
||||
+ '\n\n'
|
||||
+ '\n'.join(
|
||||
f'* `{value}` - {label}' for value, label in kwargs['choices']
|
||||
)
|
||||
+ "\n\nAdditional custom status keys may be retrieved from the 'stock_status_retrieve' call."
|
||||
)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class DisassemblyLineSerializer(serializers.Serializer):
|
||||
"""Serializer for a single component line in a stock disassembly operation."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
fields = [
|
||||
'bom_item',
|
||||
'quantity',
|
||||
'location',
|
||||
'status',
|
||||
'purchase_price',
|
||||
'purchase_price_currency',
|
||||
]
|
||||
|
||||
bom_item = serializers.PrimaryKeyRelatedField(
|
||||
queryset=part_models.BomItem.objects.all(),
|
||||
many=False,
|
||||
required=True,
|
||||
allow_null=False,
|
||||
label=_('BOM Item'),
|
||||
help_text=_('BOM line which defines the component part to break out'),
|
||||
)
|
||||
|
||||
quantity = serializers.DecimalField(
|
||||
max_digits=15,
|
||||
decimal_places=5,
|
||||
min_value=Decimal(0),
|
||||
required=True,
|
||||
label=_('Quantity'),
|
||||
help_text=_('Quantity of the component part to create'),
|
||||
)
|
||||
|
||||
def validate_quantity(self, quantity):
|
||||
"""Validation for the 'quantity' field."""
|
||||
if quantity <= 0:
|
||||
raise ValidationError(_('Quantity must be greater than zero'))
|
||||
|
||||
return quantity
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockLocation.objects.all(),
|
||||
many=False,
|
||||
allow_null=True,
|
||||
required=False,
|
||||
label=_('Location'),
|
||||
help_text=_('Destination location for the component items'),
|
||||
)
|
||||
|
||||
status = StockStatusCustomSerializer(
|
||||
required=False, help_text=_('Status code for the component items')
|
||||
)
|
||||
|
||||
purchase_price = InvenTree.serializers.InvenTreeMoneySerializer(
|
||||
label=_('Purchase Price'),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None,
|
||||
help_text=_(
|
||||
'Unit purchase price for the component items (leave blank for automatic cost allocation)'
|
||||
),
|
||||
)
|
||||
|
||||
purchase_price_currency = InvenTreeCurrencySerializer(
|
||||
required=False, help_text=_('Purchase price currency')
|
||||
)
|
||||
|
||||
|
||||
class DisassembleStockItemSerializer(serializers.Serializer):
|
||||
"""DRF serializer class for disassembling a StockItem into component parts.
|
||||
|
||||
Note: The stock item being disassembled is provided via the serializer context
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
fields = ['items', 'quantity', 'location', 'notes']
|
||||
|
||||
items = DisassemblyLineSerializer(many=True)
|
||||
|
||||
quantity = serializers.DecimalField(
|
||||
max_digits=15,
|
||||
decimal_places=5,
|
||||
min_value=Decimal(0),
|
||||
required=True,
|
||||
label=_('Quantity'),
|
||||
help_text=_('Number of assemblies to disassemble'),
|
||||
)
|
||||
|
||||
def validate_quantity(self, quantity):
|
||||
"""Validation for the 'quantity' field."""
|
||||
item = self.context.get('item')
|
||||
|
||||
if not item:
|
||||
raise ValidationError(_('No stock item provided'))
|
||||
|
||||
if quantity <= 0:
|
||||
raise ValidationError(_('Quantity must be greater than zero'))
|
||||
|
||||
if quantity > item.quantity:
|
||||
raise ValidationError(
|
||||
_('Quantity must not exceed available stock quantity')
|
||||
+ f' ({item.quantity})'
|
||||
)
|
||||
|
||||
return quantity
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockLocation.objects.all(),
|
||||
many=False,
|
||||
allow_null=True,
|
||||
required=False,
|
||||
label=_('Location'),
|
||||
help_text=_('Default destination location for the component items'),
|
||||
)
|
||||
|
||||
notes = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
label=_('Notes'),
|
||||
help_text=_('Optional note field'),
|
||||
)
|
||||
|
||||
def validate(self, data):
|
||||
"""Validate the disassembly operation.
|
||||
|
||||
- The stock item must be "in stock"
|
||||
- Each line must reference a valid BOM item for the part
|
||||
- Each BOM item may only be referenced once
|
||||
"""
|
||||
data = super().validate(data)
|
||||
|
||||
item = self.context.get('item')
|
||||
|
||||
if not item:
|
||||
raise ValidationError(_('No stock item provided'))
|
||||
|
||||
if not item.in_stock:
|
||||
raise ValidationError(_('Stock item is unavailable'))
|
||||
|
||||
items = data.get('items', [])
|
||||
|
||||
if len(items) == 0:
|
||||
raise ValidationError(_('Line items must be provided'))
|
||||
|
||||
# The set of valid BOM items for this part.
|
||||
# Consumable BOM lines (and lines pointing to a virtual part) are excluded,
|
||||
# as these components are not expected to be tracked as physical stock
|
||||
bom_items = item.part.get_bom_items(include_virtual=False).filter(
|
||||
part_models.BomItem.consumable_filter(consumable=False)
|
||||
)
|
||||
|
||||
bom_item_pks = set()
|
||||
|
||||
for line in items:
|
||||
bom_item = line['bom_item']
|
||||
|
||||
if not bom_items.filter(pk=bom_item.pk).exists():
|
||||
raise ValidationError(
|
||||
_('BOM item is not valid for the selected stock item')
|
||||
)
|
||||
|
||||
if bom_item.pk in bom_item_pks:
|
||||
raise ValidationError(_('Duplicate BOM items provided'))
|
||||
|
||||
bom_item_pks.add(bom_item.pk)
|
||||
|
||||
return data
|
||||
|
||||
def save(self) -> list[StockItem]:
|
||||
"""Disassemble the provided StockItem into its component parts.
|
||||
|
||||
Returns:
|
||||
A list of StockItem objects created by the disassembly operation.
|
||||
"""
|
||||
item = self.context['item']
|
||||
request = self.context.get('request')
|
||||
|
||||
data = self.validated_data
|
||||
|
||||
try:
|
||||
return item.disassemble(
|
||||
data['quantity'],
|
||||
data['items'],
|
||||
request.user if request else None,
|
||||
location=data.get('location', None),
|
||||
notes=data.get('notes', ''),
|
||||
)
|
||||
except DjangoValidationError as exc:
|
||||
# Catch model errors and re-throw as DRF errors
|
||||
raise ValidationError(detail=serializers.as_serializer_error(exc))
|
||||
|
||||
|
||||
class ConvertStockItemSerializer(serializers.Serializer):
|
||||
"""DRF serializer class for converting a StockItem to a valid variant part."""
|
||||
|
||||
|
|
@ -1008,33 +1229,6 @@ class ConvertStockItemSerializer(serializers.Serializer):
|
|||
help_text='Status key, chosen from the list of StockStatus keys'
|
||||
)
|
||||
)
|
||||
class StockStatusCustomSerializer(serializers.ChoiceField):
|
||||
"""Serializer to allow annotating the schema to use int where custom values may be entered."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize the status selector."""
|
||||
if 'choices' not in kwargs:
|
||||
kwargs['choices'] = stock.status_codes.StockStatus.items(custom=True)
|
||||
|
||||
if 'label' not in kwargs:
|
||||
kwargs['label'] = _('Status')
|
||||
|
||||
if 'help_text' not in kwargs:
|
||||
kwargs['help_text'] = _('Stock item status code')
|
||||
|
||||
if InvenTree.ready.isGeneratingSchema():
|
||||
kwargs['help_text'] = (
|
||||
kwargs['help_text']
|
||||
+ '\n\n'
|
||||
+ '\n'.join(
|
||||
f'* `{value}` - {label}' for value, label in kwargs['choices']
|
||||
)
|
||||
+ "\n\nAdditional custom status keys may be retrieved from the 'stock_status_retrieve' call."
|
||||
)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class StockChangeStatusSerializer(serializers.Serializer):
|
||||
"""Serializer for changing status of multiple StockItem objects."""
|
||||
|
||||
|
|
|
|||
|
|
@ -75,6 +75,10 @@ class StockHistoryCode(StatusCode):
|
|||
# Stock merging operations
|
||||
MERGED_STOCK_ITEMS = 45, _('Merged stock items')
|
||||
|
||||
# Stock disassembly operations
|
||||
DISASSEMBLED = 46, _('Disassembled into components')
|
||||
CREATED_FROM_DISASSEMBLY = 47, _('Created from disassembly')
|
||||
|
||||
# Convert stock item to variant
|
||||
CONVERTED_TO_VARIANT = 48, _('Converted to variant')
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from rest_framework import status
|
|||
|
||||
import build.models
|
||||
import company.models
|
||||
import order.models
|
||||
import part.models
|
||||
from common.models import InvenTreeCustomUserStateModel, InvenTreeSetting
|
||||
from common.settings import set_global_setting
|
||||
|
|
@ -2215,6 +2216,487 @@ class StockItemTest(StockAPITestCase):
|
|||
self.assertEqual(item.batch, 'NEW-BATCH-CODE')
|
||||
|
||||
|
||||
class StockItemDisassembleTest(StockAPITestCase):
|
||||
"""Series of API tests for the StockItem disassembly endpoint."""
|
||||
|
||||
def setUp(self):
|
||||
"""Create a stock item of an assembly part, ready for disassembly."""
|
||||
super().setUp()
|
||||
|
||||
# Part 100 ("Bob") is an assembly with 4 BOM lines
|
||||
self.assembly = part.models.Part.objects.get(pk=100)
|
||||
|
||||
self.item = StockItem.objects.create(
|
||||
part=self.assembly,
|
||||
quantity=10,
|
||||
location=StockLocation.objects.get(pk=1),
|
||||
purchase_price=Money(100, 'USD'),
|
||||
)
|
||||
|
||||
self.url = reverse('api-stock-item-disassemble', kwargs={'pk': self.item.pk})
|
||||
|
||||
def test_validation(self):
|
||||
"""Test validation checks for the disassembly endpoint."""
|
||||
# Empty request
|
||||
response = self.post(self.url, {}, expected_code=400)
|
||||
|
||||
self.assertIn('This field is required', str(response.data['items']))
|
||||
self.assertIn('This field is required', str(response.data['quantity']))
|
||||
|
||||
# No line items provided
|
||||
response = self.post(self.url, {'items': [], 'quantity': 1}, expected_code=400)
|
||||
|
||||
self.assertIn('Line items must be provided', str(response.data))
|
||||
|
||||
# Quantity exceeds available stock
|
||||
response = self.post(
|
||||
self.url,
|
||||
{'items': [{'bom_item': 1, 'quantity': 10}], 'quantity': 100},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
'Quantity must not exceed available stock quantity', str(response.data)
|
||||
)
|
||||
|
||||
# BOM item which points to a different part (BomItem pk=5 -> part 1)
|
||||
response = self.post(
|
||||
self.url,
|
||||
{'items': [{'bom_item': 5, 'quantity': 1}], 'quantity': 1},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
'BOM item is not valid for the selected stock item', str(response.data)
|
||||
)
|
||||
|
||||
# Duplicated BOM items
|
||||
response = self.post(
|
||||
self.url,
|
||||
{
|
||||
'items': [
|
||||
{'bom_item': 1, 'quantity': 10},
|
||||
{'bom_item': 1, 'quantity': 5},
|
||||
],
|
||||
'quantity': 1,
|
||||
},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn('Duplicate BOM items provided', str(response.data))
|
||||
|
||||
def test_consumable_and_virtual_excluded(self):
|
||||
"""Consumable BOM lines, and lines pointing to a virtual part, cannot be disassembled."""
|
||||
# BOM line marked as consumable directly
|
||||
consumable_line = part.models.BomItem.objects.create(
|
||||
part=self.assembly,
|
||||
sub_part=part.models.Part.objects.get(pk=2),
|
||||
quantity=1,
|
||||
consumable=True,
|
||||
)
|
||||
|
||||
# BOM line whose sub_part is marked as consumable
|
||||
consumable_part = part.models.Part.objects.get(pk=3)
|
||||
consumable_part.consumable = True
|
||||
consumable_part.save()
|
||||
|
||||
# BOM line whose sub_part is marked as virtual
|
||||
virtual_part = part.models.Part.objects.get(pk=5)
|
||||
virtual_part.virtual = True
|
||||
virtual_part.save()
|
||||
|
||||
for bom_item_pk in [consumable_line.pk, 2, 3]:
|
||||
response = self.post(
|
||||
self.url,
|
||||
{'items': [{'bom_item': bom_item_pk, 'quantity': 1}], 'quantity': 1},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
'BOM item is not valid for the selected stock item', str(response.data)
|
||||
)
|
||||
|
||||
def test_disassemble(self):
|
||||
"""Test a valid disassembly operation, using a subset of the BOM lines."""
|
||||
n = StockItem.objects.count()
|
||||
|
||||
# Disassemble 4 assemblies, but only break out 2 of the 4 BOM lines
|
||||
# The second line specifies a custom location and status
|
||||
response = self.post(
|
||||
self.url,
|
||||
{
|
||||
'items': [
|
||||
{'bom_item': 1, 'quantity': 40},
|
||||
{
|
||||
'bom_item': 4,
|
||||
'quantity': 12,
|
||||
'location': 2,
|
||||
'status': StockStatus.DAMAGED.value,
|
||||
},
|
||||
],
|
||||
'quantity': 4,
|
||||
'notes': 'Breaking apart',
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 2)
|
||||
self.assertEqual(StockItem.objects.count(), n + 2)
|
||||
|
||||
self.item.refresh_from_db()
|
||||
self.assertEqual(self.item.quantity, 6)
|
||||
|
||||
items = {entry['part']: entry for entry in response.data}
|
||||
|
||||
# Check the generated component items
|
||||
item_1 = StockItem.objects.get(pk=items[1]['pk'])
|
||||
item_50 = StockItem.objects.get(pk=items[50]['pk'])
|
||||
|
||||
self.assertEqual(item_1.quantity, 40)
|
||||
self.assertEqual(item_50.quantity, 12)
|
||||
|
||||
# Components inherit the location of the disassembled item by default
|
||||
self.assertEqual(item_1.location, self.item.location)
|
||||
self.assertEqual(item_1.status, StockStatus.OK.value)
|
||||
|
||||
# A custom location and status can be specified per line
|
||||
self.assertEqual(item_50.location.pk, 2)
|
||||
self.assertEqual(item_50.status, StockStatus.DAMAGED.value)
|
||||
|
||||
# Components are linked to the disassembled item
|
||||
self.assertEqual(item_1.parent.pk, self.item.pk)
|
||||
self.assertEqual(item_50.parent.pk, self.item.pk)
|
||||
|
||||
# Total cost (4 x 100 USD) is allocated evenly across 52 units
|
||||
self.assertAlmostEqual(float(item_1.purchase_price.amount), 400 / 52, places=4)
|
||||
self.assertAlmostEqual(float(item_50.purchase_price.amount), 400 / 52, places=4)
|
||||
self.assertEqual(str(item_1.purchase_price.currency), 'USD')
|
||||
|
||||
# Check stock tracking entries have been created
|
||||
self.assertTrue(
|
||||
self.item.tracking_info.filter(
|
||||
tracking_type=StockHistoryCode.DISASSEMBLED.value
|
||||
).exists()
|
||||
)
|
||||
|
||||
for item in [item_1, item_50]:
|
||||
entry = item.tracking_info.filter(
|
||||
tracking_type=StockHistoryCode.CREATED_FROM_DISASSEMBLY.value
|
||||
).first()
|
||||
|
||||
self.assertIsNotNone(entry)
|
||||
self.assertEqual(entry.deltas['stockitem'], self.item.pk)
|
||||
|
||||
def test_full_disassembly(self):
|
||||
"""Test that a fully disassembled item is retained at zero quantity."""
|
||||
self.post(
|
||||
self.url,
|
||||
{'items': [{'bom_item': 2, 'quantity': 400}], 'quantity': 10},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
self.item.refresh_from_db()
|
||||
|
||||
self.assertEqual(self.item.quantity, 0)
|
||||
self.assertFalse(self.item.in_stock)
|
||||
|
||||
def test_traceability_inheritance(self):
|
||||
"""Test that traceability data is passed down to the generated components."""
|
||||
po = order.models.PurchaseOrder.objects.create(
|
||||
supplier=company.models.Company.objects.get(pk=1), reference='PO-9999'
|
||||
)
|
||||
|
||||
bo = build.models.Build.objects.create(
|
||||
part=self.assembly, quantity=10, reference='BO-9999'
|
||||
)
|
||||
|
||||
self.item.batch = 'ABC-123'
|
||||
self.item.purchase_order = po
|
||||
self.item.build = bo
|
||||
self.item.save()
|
||||
|
||||
response = self.post(
|
||||
self.url,
|
||||
{'items': [{'bom_item': 1, 'quantity': 10}], 'quantity': 1},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
component = StockItem.objects.get(pk=response.data[0]['pk'])
|
||||
|
||||
# Batch code and source purchase order are inherited directly
|
||||
self.assertEqual(component.batch, 'ABC-123')
|
||||
self.assertEqual(component.purchase_order, po)
|
||||
|
||||
# The source build order cannot be copied across (the component is not
|
||||
# an output of the build) - instead it is recorded in the stock history
|
||||
self.assertIsNone(component.build)
|
||||
|
||||
entry = component.tracking_info.filter(
|
||||
tracking_type=StockHistoryCode.CREATED_FROM_DISASSEMBLY.value
|
||||
).first()
|
||||
|
||||
self.assertIsNotNone(entry)
|
||||
self.assertEqual(entry.deltas['buildorder'], bo.pk)
|
||||
self.assertEqual(entry.deltas['purchaseorder'], po.pk)
|
||||
|
||||
def test_explicit_pricing(self):
|
||||
"""Test that automatic cost allocation is skipped if explicit pricing is provided."""
|
||||
response = self.post(
|
||||
self.url,
|
||||
{
|
||||
'items': [
|
||||
{
|
||||
'bom_item': 1,
|
||||
'quantity': 10,
|
||||
'purchase_price': 5,
|
||||
'purchase_price_currency': 'NZD',
|
||||
},
|
||||
{'bom_item': 4, 'quantity': 3},
|
||||
],
|
||||
'quantity': 1,
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
prices = {entry['part']: entry['purchase_price'] for entry in response.data}
|
||||
|
||||
self.assertAlmostEqual(prices[1], 5, places=3)
|
||||
self.assertIsNone(prices[50])
|
||||
|
||||
def install_item(self, part_pk: int, quantity, **kwargs) -> StockItem:
|
||||
"""Install a new stock item into the assembly under test."""
|
||||
return StockItem.objects.create(
|
||||
part=part.models.Part.objects.get(pk=part_pk),
|
||||
quantity=quantity,
|
||||
belongs_to=self.item,
|
||||
location=None,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def test_installed_items_partial(self):
|
||||
"""Installed items are uninstalled, and only the remainder is created afresh."""
|
||||
sub_1 = self.install_item(1, 12)
|
||||
sub_2 = self.install_item(1, 8)
|
||||
|
||||
n = StockItem.objects.count()
|
||||
|
||||
# Disassemble all 10 assemblies - the line requires 100 units in total
|
||||
response = self.post(
|
||||
self.url,
|
||||
{'items': [{'bom_item': 1, 'quantity': 100}], 'quantity': 10},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
# Two uninstalled items, plus a single new item for the remainder
|
||||
self.assertEqual(len(response.data), 3)
|
||||
self.assertEqual(StockItem.objects.count(), n + 1)
|
||||
|
||||
pks = {entry['pk'] for entry in response.data}
|
||||
self.assertIn(sub_1.pk, pks)
|
||||
self.assertIn(sub_2.pk, pks)
|
||||
|
||||
for sub in [sub_1, sub_2]:
|
||||
sub.refresh_from_db()
|
||||
self.assertIsNone(sub.belongs_to)
|
||||
self.assertEqual(sub.location, self.item.location)
|
||||
|
||||
new_item = StockItem.objects.get(pk=(pks - {sub_1.pk, sub_2.pk}).pop())
|
||||
self.assertEqual(new_item.part.pk, 1)
|
||||
self.assertEqual(new_item.quantity, 80)
|
||||
|
||||
# Uninstalled items are updated, not created afresh
|
||||
self.assertTrue(
|
||||
sub_1.tracking_info.filter(
|
||||
tracking_type=StockHistoryCode.REMOVED_FROM_ASSEMBLY.value
|
||||
).exists()
|
||||
)
|
||||
self.assertFalse(
|
||||
sub_1.tracking_info.filter(
|
||||
tracking_type=StockHistoryCode.CREATED_FROM_DISASSEMBLY.value
|
||||
).exists()
|
||||
)
|
||||
self.assertTrue(
|
||||
self.item.tracking_info.filter(
|
||||
tracking_type=StockHistoryCode.REMOVED_CHILD_ITEM.value
|
||||
).exists()
|
||||
)
|
||||
self.assertTrue(
|
||||
new_item.tracking_info.filter(
|
||||
tracking_type=StockHistoryCode.CREATED_FROM_DISASSEMBLY.value
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_installed_items_full_coverage(self):
|
||||
"""No new stock item is created for a line fully covered by installed items."""
|
||||
sub = self.install_item(1, 120, purchase_price=Money(3, 'USD'))
|
||||
|
||||
n = StockItem.objects.count()
|
||||
|
||||
response = self.post(
|
||||
self.url,
|
||||
{
|
||||
'items': [
|
||||
{'bom_item': 1, 'quantity': 100},
|
||||
{'bom_item': 4, 'quantity': 30},
|
||||
],
|
||||
'quantity': 10,
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
# The first line is fully covered by the uninstalled item
|
||||
self.assertEqual(len(response.data), 2)
|
||||
self.assertEqual(StockItem.objects.count(), n + 1)
|
||||
|
||||
sub.refresh_from_db()
|
||||
self.assertIsNone(sub.belongs_to)
|
||||
|
||||
# The uninstalled item retains its own purchase price
|
||||
self.assertEqual(sub.purchase_price, Money(3, 'USD'))
|
||||
|
||||
# The total cost (10 x 100 USD) is spread only across newly created units
|
||||
new_item = StockItem.objects.get(part=50, parent=self.item)
|
||||
self.assertEqual(new_item.quantity, 30)
|
||||
self.assertAlmostEqual(
|
||||
float(new_item.purchase_price.amount), 1000 / 30, places=4
|
||||
)
|
||||
|
||||
def test_installed_items_leftover(self):
|
||||
"""Installed items which do not match a BOM line are still uninstalled."""
|
||||
# Part 2 is not a component of the assembly
|
||||
sub = self.install_item(2, 5, status=StockStatus.ATTENTION.value)
|
||||
|
||||
response = self.post(
|
||||
self.url,
|
||||
{
|
||||
'items': [{'bom_item': 2, 'quantity': 400}],
|
||||
'quantity': 10,
|
||||
'location': 2,
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 2)
|
||||
|
||||
sub.refresh_from_db()
|
||||
self.assertIsNone(sub.belongs_to)
|
||||
|
||||
# Uninstalled to the top-level destination, retaining its own status
|
||||
self.assertEqual(sub.location.pk, 2)
|
||||
self.assertEqual(sub.status, StockStatus.ATTENTION.value)
|
||||
|
||||
# The BOM line quantity is not reduced by the unmatched item
|
||||
new_item = StockItem.objects.get(part=3, parent=self.item)
|
||||
self.assertEqual(new_item.quantity, 400)
|
||||
|
||||
def test_installed_items_require_full_disassembly(self):
|
||||
"""Partial disassembly is rejected while items are installed."""
|
||||
self.install_item(1, 1)
|
||||
|
||||
response = self.post(
|
||||
self.url,
|
||||
{'items': [{'bom_item': 1, 'quantity': 50}], 'quantity': 5},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn('must be disassembled in its entirety', str(response.data))
|
||||
|
||||
def test_installed_items_status_and_location(self):
|
||||
"""Explicit per-line status and location values are applied to uninstalled items."""
|
||||
sub_1 = self.install_item(1, 10)
|
||||
sub_2 = self.install_item(50, 4, status=StockStatus.ATTENTION.value)
|
||||
|
||||
self.post(
|
||||
self.url,
|
||||
{
|
||||
'items': [
|
||||
{
|
||||
'bom_item': 1,
|
||||
'quantity': 100,
|
||||
'location': 2,
|
||||
'status': StockStatus.DAMAGED.value,
|
||||
},
|
||||
{'bom_item': 4, 'quantity': 30},
|
||||
],
|
||||
'quantity': 10,
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
sub_1.refresh_from_db()
|
||||
self.assertEqual(sub_1.location.pk, 2)
|
||||
self.assertEqual(sub_1.status, StockStatus.DAMAGED.value)
|
||||
|
||||
# No explicit status provided for the second line - item status is retained
|
||||
sub_2.refresh_from_db()
|
||||
self.assertEqual(sub_2.location, self.item.location)
|
||||
self.assertEqual(sub_2.status, StockStatus.ATTENTION.value)
|
||||
|
||||
def test_installed_items_variants_and_substitutes(self):
|
||||
"""Installed variant and substitute parts are matched against BOM lines."""
|
||||
# Designate part 2 as a substitute for BOM line 4
|
||||
part.models.BomItemSubstitute.objects.create(
|
||||
bom_item=part.models.BomItem.objects.get(pk=4),
|
||||
part=part.models.Part.objects.get(pk=2),
|
||||
)
|
||||
|
||||
# New BOM line against a template part, allowing variants
|
||||
bom_line = part.models.BomItem.objects.create(
|
||||
part=self.assembly,
|
||||
sub_part=part.models.Part.objects.get(pk=10000),
|
||||
quantity=5,
|
||||
allow_variants=True,
|
||||
)
|
||||
|
||||
substitute_item = self.install_item(2, 10)
|
||||
variant_item = self.install_item(10001, 20)
|
||||
|
||||
response = self.post(
|
||||
self.url,
|
||||
{
|
||||
'items': [
|
||||
{'bom_item': 4, 'quantity': 30},
|
||||
{'bom_item': bom_line.pk, 'quantity': 50},
|
||||
],
|
||||
'quantity': 10,
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 4)
|
||||
|
||||
for sub in [substitute_item, variant_item]:
|
||||
sub.refresh_from_db()
|
||||
self.assertIsNone(sub.belongs_to)
|
||||
|
||||
# Line quantities are reduced by the matched installed items
|
||||
new_50 = StockItem.objects.get(part=50, parent=self.item)
|
||||
self.assertEqual(new_50.quantity, 20)
|
||||
|
||||
new_chair = StockItem.objects.get(part=10000, parent=self.item)
|
||||
self.assertEqual(new_chair.quantity, 30)
|
||||
|
||||
def test_serialized(self):
|
||||
"""Test disassembly of a serialized stock item."""
|
||||
item = StockItem.objects.create(
|
||||
part=self.assembly, quantity=1, serial='SN-DISASSEMBLE-1'
|
||||
)
|
||||
|
||||
url = reverse('api-stock-item-disassemble', kwargs={'pk': item.pk})
|
||||
|
||||
self.post(
|
||||
url,
|
||||
{'items': [{'bom_item': 1, 'quantity': 10}], 'quantity': 1},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
item.refresh_from_db()
|
||||
|
||||
# A serialized item cannot be "depleted" - it is marked as destroyed instead
|
||||
self.assertEqual(item.status, StockStatus.DESTROYED.value)
|
||||
self.assertFalse(item.in_stock)
|
||||
|
||||
|
||||
class StocktakeTest(StockAPITestCase):
|
||||
"""Series of tests for the Stocktake API."""
|
||||
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@ export enum ApiEndpoints {
|
|||
stock_assign = 'stock/assign/',
|
||||
stock_status = 'stock/status/',
|
||||
stock_convert = 'stock/:id/convert/',
|
||||
stock_disassemble = 'stock/:id/disassemble/',
|
||||
stock_install = 'stock/:id/install/',
|
||||
stock_uninstall = 'stock/:id/uninstall/',
|
||||
stock_serialize = 'stock/:id/serialize/',
|
||||
|
|
|
|||
|
|
@ -6,16 +6,19 @@ import { InvenTreeIcon } from '../../functions/icons';
|
|||
|
||||
export default function RemoveRowButton({
|
||||
onClick,
|
||||
disabled,
|
||||
tooltip = t`Remove this row`,
|
||||
tooltipAlignment
|
||||
}: Readonly<{
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
tooltip?: string;
|
||||
tooltipAlignment?: FloatingPosition;
|
||||
}>) {
|
||||
return (
|
||||
<ActionButton
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
icon={<InvenTreeIcon icon='square_x' />}
|
||||
tooltip={tooltip}
|
||||
tooltipAlignment={tooltipAlignment ?? 'top-end'}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { StylishText } from '@lib/components/StylishText';
|
|||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { formatDecimal } from '@lib/functions/Formatting';
|
||||
import { getDetailUrl } from '@lib/functions/Navigation';
|
||||
import type {
|
||||
ApiFormAdjustFilterType,
|
||||
|
|
@ -14,18 +15,24 @@ import type {
|
|||
import { t } from '@lingui/core/macro';
|
||||
import {
|
||||
Alert,
|
||||
Collapse,
|
||||
Flex,
|
||||
Group,
|
||||
List,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
Text
|
||||
Text,
|
||||
UnstyledButton
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { modals } from '@mantine/modals';
|
||||
import {
|
||||
IconCalendarExclamation,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconCoins,
|
||||
IconCurrencyDollar,
|
||||
IconLink,
|
||||
|
|
@ -37,6 +44,7 @@ import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
|
|||
import dayjs from 'dayjs';
|
||||
import {
|
||||
type JSX,
|
||||
type ReactNode,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
|
|
@ -44,7 +52,7 @@ import {
|
|||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../App';
|
||||
import RemoveRowButton from '../components/buttons/RemoveRowButton';
|
||||
|
|
@ -54,8 +62,14 @@ import {
|
|||
type TableFieldRowProps
|
||||
} from '../components/forms/fields/TableField';
|
||||
import { Thumbnail } from '../components/images/Thumbnail';
|
||||
import { StatusRenderer } from '../components/render/StatusRenderer';
|
||||
import { RenderStockLocation } from '../components/render/Stock';
|
||||
import {
|
||||
StatusRenderer,
|
||||
getStatusCodeOptions
|
||||
} from '../components/render/StatusRenderer';
|
||||
import {
|
||||
RenderStockItem,
|
||||
RenderStockLocation
|
||||
} from '../components/render/Stock';
|
||||
import { StatusFilterOptions } from '../components/tables/Filter';
|
||||
import { InvenTreeIcon } from '../functions/icons';
|
||||
import {
|
||||
|
|
@ -438,6 +452,497 @@ export function useStockItemSerializeFields({
|
|||
}, [serialGenerator.result]);
|
||||
}
|
||||
|
||||
function DisassemblyLineRow({
|
||||
props,
|
||||
record,
|
||||
installedItems,
|
||||
serialized,
|
||||
statuses
|
||||
}: Readonly<{
|
||||
props: TableFieldRowProps;
|
||||
record: any;
|
||||
installedItems: any[];
|
||||
serialized: boolean;
|
||||
statuses: any;
|
||||
}>) {
|
||||
// Number of assemblies being disassembled (top-level form field)
|
||||
const assemblies = useWatch({ name: 'quantity' });
|
||||
|
||||
// Once the user manually edits the row quantity, stop auto-scaling it
|
||||
const [edited, setEdited] = useState<boolean>(false);
|
||||
|
||||
const [installedOpen, installedHandlers] = useDisclosure(false);
|
||||
|
||||
const [locationOpen, locationHandlers] = useDisclosure(false, {
|
||||
onClose: () => props.changeFn(props.rowId, 'location', undefined)
|
||||
});
|
||||
|
||||
const [statusOpen, statusHandlers] = useDisclosure(false, {
|
||||
onClose: () => props.changeFn(props.rowId, 'status', undefined)
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (edited) {
|
||||
return;
|
||||
}
|
||||
|
||||
const n = Number(assemblies);
|
||||
const unit = Number(record.quantity);
|
||||
|
||||
if (Number.isFinite(n) && Number.isFinite(unit)) {
|
||||
const expected = unit * n;
|
||||
|
||||
if (props.item.quantity !== expected) {
|
||||
props.changeFn(props.rowId, 'quantity', expected);
|
||||
}
|
||||
}
|
||||
}, [assemblies, edited]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Group gap='xs' justify='left' wrap='nowrap'>
|
||||
<Thumbnail
|
||||
size={32}
|
||||
src={record.sub_part_detail?.thumbnail}
|
||||
align='center'
|
||||
/>
|
||||
<Stack gap={2}>
|
||||
<div>{record.sub_part_detail?.name}</div>
|
||||
{installedItems.length > 0 && (
|
||||
<UnstyledButton
|
||||
onClick={() => installedHandlers.toggle()}
|
||||
aria-label={`toggle-installed-items-${props.rowId}`}
|
||||
>
|
||||
<Group gap={4} wrap='nowrap'>
|
||||
{installedOpen ? (
|
||||
<IconChevronUp size={14} />
|
||||
) : (
|
||||
<IconChevronDown size={14} />
|
||||
)}
|
||||
<Text size='xs' c='blue'>
|
||||
{t`Installed Items`}: {installedItems.length}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>{record.quantity}</Table.Td>
|
||||
<Table.Td style={{ whiteSpace: 'nowrap' }}>
|
||||
{serialized ? (
|
||||
// Quantity is fixed when disassembling a serialized stock item
|
||||
<Text size='sm' aria-label='text-field-quantity'>
|
||||
{formatDecimal(props.item.quantity)}
|
||||
</Text>
|
||||
) : (
|
||||
<TableFieldQuantityInput
|
||||
min={0}
|
||||
value={props.item.quantity ?? ''}
|
||||
onChange={(value) => {
|
||||
setEdited(true);
|
||||
props.changeFn(props.rowId, 'quantity', value);
|
||||
}}
|
||||
error={props.rowErrors?.quantity?.message}
|
||||
/>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td style={{ whiteSpace: 'nowrap' }}>
|
||||
<NumberInput
|
||||
radius='sm'
|
||||
aria-label='number-field-purchase_price'
|
||||
placeholder={t`Automatic`}
|
||||
min={0}
|
||||
decimalScale={6}
|
||||
value={props.item.purchase_price ?? ''}
|
||||
onChange={(value) => {
|
||||
props.changeFn(
|
||||
props.rowId,
|
||||
'purchase_price',
|
||||
value === '' ? null : value
|
||||
);
|
||||
}}
|
||||
error={props.rowErrors?.purchase_price?.message}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td style={{ width: '1%', whiteSpace: 'nowrap' }}>
|
||||
<Flex gap='1px'>
|
||||
<ActionButton
|
||||
size='sm'
|
||||
onClick={() => locationHandlers.toggle()}
|
||||
icon={<InvenTreeIcon icon='location' />}
|
||||
tooltip={t`Set Location`}
|
||||
tooltipAlignment='top'
|
||||
variant={locationOpen ? 'outline' : 'transparent'}
|
||||
/>
|
||||
<ActionButton
|
||||
size='sm'
|
||||
onClick={() => statusHandlers.toggle()}
|
||||
icon={<InvenTreeIcon icon='status' />}
|
||||
tooltip={t`Change Status`}
|
||||
tooltipAlignment='top'
|
||||
variant={statusOpen ? 'outline' : 'transparent'}
|
||||
/>
|
||||
</Flex>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<RemoveRowButton
|
||||
onClick={() => props.removeFn(props.rowId)}
|
||||
disabled={installedItems.length > 0}
|
||||
tooltip={
|
||||
installedItems.length > 0
|
||||
? t`This row cannot be removed as it has installed items`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{installedItems.length > 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6} style={{ padding: 0, borderBottom: 'none' }}>
|
||||
<Collapse expanded={installedOpen}>
|
||||
<Paper p='xs' pl='xl'>
|
||||
<Stack gap='xs'>
|
||||
<Text size='xs' c='dimmed'>
|
||||
{t`The following installed items will be uninstalled during disassembly`}
|
||||
</Text>
|
||||
{installedItems.map((item: any) => (
|
||||
<RenderStockItem key={item.pk} instance={item} />
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Collapse>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
<TableFieldExtraRow
|
||||
visible={locationOpen}
|
||||
fieldName='location'
|
||||
onValueChange={(value) =>
|
||||
props.changeFn(props.rowId, 'location', value)
|
||||
}
|
||||
fieldDefinition={{
|
||||
field_type: 'related field',
|
||||
model: ModelType.stocklocation,
|
||||
api_url: apiUrl(ApiEndpoints.stock_location_list),
|
||||
filters: {
|
||||
structural: false
|
||||
},
|
||||
value: props.item.location,
|
||||
label: t`Location`,
|
||||
description: t`Custom location for the component items`,
|
||||
icon: <InvenTreeIcon icon='location' />
|
||||
}}
|
||||
error={props.rowErrors?.location?.message}
|
||||
/>
|
||||
<TableFieldExtraRow
|
||||
visible={statusOpen}
|
||||
fieldName='status'
|
||||
defaultValue={10}
|
||||
onValueChange={(value) => props.changeFn(props.rowId, 'status', value)}
|
||||
fieldDefinition={{
|
||||
field_type: 'choice',
|
||||
api_url: apiUrl(ApiEndpoints.stock_status),
|
||||
choices: statuses,
|
||||
label: t`Status`,
|
||||
description: t`Status for the component items`
|
||||
}}
|
||||
error={props.rowErrors?.status?.message}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a summary of the stock item which is about to be disassembled.
|
||||
*/
|
||||
function DisassemblyItemInfo({
|
||||
stockItem
|
||||
}: Readonly<{
|
||||
stockItem: any;
|
||||
}>) {
|
||||
const serialized: boolean =
|
||||
!!stockItem.serial && Number(stockItem.quantity) == 1;
|
||||
|
||||
const details: { label: string; value: ReactNode }[] = useMemo(() => {
|
||||
const rows = [
|
||||
{
|
||||
label: t`Location`,
|
||||
value: stockItem.location_detail?.pathstring ?? t`No location set`
|
||||
}
|
||||
];
|
||||
|
||||
if (serialized) {
|
||||
rows.push({
|
||||
label: t`Serial Number`,
|
||||
value: stockItem.serial
|
||||
});
|
||||
} else {
|
||||
rows.push({
|
||||
label: t`Quantity`,
|
||||
value: formatDecimal(stockItem.quantity)
|
||||
});
|
||||
}
|
||||
|
||||
if (stockItem.batch) {
|
||||
rows.push({
|
||||
label: t`Batch Code`,
|
||||
value: stockItem.batch
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}, [stockItem, serialized]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm'>
|
||||
<Group gap='md' wrap='nowrap' align='center'>
|
||||
<Thumbnail
|
||||
size={56}
|
||||
src={stockItem.part_detail?.thumbnail}
|
||||
align='center'
|
||||
/>
|
||||
<Group grow wrap='nowrap'>
|
||||
<Stack gap={2}>
|
||||
<Text fw={700}>
|
||||
{stockItem.part_detail?.full_name ?? stockItem.part_detail?.name}
|
||||
</Text>
|
||||
{stockItem.part_detail?.description && (
|
||||
<Text size='sm' c='dimmed'>
|
||||
{stockItem.part_detail.description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Table withRowBorders={false} verticalSpacing={2}>
|
||||
<Table.Tbody>
|
||||
{details.map((row) => (
|
||||
<Table.Tr key={row.label}>
|
||||
<Table.Td style={{ width: '1%', whiteSpace: 'nowrap' }}>
|
||||
<Text size='sm' c='dimmed'>
|
||||
{row.label}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size='sm'>{row.value}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display any installed stock items which could not be matched
|
||||
* against a BOM line item for the disassembly operation.
|
||||
*/
|
||||
function DisassemblyLeftoverItems({
|
||||
items
|
||||
}: Readonly<{
|
||||
items: any[];
|
||||
}>) {
|
||||
if (items.length == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert
|
||||
color='yellow'
|
||||
icon={<IconPackage />}
|
||||
title={t`Additional Installed Items`}
|
||||
>
|
||||
<Stack gap='xs'>
|
||||
<Text size='sm'>
|
||||
{t`The following items are installed within this stock item, but do not match a listed component. They will be uninstalled during disassembly.`}
|
||||
</Text>
|
||||
{items.map((item: any) => (
|
||||
<RenderStockItem key={item.pk} instance={item} />
|
||||
))}
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Form for disassembling a stock item into its component parts,
|
||||
* based on the Bill of Materials for the associated part.
|
||||
*/
|
||||
export function useDisassembleStockItem({
|
||||
stockItem,
|
||||
refresh
|
||||
}: {
|
||||
stockItem: any;
|
||||
refresh: () => void;
|
||||
}) {
|
||||
// Fetch BOM lines for the part associated with the stock item
|
||||
const bomQuery = useQuery({
|
||||
queryKey: ['bom-items-disassemble', stockItem.pk, stockItem.part],
|
||||
enabled: !!stockItem.part && (stockItem.part_detail?.assembly ?? false),
|
||||
queryFn: async () =>
|
||||
api
|
||||
.get(apiUrl(ApiEndpoints.bom_list), {
|
||||
params: {
|
||||
part: stockItem.part,
|
||||
sub_part_detail: true,
|
||||
substitutes: true
|
||||
}
|
||||
})
|
||||
.then((response) => response.data ?? [])
|
||||
});
|
||||
|
||||
// Fetch any stock items which are installed within this stock item
|
||||
const installedQuery = useQuery({
|
||||
queryKey: ['installed-items-disassemble', stockItem.pk],
|
||||
enabled: !!stockItem.pk && (stockItem.part_detail?.assembly ?? false),
|
||||
queryFn: async () =>
|
||||
api
|
||||
.get(apiUrl(ApiEndpoints.stock_item_list), {
|
||||
params: {
|
||||
belongs_to: stockItem.pk,
|
||||
part_detail: true
|
||||
}
|
||||
})
|
||||
.then((response) => response.data ?? [])
|
||||
});
|
||||
|
||||
const bomItems: any[] = useMemo(() => {
|
||||
// Consumable BOM lines are excluded from disassembly,
|
||||
// as are any lines which point to a virtual part
|
||||
return (bomQuery.data ?? []).filter(
|
||||
(elem: any) =>
|
||||
!elem.consumable &&
|
||||
!elem.sub_part_detail?.consumable &&
|
||||
!elem.sub_part_detail?.virtual
|
||||
);
|
||||
}, [bomQuery.data]);
|
||||
|
||||
const records = useMemo(
|
||||
() => Object.fromEntries(bomItems.map((elem: any) => [elem.pk, elem])),
|
||||
[bomItems]
|
||||
);
|
||||
|
||||
// Map installed stock items against the available BOM lines.
|
||||
// Note: This mirrors the matching performed by the server during disassembly,
|
||||
// matching against the referenced sub_part or any designated substitute parts.
|
||||
// Variants of the sub_part cannot be resolved client-side, so any such items
|
||||
// are reported as "leftover" items instead.
|
||||
const { installedMap, leftoverItems } = useMemo(() => {
|
||||
const map: Record<number, any[]> = {};
|
||||
const leftover: any[] = [];
|
||||
|
||||
for (const item of installedQuery.data ?? []) {
|
||||
const bomItem = bomItems.find(
|
||||
(elem: any) =>
|
||||
elem.sub_part === item.part ||
|
||||
elem.substitutes?.some((sub: any) => sub.part === item.part)
|
||||
);
|
||||
|
||||
if (bomItem) {
|
||||
map[bomItem.pk] = [...(map[bomItem.pk] ?? []), item];
|
||||
} else {
|
||||
leftover.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return { installedMap: map, leftoverItems: leftover };
|
||||
}, [bomItems, installedQuery.data]);
|
||||
|
||||
// A serialized stock item must be disassembled in its entirety
|
||||
const serialized: boolean =
|
||||
!!stockItem.serial && Number(stockItem.quantity) == 1;
|
||||
|
||||
const stockStatusCodes = useMemo(
|
||||
() => getStatusCodeOptions(ModelType.stockitem),
|
||||
[]
|
||||
);
|
||||
|
||||
const fields: ApiFormFieldSet = useMemo(() => {
|
||||
return {
|
||||
quantity: {
|
||||
disabled: serialized
|
||||
},
|
||||
location: {
|
||||
filters: {
|
||||
structural: false
|
||||
}
|
||||
},
|
||||
items: {
|
||||
field_type: 'table',
|
||||
value: bomItems.map((elem: any) => {
|
||||
return {
|
||||
id: elem.pk,
|
||||
bom_item: elem.pk,
|
||||
quantity:
|
||||
(Number(elem.quantity) || 0) * (Number(stockItem.quantity) || 0),
|
||||
purchase_price: null,
|
||||
purchase_price_currency:
|
||||
stockItem.purchase_price_currency ?? undefined
|
||||
};
|
||||
}),
|
||||
modelRenderer: (row: TableFieldRowProps) => {
|
||||
const record = records[row.item.bom_item];
|
||||
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DisassemblyLineRow
|
||||
props={row}
|
||||
record={record}
|
||||
installedItems={installedMap[row.item.bom_item] ?? []}
|
||||
serialized={serialized}
|
||||
statuses={stockStatusCodes}
|
||||
key={row.rowId}
|
||||
/>
|
||||
);
|
||||
},
|
||||
headers: [
|
||||
{ title: t`Component`, style: { minWidth: '200px' } },
|
||||
{ title: t`Unit Quantity`, style: { width: '100px' } },
|
||||
{ title: t`Quantity`, style: { width: '200px' } },
|
||||
{ title: t`Unit Price`, style: { width: '200px' } },
|
||||
{ title: t`Actions` },
|
||||
{ title: '', style: { width: '50px' } }
|
||||
]
|
||||
},
|
||||
notes: {}
|
||||
};
|
||||
}, [
|
||||
bomItems,
|
||||
records,
|
||||
installedMap,
|
||||
stockItem,
|
||||
serialized,
|
||||
stockStatusCodes
|
||||
]);
|
||||
|
||||
return useCreateApiFormModal({
|
||||
url: ApiEndpoints.stock_disassemble,
|
||||
pk: stockItem.pk,
|
||||
title: t`Disassemble Stock Item`,
|
||||
fields: fields,
|
||||
preFormContent: <DisassemblyItemInfo stockItem={stockItem} />,
|
||||
postFormContent: <DisassemblyLeftoverItems items={leftoverItems} />,
|
||||
initialData: {
|
||||
quantity: stockItem.quantity,
|
||||
location: stockItem.location
|
||||
},
|
||||
size: '80%',
|
||||
successMessage: t`Stock item disassembled`,
|
||||
onFormSuccess: refresh,
|
||||
onOpen: () => {
|
||||
// Ensure the installed items data is up to date when the form is opened
|
||||
installedQuery.refetch();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function StockItemDefaultMove({
|
||||
stockItem,
|
||||
value
|
||||
|
|
|
|||
|
|
@ -232,6 +232,8 @@ export default function BuildDetail() {
|
|||
part_detail: true,
|
||||
tags: true
|
||||
},
|
||||
hasPrimaryKey: true,
|
||||
defaultValue: {},
|
||||
refetchOnMount: true
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
import {
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconArrowsSplit,
|
||||
IconBookmark,
|
||||
IconBoxPadding,
|
||||
IconChecklist,
|
||||
|
|
@ -69,6 +70,7 @@ import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
|
|||
import { useApi } from '../../contexts/ApiContext';
|
||||
import { formatCurrency, formatDecimal } from '../../defaults/formatters';
|
||||
import {
|
||||
useDisassembleStockItem,
|
||||
useFindSerialNumberForm,
|
||||
useStockFields,
|
||||
useStockItemSerializeFields
|
||||
|
|
@ -864,6 +866,11 @@ export default function StockDetail() {
|
|||
successMessage: t`Stock item serialized`
|
||||
});
|
||||
|
||||
const disassembleStockItem = useDisassembleStockItem({
|
||||
stockItem: stockitem,
|
||||
refresh: refreshInstance
|
||||
});
|
||||
|
||||
const orderPartsWizard = OrderPartsWizard({
|
||||
parts: stockitem.part_detail ? [stockitem.part_detail] : []
|
||||
});
|
||||
|
|
@ -950,6 +957,18 @@ export default function StockDetail() {
|
|||
serializeStockItem.open();
|
||||
}
|
||||
},
|
||||
{
|
||||
name: t`Disassemble`,
|
||||
tooltip: t`Disassemble this stock item into its component parts`,
|
||||
hidden:
|
||||
!user.hasAddRole(UserRoles.stock) ||
|
||||
!stockitem.in_stock ||
|
||||
stockitem.part_detail?.assembly != true,
|
||||
icon: <IconArrowsSplit color='blue' />,
|
||||
onClick: () => {
|
||||
disassembleStockItem.open();
|
||||
}
|
||||
},
|
||||
{
|
||||
name: t`Order`,
|
||||
tooltip: t`Order Stock`,
|
||||
|
|
@ -1118,6 +1137,7 @@ export default function StockDetail() {
|
|||
{convertStockItem.modal}
|
||||
{duplicateStockItem.modal}
|
||||
{serializeStockItem.modal}
|
||||
{disassembleStockItem.modal}
|
||||
{stockAdjustActions.modals.map((modal) => modal.modal)}
|
||||
{orderPartsWizard.wizard}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -148,6 +148,7 @@ test('Build Order - Tags', async ({ browser }) => {
|
|||
|
||||
// Check for expected results
|
||||
await page.getByRole('cell', { name: 'BO0026' }).click();
|
||||
await page.getByText('Build Order: BO0026').first().waitFor();
|
||||
await page.getByText('100 x 002.01-PCBA | Widget').waitFor();
|
||||
|
||||
// Check for tags displayed on BuildOrder detail page
|
||||
|
|
@ -279,6 +280,7 @@ test('Build Order - Build Outputs', async ({ browser }) => {
|
|||
await page.getByRole('cell', { name: 'BO0011' }).click();
|
||||
await loadTab(page, 'Incomplete Outputs');
|
||||
await page.getByRole('cell', { name: 'BX-123' }).waitFor();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Check the "printing" actions for the selected outputs
|
||||
await page.getByRole('checkbox', { name: 'Select all records' }).check();
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import { expect, test } from '../baseFixtures.js';
|
||||
import { stevenuser } from '../defaults.js';
|
||||
import {
|
||||
activateCalendarView,
|
||||
clearTableFilters,
|
||||
clickButtonIfVisible,
|
||||
clickOnRowMenu,
|
||||
loadTab,
|
||||
navigate,
|
||||
openFilterDrawer,
|
||||
|
|
@ -585,208 +583,68 @@ test('Stock - Location', async ({ browser }) => {
|
|||
await page.getByText('No match found for barcode data').waitFor();
|
||||
});
|
||||
|
||||
test('Transfer Orders - General', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
// Test disassembly of an assembled stock item
|
||||
test('Stock - Disassembly', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, { url: 'stock/item/1387/details' });
|
||||
|
||||
await page.getByRole('tab', { name: 'Stock' }).click();
|
||||
await page.waitForURL('**/stock/location/index/**');
|
||||
|
||||
await loadTab(page, 'Transfer Orders');
|
||||
|
||||
await clearTableFilters(page);
|
||||
|
||||
// We have now loaded the "Transfer Orders" table. Check for some expected texts
|
||||
await page.getByText('Complete').first().waitFor();
|
||||
await page.getByText('Issued').first().waitFor();
|
||||
await page.getByText('Cancelled').first().waitFor();
|
||||
|
||||
// Load a particular Transfer Order
|
||||
await page.getByRole('cell', { name: 'TO-0002' }).click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// This transfer order should be "issued"
|
||||
await page.getByText('Issued').first().waitFor();
|
||||
|
||||
// Edit the transfer order (via keyboard shortcut)
|
||||
await page.keyboard.press('Control+E');
|
||||
await page.getByLabel('text-field-reference', { exact: true }).waitFor();
|
||||
await page.getByLabel('related-field-project_code').waitFor();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete Order' }).click();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
// Check for other expected actions
|
||||
await page.getByRole('button', { name: 'action-menu-order-actions' }).click();
|
||||
await page.getByLabel('action-menu-order-actions-edit').waitFor();
|
||||
await page.getByLabel('action-menu-order-actions-duplicate').waitFor();
|
||||
await page.getByLabel('action-menu-order-actions-hold').waitFor();
|
||||
|
||||
// Click on some tabs
|
||||
await loadTab(page, 'Line Items');
|
||||
await loadTab(page, 'Allocated Stock');
|
||||
await loadTab(page, 'Parameters');
|
||||
await loadTab(page, 'Attachments');
|
||||
await loadTab(page, 'Notes');
|
||||
});
|
||||
|
||||
test('Transfer Order - Reference', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
// go to transfer orders
|
||||
await page.getByRole('tab', { name: 'Stock' }).click();
|
||||
await page.waitForURL('**/stock/location/index/**');
|
||||
await loadTab(page, 'Transfer Orders');
|
||||
|
||||
// click add button
|
||||
// Duplicate this stock item to avoid impacting other tests
|
||||
await page
|
||||
.getByRole('button', { name: 'action-button-add-transfer-' })
|
||||
.getByRole('button', { name: 'action-menu-stock-item-actions' })
|
||||
.click();
|
||||
|
||||
// Ensure a new reference is suggested
|
||||
await page
|
||||
.getByRole('menuitem', { name: 'action-menu-stock-item-actions-duplicate' })
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.waitForTimeout(100);
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
// Grab the Transfer Order reference
|
||||
const reference: string = await page
|
||||
.getByRole('textbox', { name: 'text-field-reference' })
|
||||
.inputValue();
|
||||
expect(reference).toMatch(/TO-\d+/);
|
||||
|
||||
await page.getByRole('textbox', { name: 'text-field-description' }).click();
|
||||
// Disassemble this stock item
|
||||
await page
|
||||
.getByRole('textbox', { name: 'text-field-description' })
|
||||
.fill('creating from playwrigh!');
|
||||
|
||||
// create the transfer order
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Item Created').waitFor();
|
||||
|
||||
// go back to stock page
|
||||
await page.getByRole('link', { name: 'Stock', exact: true }).click();
|
||||
.getByRole('button', { name: 'action-menu-stock-operations' })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('button', { name: 'action-button-add-transfer-' })
|
||||
.getByRole('menuitem', { name: 'action-menu-stock-operations-disassemble' })
|
||||
.click();
|
||||
|
||||
const nextReference: string = await page
|
||||
.getByRole('textbox', { name: 'text-field-reference' })
|
||||
.inputValue();
|
||||
expect(nextReference).toMatch(/TO-\d+/);
|
||||
// Perform partial disassembly
|
||||
await page
|
||||
.getByRole('textbox', {
|
||||
name: 'number-field-quantity',
|
||||
description: 'Number of assemblies to disassemble'
|
||||
})
|
||||
.fill('6');
|
||||
|
||||
// Ensure that the reference has incremented
|
||||
const refNumber = Number(reference.replace('TO-', ''));
|
||||
const nextRefNumber = Number(nextReference.replace('TO-', ''));
|
||||
expect(nextRefNumber).toBe(refNumber + 1);
|
||||
});
|
||||
|
||||
test('Transfer Order - Calendar', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await navigate(page, 'stock/location/index/transfer-orders');
|
||||
await activateCalendarView(page);
|
||||
|
||||
// Export calendar data
|
||||
await page.getByLabel('calendar-export-data').click();
|
||||
await page.getByRole('button', { name: 'Export', exact: true }).click();
|
||||
await page.getByText('Process completed successfully').waitFor();
|
||||
|
||||
// Required because we downloaded a file
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('Transfer Order - Edit', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await navigate(page, 'stock/transfer-order/2/');
|
||||
|
||||
// Check for expected text items
|
||||
await page.getByText('Consume some paint').first().waitFor();
|
||||
await page.getByText('2026-04-20').waitFor(); // Created date
|
||||
await page.getByText('2026-04-23').waitFor(); // Issue date
|
||||
await page.getByText('PRJ-HEL').waitFor(); // Project Code
|
||||
|
||||
await page.keyboard.press('Control+E');
|
||||
|
||||
// Edit start date
|
||||
await page.getByLabel('date-field-start_date').fill('2026-04-28');
|
||||
|
||||
// Submit the form
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Expect error
|
||||
await page.getByText('Errors exist for one or more form fields').waitFor();
|
||||
await page.getByText('Target date must be after start date').waitFor();
|
||||
|
||||
// Cancel the form
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
});
|
||||
|
||||
test('Transfer Order - Allocate and Transfer', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await navigate(page, 'stock/transfer-order/6/');
|
||||
|
||||
// Duplicate this transfer order, to ensure a fresh run each time
|
||||
await page.getByLabel('action-menu-order-actions').click();
|
||||
await page.getByLabel('action-menu-order-actions-duplicate').click();
|
||||
|
||||
// Submit the duplicate request and ensure it completes
|
||||
await page.getByRole('button', { name: 'Submit' }).isEnabled();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Item Created').waitFor();
|
||||
|
||||
// Issue the order
|
||||
await page.getByRole('button', { name: 'Issue Order' }).click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Issued', { exact: true }).first().waitFor();
|
||||
|
||||
await loadTab(page, 'Line Items');
|
||||
|
||||
// Allocate line item 1
|
||||
const cell1 = await page.getByText('C_100pF_0402', { exact: true });
|
||||
await clickOnRowMenu(cell1);
|
||||
await page.getByRole('menuitem', { name: 'Allocate Stock' }).click();
|
||||
await page.getByText('C_100pF_0402Location:Offsite').waitFor();
|
||||
await page.waitForTimeout(200);
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Allocate line item 1
|
||||
const cell2 = await page.getByText('R_2.2K_0603_1%', { exact: true });
|
||||
await clickOnRowMenu(cell2);
|
||||
await page.getByRole('menuitem', { name: 'Allocate Stock' }).click();
|
||||
await page.getByText('R_2.2K_0603_1%Location:').waitFor();
|
||||
await page.waitForTimeout(200);
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Complete the order
|
||||
await page.getByRole('button', { name: 'Complete Order' }).click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Complete', { exact: true }).first().waitFor();
|
||||
|
||||
// Tab should have changed to Transferred Stock
|
||||
await loadTab(page, 'Transferred Stock');
|
||||
await page.getByText('C_100pF_0402').waitFor();
|
||||
await page.getByText('2.2K resistor in 0603 SMD').waitFor();
|
||||
});
|
||||
|
||||
test('Transfer Orders - Duplicate', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, {
|
||||
url: 'stock/transfer-order/1/detail'
|
||||
});
|
||||
|
||||
await page.getByLabel('action-menu-order-actions').click();
|
||||
await page.getByLabel('action-menu-order-actions-duplicate').click();
|
||||
|
||||
// Ensure a new reference is suggested
|
||||
await expect(
|
||||
page.getByLabel('text-field-reference', { exact: true })
|
||||
).not.toBeEmpty();
|
||||
|
||||
// Submit the duplicate request and ensure it completes
|
||||
await page.getByRole('button', { name: 'Submit' }).isEnabled();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByRole('tab', { name: 'Order Details' }).waitFor();
|
||||
await page.getByRole('tab', { name: 'Order Details' }).click();
|
||||
|
||||
await page.getByText('Pending').first().waitFor();
|
||||
// Set custom status for one item
|
||||
await page
|
||||
.getByRole('row', { name: 'Thumbnail XT90-M 10 60 action' })
|
||||
.getByLabel('action-button-change-status')
|
||||
.click();
|
||||
await page
|
||||
.getByRole('combobox', { name: 'choice-field-status' })
|
||||
.fill('damaged');
|
||||
await page.getByRole('option', { name: 'Damaged' }).click();
|
||||
|
||||
// Specify different location for one item
|
||||
await page
|
||||
.getByRole('row', { name: 'Thumbnail XT90-F 10 60' })
|
||||
.getByLabel('action-button-set-location')
|
||||
.click();
|
||||
await page
|
||||
.getByRole('cell', { name: 'Location Custom location for' })
|
||||
.getByPlaceholder('Select location')
|
||||
.fill('factory');
|
||||
await page.getByRole('listbox').getByText('Factory', { exact: true }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Quantity of this assembly has been reduced
|
||||
await page.getByText('Quantity: 4').first().waitFor();
|
||||
|
||||
await loadTab(page, 'Child Items');
|
||||
await page.getByText('1 - 2 / 2').waitFor();
|
||||
|
||||
await page.getByRole('cell', { name: 'Thumbnail XT90-F' }).waitFor();
|
||||
await page.getByRole('cell', { name: 'Thumbnail XT90-M' }).waitFor();
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,215 @@
|
|||
import { expect, test } from '../baseFixtures.js';
|
||||
import {
|
||||
activateCalendarView,
|
||||
clearTableFilters,
|
||||
clickOnRowMenu,
|
||||
loadTab,
|
||||
navigate
|
||||
} from '../helpers.js';
|
||||
import { doCachedLogin } from '../login.js';
|
||||
|
||||
test('Transfer Orders - General', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await page.getByRole('tab', { name: 'Stock' }).click();
|
||||
await page.waitForURL('**/stock/location/index/**');
|
||||
|
||||
await loadTab(page, 'Transfer Orders');
|
||||
|
||||
await clearTableFilters(page);
|
||||
|
||||
// We have now loaded the "Transfer Orders" table. Check for some expected texts
|
||||
await page.getByText('Complete').first().waitFor();
|
||||
await page.getByText('Issued').first().waitFor();
|
||||
await page.getByText('Cancelled').first().waitFor();
|
||||
|
||||
// Load a particular Transfer Order
|
||||
await page.getByRole('cell', { name: 'TO-0002' }).click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// This transfer order should be "issued"
|
||||
await page.getByText('Issued').first().waitFor();
|
||||
|
||||
// Edit the transfer order (via keyboard shortcut)
|
||||
await page.keyboard.press('Control+E');
|
||||
await page.getByLabel('text-field-reference', { exact: true }).waitFor();
|
||||
await page.getByLabel('related-field-project_code').waitFor();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete Order' }).click();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
// Check for other expected actions
|
||||
await page.getByRole('button', { name: 'action-menu-order-actions' }).click();
|
||||
await page.getByLabel('action-menu-order-actions-edit').waitFor();
|
||||
await page.getByLabel('action-menu-order-actions-duplicate').waitFor();
|
||||
await page.getByLabel('action-menu-order-actions-hold').waitFor();
|
||||
|
||||
// Click on some tabs
|
||||
await loadTab(page, 'Line Items');
|
||||
await loadTab(page, 'Allocated Stock');
|
||||
await loadTab(page, 'Parameters');
|
||||
await loadTab(page, 'Attachments');
|
||||
await loadTab(page, 'Notes');
|
||||
});
|
||||
|
||||
test('Transfer Order - Reference', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
// go to transfer orders
|
||||
await page.getByRole('tab', { name: 'Stock' }).click();
|
||||
await page.waitForURL('**/stock/location/index/**');
|
||||
await loadTab(page, 'Transfer Orders');
|
||||
|
||||
// click add button
|
||||
await page
|
||||
.getByRole('button', { name: 'action-button-add-transfer-' })
|
||||
.click();
|
||||
|
||||
// Ensure a new reference is suggested
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
// Grab the Transfer Order reference
|
||||
const reference: string = await page
|
||||
.getByRole('textbox', { name: 'text-field-reference' })
|
||||
.inputValue();
|
||||
expect(reference).toMatch(/TO-\d+/);
|
||||
|
||||
await page.getByRole('textbox', { name: 'text-field-description' }).click();
|
||||
await page
|
||||
.getByRole('textbox', { name: 'text-field-description' })
|
||||
.fill('creating from playwrigh!');
|
||||
|
||||
// create the transfer order
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Item Created').waitFor();
|
||||
|
||||
// go back to stock page
|
||||
await page.getByRole('link', { name: 'Stock', exact: true }).click();
|
||||
await page
|
||||
.getByRole('button', { name: 'action-button-add-transfer-' })
|
||||
.click();
|
||||
|
||||
const nextReference: string = await page
|
||||
.getByRole('textbox', { name: 'text-field-reference' })
|
||||
.inputValue();
|
||||
expect(nextReference).toMatch(/TO-\d+/);
|
||||
|
||||
// Ensure that the reference has incremented
|
||||
const refNumber = Number(reference.replace('TO-', ''));
|
||||
const nextRefNumber = Number(nextReference.replace('TO-', ''));
|
||||
expect(nextRefNumber).toBe(refNumber + 1);
|
||||
});
|
||||
|
||||
test('Transfer Order - Calendar', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await navigate(page, 'stock/location/index/transfer-orders');
|
||||
await activateCalendarView(page);
|
||||
|
||||
// Export calendar data
|
||||
await page.getByLabel('calendar-export-data').click();
|
||||
await page.getByRole('button', { name: 'Export', exact: true }).click();
|
||||
await page.getByText('Process completed successfully').waitFor();
|
||||
|
||||
// Required because we downloaded a file
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('Transfer Order - Edit', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await navigate(page, 'stock/transfer-order/2/');
|
||||
|
||||
// Check for expected text items
|
||||
await page.getByText('Consume some paint').first().waitFor();
|
||||
await page.getByText('2026-04-20').waitFor(); // Created date
|
||||
await page.getByText('2026-04-23').waitFor(); // Issue date
|
||||
await page.getByText('PRJ-HEL').waitFor(); // Project Code
|
||||
|
||||
await page.keyboard.press('Control+E');
|
||||
|
||||
// Edit start date
|
||||
await page.getByLabel('date-field-start_date').fill('2026-04-28');
|
||||
|
||||
// Submit the form
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Expect error
|
||||
await page.getByText('Errors exist for one or more form fields').waitFor();
|
||||
await page.getByText('Target date must be after start date').waitFor();
|
||||
|
||||
// Cancel the form
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
});
|
||||
|
||||
test('Transfer Order - Allocate and Transfer', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await navigate(page, 'stock/transfer-order/6/');
|
||||
|
||||
// Duplicate this transfer order, to ensure a fresh run each time
|
||||
await page.getByLabel('action-menu-order-actions').click();
|
||||
await page.getByLabel('action-menu-order-actions-duplicate').click();
|
||||
|
||||
// Submit the duplicate request and ensure it completes
|
||||
await page.getByRole('button', { name: 'Submit' }).isEnabled();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Item Created').waitFor();
|
||||
|
||||
// Issue the order
|
||||
await page.getByRole('button', { name: 'Issue Order' }).click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Issued', { exact: true }).first().waitFor();
|
||||
|
||||
await loadTab(page, 'Line Items');
|
||||
|
||||
// Allocate line item 1
|
||||
const cell1 = await page.getByText('C_100pF_0402', { exact: true });
|
||||
await clickOnRowMenu(cell1);
|
||||
await page.getByRole('menuitem', { name: 'Allocate Stock' }).click();
|
||||
await page.getByText('C_100pF_0402Location:Offsite').waitFor();
|
||||
await page.waitForTimeout(200);
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Allocate line item 1
|
||||
const cell2 = await page.getByText('R_2.2K_0603_1%', { exact: true });
|
||||
await clickOnRowMenu(cell2);
|
||||
await page.getByRole('menuitem', { name: 'Allocate Stock' }).click();
|
||||
await page.getByText('R_2.2K_0603_1%Location:').waitFor();
|
||||
await page.waitForTimeout(200);
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Complete the order
|
||||
await page.getByRole('button', { name: 'Complete Order' }).click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Complete', { exact: true }).first().waitFor();
|
||||
|
||||
// Tab should have changed to Transferred Stock
|
||||
await loadTab(page, 'Transferred Stock');
|
||||
await page.getByText('C_100pF_0402').waitFor();
|
||||
await page.getByText('2.2K resistor in 0603 SMD').waitFor();
|
||||
});
|
||||
|
||||
test('Transfer Orders - Duplicate', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, {
|
||||
url: 'stock/transfer-order/1/detail'
|
||||
});
|
||||
|
||||
await page.getByLabel('action-menu-order-actions').click();
|
||||
await page.getByLabel('action-menu-order-actions-duplicate').click();
|
||||
|
||||
// Ensure a new reference is suggested
|
||||
await expect(
|
||||
page.getByLabel('text-field-reference', { exact: true })
|
||||
).not.toBeEmpty();
|
||||
|
||||
// Submit the duplicate request and ensure it completes
|
||||
await page.getByRole('button', { name: 'Submit' }).isEnabled();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByRole('tab', { name: 'Order Details' }).waitFor();
|
||||
await page.getByRole('tab', { name: 'Order Details' }).click();
|
||||
|
||||
await page.getByText('Pending').first().waitFor();
|
||||
});
|
||||
|
|
@ -233,10 +233,10 @@ test('Forms - DateTime Field', async ({ browser }) => {
|
|||
hour: string,
|
||||
minute: string
|
||||
) => {
|
||||
const field = page.getByLabel(fieldLabel);
|
||||
const field = page.getByLabel(fieldLabel).first();
|
||||
await expect(field).toBeVisible();
|
||||
await field.click();
|
||||
await page.getByRole('button', { name: day }).click();
|
||||
await page.getByRole('button', { name: day }).first().click();
|
||||
|
||||
const spinbuttons = page.getByRole('spinbutton');
|
||||
await spinbuttons.nth(0).fill(hour);
|
||||
|
|
|
|||
Loading…
Reference in New Issue