mirror of
https://github.com/inventree/InvenTree.git
synced 2026-02-12 09:17:13 +00:00
[enhancements] Stock tracking enhancements (#11260)
* Data migrations for StockItemTracking - Propagate the 'part' links * Enable filtering of stock tracking entries by part * Enable filtering by date range * Display stock tracking for part * Table enhancements * Bump API version * Display stock item column * Ensure 'quantity' is recorded for stock tracking entries * Add new global settings * Adds background task for deleting old stock tracking entries * Docs updates * Enhanced docs * Cast quantity to float * Rever data migration * Ensure part link gets created * Improved prefetch for API * Playwright testing * Tweak unit test thresholds --------- Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
@@ -7,7 +7,7 @@ The API schema as documented below is generated using the [drf-spectactular](htt
|
|||||||
|
|
||||||
## API Version
|
## API Version
|
||||||
|
|
||||||
This documentation is for API version: `352`
|
This documentation is for API version: `449`
|
||||||
|
|
||||||
!!! tip "API Schema History"
|
!!! tip "API Schema History"
|
||||||
We track API schema changes, and provide a snapshot of each API schema version in the [API schema repository](https://github.com/inventree/schema/).
|
We track API schema changes, and provide a snapshot of each API schema version in the [API schema repository](https://github.com/inventree/schema/).
|
||||||
|
|||||||
BIN
docs/docs/assets/images/stock/part_tracking_history.png
Normal file
BIN
docs/docs/assets/images/stock/part_tracking_history.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
BIN
docs/docs/assets/images/stock/stock_item_tracking_history.png
Normal file
BIN
docs/docs/assets/images/stock/stock_item_tracking_history.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
@@ -304,6 +304,6 @@ def on_post_build(*args, **kwargs):
|
|||||||
if missing:
|
if missing:
|
||||||
raise NotImplementedError(
|
raise NotImplementedError(
|
||||||
'Missing Settings:\n'
|
'Missing Settings:\n'
|
||||||
+ f"There are {len(missing)} missing settings in the '{group}' group:\n"
|
+ f"There are {len(missing)} missing settings in the '{group}' group:\n- "
|
||||||
+ '\n- '.join(missing)
|
+ '\n- '.join(missing)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
title: Part Stock History
|
title: Part Stocktake
|
||||||
---
|
---
|
||||||
|
|
||||||
## Part Stock History
|
## Part Stocktake
|
||||||
|
|
||||||
InvenTree can track the historical stock levels of parts, allowing users to view past stocktake data and generate reports based on this information.
|
InvenTree can record the historical stock levels of parts, allowing users to view past stocktake data and generate reports based on this information.
|
||||||
|
|
||||||
A *Stocktake* refers to a "snapshot" of stock levels for a particular part, at a specific point in time. Stocktake information is used for tracking a historical record of the quantity and value of part stock.
|
A *Stocktake* refers to a "snapshot" of stock levels for a particular part, at a specific point in time. Stocktake information is used for tracking a historical record of the quantity and value of part stock.
|
||||||
|
|
||||||
@@ -75,9 +75,9 @@ A dashboard widget is available for generating stocktake reports, which can be a
|
|||||||
|
|
||||||
Here, the user can specify the report parameters, and then click the *Generate Report* button to generate a new stocktake report based on the specified parameters.
|
Here, the user can specify the report parameters, and then click the *Generate Report* button to generate a new stocktake report based on the specified parameters.
|
||||||
|
|
||||||
## Stock History Settings
|
## Stocktake Settings
|
||||||
|
|
||||||
There are a number of configuration options available in the [settings view](../settings/global.md):
|
There are a number of configuration options available for controlling the behavior of part stocktake functionality in the [system settings view](../settings/global.md):
|
||||||
|
|
||||||
| Name | Description | Default | Units |
|
| Name | Description | Default | Units |
|
||||||
| ---- | ----------- | ------- | ----- |
|
| ---- | ----------- | ------- | ----- |
|
||||||
@@ -87,9 +87,9 @@ There are a number of configuration options available in the [settings view](../
|
|||||||
{{ globalsetting("STOCKTAKE_DELETE_OLD_ENTRIES")}}
|
{{ globalsetting("STOCKTAKE_DELETE_OLD_ENTRIES")}}
|
||||||
{{ globalsetting("STOCKTAKE_DELETE_DAYS") }}
|
{{ globalsetting("STOCKTAKE_DELETE_DAYS") }}
|
||||||
|
|
||||||
{{ image("part/part_stocktake_settings.png", "Stock history settings") }}
|
{{ image("part/part_stocktake_settings.png", "Stocktake settings") }}
|
||||||
|
|
||||||
### Enable Stock History
|
### Enable Stocktake
|
||||||
|
|
||||||
Enable or disable stocktake functionality. Note that by default, stocktake functionality is disabled.
|
Enable or disable stocktake functionality. Note that by default, stocktake functionality is disabled.
|
||||||
|
|
||||||
@@ -97,10 +97,10 @@ Enable or disable stocktake functionality. Note that by default, stocktake funct
|
|||||||
|
|
||||||
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 reports](#automatic-stocktake). If this value is set to zero, automatic stocktake reports will not be generated.
|
||||||
|
|
||||||
### Delete Old Stock History Entries
|
### Delete Old Stocktake Entries
|
||||||
|
|
||||||
If enabled, stock history entries older than the specified number of days will be automatically deleted from the database.
|
If enabled, stocktake entries older than the specified number of days will be automatically deleted from the database.
|
||||||
|
|
||||||
### Stock History Deletion Interval
|
### Stocktake Deletion Interval
|
||||||
|
|
||||||
Configure how many days historical stock records are retained in the database.
|
Configure how many days historical stocktake records are retained in the database.
|
||||||
|
|||||||
@@ -2,13 +2,15 @@
|
|||||||
title: Stock
|
title: Stock
|
||||||
---
|
---
|
||||||
|
|
||||||
## Stock Items
|
## Stock Item
|
||||||
|
|
||||||
A *Stock Item* is an actual instance of a [*Part*](../part/index.md) item. It represents a physical quantity of the *Part* in a specific location.
|
A *Stock Item* is an actual instance of a [*Part*](../part/index.md) item. It represents a physical quantity of the *Part* in a specific location.
|
||||||
|
|
||||||
|
Each Part instance may have multiple stock items associated with it, in various quantities and locations. Additionally, each stock item may have a serial number (if the part is tracked by serial number) and may be associated with a particular supplier part (if the item was purchased from a supplier).
|
||||||
|
|
||||||
### Stock Item Details
|
### Stock Item Details
|
||||||
|
|
||||||
The *Stock Item* detail view shows information regarding the particular stock item:
|
Each *Stock Item* is linked to the following information:
|
||||||
|
|
||||||
**Part** - Which *Part* this stock item is an instance of
|
**Part** - Which *Part* this stock item is an instance of
|
||||||
|
|
||||||
@@ -26,17 +28,27 @@ The *Stock Item* detail view shows information regarding the particular stock it
|
|||||||
|
|
||||||
**Status** - Status of this stock item
|
**Status** - Status of this stock item
|
||||||
|
|
||||||
### Stock Availability
|
**Serial Number** - If the part is tracked by serial number, the unique serial number of this stock item
|
||||||
|
|
||||||
|
**Batch Code** - If the part is tracked by batch code, the batch code of this stock item
|
||||||
|
|
||||||
|
## Stock Availability
|
||||||
|
|
||||||
InvenTree has a number of different mechanisms to determine whether stock is available for use. See the [Stock Availability](./availability.md) page for more information.
|
InvenTree has a number of different mechanisms to determine whether stock is available for use. See the [Stock Availability](./availability.md) page for more information.
|
||||||
|
|
||||||
### Stock Tracking
|
## Traceability
|
||||||
|
|
||||||
|
Stock items can be associated with a unique serial number and / or a batch code, which allows for traceability of individual stock items. This is particularly useful for tracking the history of specific items, and for ensuring that items can be traced back to their source (e.g. supplier, purchase order, etc).
|
||||||
|
|
||||||
|
Refer to the [traceability](./traceability.md) page for more information on how serial numbers and batch codes work in InvenTree.
|
||||||
|
|
||||||
|
## Stock Tracking
|
||||||
|
|
||||||
Every time a *Stock Item* is adjusted, a *Stock Tracking* entry is automatically created. This ensures a complete history of the *Stock Item* is maintained as long as the item is in the system.
|
Every time a *Stock Item* is adjusted, a *Stock Tracking* entry is automatically created. This ensures a complete history of the *Stock Item* is maintained as long as the item is in the system.
|
||||||
|
|
||||||
Each stock tracking historical item records the user who performed the action. [Read more about stock tracking here](./tracking.md).
|
Each stock tracking historical item records the user who performed the action. [Read more about stock tracking here](./tracking.md).
|
||||||
|
|
||||||
## Stock Location
|
## Stock Locations
|
||||||
|
|
||||||
A stock location represents a physical real-world location where *Stock Items* are stored. Locations are arranged in a cascading manner and each location may contain multiple sub-locations, or stock, or both.
|
A stock location represents a physical real-world location where *Stock Items* are stored. Locations are arranged in a cascading manner and each location may contain multiple sub-locations, or stock, or both.
|
||||||
|
|
||||||
@@ -60,3 +72,7 @@ in the build order line items view where the material is allocated.
|
|||||||
{{ image("stock/stock_external_icon.png", title="External stock indication") }}
|
{{ image("stock/stock_external_icon.png", title="External stock indication") }}
|
||||||
|
|
||||||
The external flag does not get inherited to sublocations.
|
The external flag does not get inherited to sublocations.
|
||||||
|
|
||||||
|
### Structural Locations
|
||||||
|
|
||||||
|
A stock location may be optionally marked as *structural*. Structural locations are used to represent physical locations which are not directly associated with stock items, but rather serve as a means of organizing the stock location hierarchy. For example, a structural location might represent a particular shelf or drawer within a warehouse, while the actual stock items are stored in sub-locations within that location.
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ Refer to the source code for the Stock status codes:
|
|||||||
show_source: True
|
show_source: True
|
||||||
members: []
|
members: []
|
||||||
|
|
||||||
|
### Custom Status Codes
|
||||||
|
|
||||||
Stock Status supports [custom states](../concepts/custom_states.md).
|
Stock Status supports [custom states](../concepts/custom_states.md).
|
||||||
|
|
||||||
### Default Status Code
|
### Default Status Code
|
||||||
|
|||||||
152
docs/docs/stock/traceability.md
Normal file
152
docs/docs/stock/traceability.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
---
|
||||||
|
title: Stock Traceability
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stock Traceability
|
||||||
|
|
||||||
|
It may be desirable to track individual stock items, or groups of stock items, with unique identifier values. Stock items may be *tracked* using either *Batch Codes* or *Serial Numbers*.
|
||||||
|
|
||||||
|
Individual stock items can be assigned a batch code, or a serial number, or both, or neither, as requirements dictate.
|
||||||
|
|
||||||
|
{{ image("stock/batch_and_serial.png", title="Batch and Serial Number Tracking") }}
|
||||||
|
|
||||||
|
Out of the box, the default implementations for both batch codes and serial numbers are (intentionally) simplistic.
|
||||||
|
|
||||||
|
As the particular requirements for serial number or batch code conventions may vary significantly from one application to another, InvenTree provides the ability for custom plugins to determine exactly how batch codes and serial numbers are implemented.
|
||||||
|
|
||||||
|
### Batch Codes
|
||||||
|
|
||||||
|
Batch codes can be used to specify a particular "group" of items, and can be assigned to any stock item without restriction. Batch codes are tracked even as stock items are split into separate items.
|
||||||
|
|
||||||
|
Multiple stock items may share the same batch code without restriction, even across different parts.
|
||||||
|
|
||||||
|
#### Generating Batch Codes
|
||||||
|
|
||||||
|
Batch codes can be generated automatically based on a provided pattern. The default pattern simply uses the current date-code as the batch number, however this can be customized within a certain scope.
|
||||||
|
|
||||||
|
{{ image("stock/batch_code_template.png", title="Batch code pattern") }}
|
||||||
|
|
||||||
|
#### Context Variables
|
||||||
|
|
||||||
|
The following context variables are available by default when generating a batch code using the builtin generation functionality:
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| year | The current year e.g. `2024` |
|
||||||
|
| month | The current month number, e.g. `5` |
|
||||||
|
| day | The current day of month, e.g. `21` |
|
||||||
|
| hour | The current hour of day, in 24-hour format, e.g. `23` |
|
||||||
|
| minute | The current minute of hour, e.g. `17` |
|
||||||
|
| week | The current week of year, e.g. `51` |
|
||||||
|
|
||||||
|
#### Plugin Support
|
||||||
|
|
||||||
|
To implement custom batch code functionality, refer to the details on the [Validation Plugin Mixin](../plugins/mixins/validation.md#batch-codes).
|
||||||
|
|
||||||
|
### Serial Numbers
|
||||||
|
|
||||||
|
A serial "number" is used to uniquely identify a single, unique stock item. Note that while *number* is used throughout the documentation, these values are not required to be numeric.
|
||||||
|
|
||||||
|
#### Uniqueness Requirements
|
||||||
|
|
||||||
|
By default, serial numbers must be unique across any given [Part](../part/index.md) instance (including any variants of that part).
|
||||||
|
|
||||||
|
However, it is also possible to specify that serial numbers must be globally unique across all types of parts. This is configurable in the settings display (see below):
|
||||||
|
|
||||||
|
{{ image("stock/serial_numbers_unique.png", title="Serial number uniqueness") }}
|
||||||
|
|
||||||
|
#### Generating Serial Numbers
|
||||||
|
|
||||||
|
When creating a group of serialized stock items, it can be very useful for the user to be able to generate a group of unique serial numbers, with one serial number for each serialized stock item.
|
||||||
|
|
||||||
|
{{ image("stock/serial_next.png", title="Serial number entry") }}
|
||||||
|
|
||||||
|
For a given serial number *schema* (either the in-built schema or a custom schema defined by a plugin), a group (or *range*) of serial numbers can be generated using a number of possible patterns:
|
||||||
|
|
||||||
|
##### Comma Separated Values
|
||||||
|
|
||||||
|
Individual serial numbers can be specified by separating using a comma character (`,`).
|
||||||
|
|
||||||
|
| Pattern | Serial Numbers |
|
||||||
|
| --- | --- |
|
||||||
|
| `1, 2, 45, 99, 101` | `1, 2, 45, 99, 101` |
|
||||||
|
|
||||||
|
##### Hyphen Separated Range
|
||||||
|
|
||||||
|
Use a hyphen character (`-`) to specify a *range* of sequential values, inclusive of the two values separated by the hyphen.
|
||||||
|
|
||||||
|
| Pattern | Serial Numbers |
|
||||||
|
| --- | --- |
|
||||||
|
| `10-15` | `10, 11, 12, 13, 14, 15` |
|
||||||
|
|
||||||
|
##### Starting Value Range
|
||||||
|
|
||||||
|
A *starting value* can be supplied, followed by the plus (`+`) character to indicate a number of sequential values following the provided starting value. The `+` character should be followed by an integer value to indicate the number of serial numbers which will be generated.
|
||||||
|
|
||||||
|
| Pattern | Serial Numbers |
|
||||||
|
| --- | --- |
|
||||||
|
| `10+3` | `10, 11, 12, 13` |
|
||||||
|
| `100 + 2` | `100, 101, 102` |
|
||||||
|
|
||||||
|
##### Next Value
|
||||||
|
|
||||||
|
When specifying serial numbers, the tilde (`~`) character is replaced with the next available serial number. It can be used in combination with the available patterns specified above.
|
||||||
|
|
||||||
|
For example, if the *next* available serial number is `100`, the following patterns can be used:
|
||||||
|
|
||||||
|
| Pattern | Serial Numbers |
|
||||||
|
| --- | --- |
|
||||||
|
| `~` | `100` |
|
||||||
|
| `~, ~, ~` | `100, 101, 102` |
|
||||||
|
| `800, ~, 900` | `800, 100, 900` |
|
||||||
|
| `~+5` | `100, 101, 102, 103, 104, 105` |
|
||||||
|
|
||||||
|
##### Combination Groups
|
||||||
|
|
||||||
|
Any of the above patterns can be combined using multiple groups separated by the comma (`,`) character:
|
||||||
|
|
||||||
|
| Pattern | Serial Numbers |
|
||||||
|
| --- | --- |
|
||||||
|
| `1, 2, 4-7, 10` | `1, 2, 4, 5, 6, 7, 10` |
|
||||||
|
| `40+4, 50+4` | `40, 41, 42, 43, 44, 50, 51, 52, 53, 54` |
|
||||||
|
| `10, 14, 20+3, 30-35` | `10, 14, 20, 21, 22, 23, 30, 31, 32, 33, 34, 35` |
|
||||||
|
|
||||||
|
In the default implementation, InvenTree assumes that serial "numbers" are integer values in a simple incrementing sequence e.g. `{1, 2, 3, 4, 5, 6}`. When generating the *next* value for a serial number, the algorithm looks for the *most recent* serial number, and attempts to coerce that value into an integer, and then increment that value.
|
||||||
|
|
||||||
|
While this approach is reasonably robust, it is definitely simplistic and is not expected to meet the requirements of every installation. For this reason, more complex serial number management is intended to be implemented using a custom plugin (see below).
|
||||||
|
|
||||||
|
#### Serial Number Errors
|
||||||
|
|
||||||
|
If a provided serial number (or group of numbers) is not considered valid, an error message is provided to the user.
|
||||||
|
|
||||||
|
##### Example: Invalid Quantity
|
||||||
|
|
||||||
|
{{ image("stock/serial_error_quantity.png", title="Serial number - invalid quantity") }}
|
||||||
|
|
||||||
|
##### Example: Duplicate Serial Numbers
|
||||||
|
|
||||||
|
{{ image("stock/serial_error_unique.png", title="Serial number - duplicate values") }}
|
||||||
|
|
||||||
|
##### Example: Invalid Serial Numbers
|
||||||
|
|
||||||
|
!!! tip "Serial Number Validation"
|
||||||
|
Custom serial number validation can be implemented using an external plugin
|
||||||
|
|
||||||
|
#### Adjusting Serial Numbers
|
||||||
|
|
||||||
|
Once a stock item has been created with a serial number, it is possible to adjust that serial number value if required. This can be achieved by navigating to the stock item's detail page, and selecting the "Edit" option from the actions menu. Here, the serial number value can be modified as required:
|
||||||
|
|
||||||
|
{{ image("stock/serial_edit.png", title="Editing a serial number") }}
|
||||||
|
|
||||||
|
Note that any serial number adjustments are subject to the same validation rules as when the stock item was created. If the new serial number value is not valid, an error message will be displayed to the user:
|
||||||
|
|
||||||
|
{{ image("stock/serial_edit_error.png", title="Error while editing a serial number") }}
|
||||||
|
|
||||||
|
|
||||||
|
#### Plugin Support
|
||||||
|
|
||||||
|
Custom serial number functionality, with any arbitrary requirements or level of complexity, can be implemented using the [Validation Plugin Mixin class](../plugins/mixins/validation.md#serial-numbers). Refer to the documentation for this plugin for technical details.
|
||||||
|
|
||||||
|
A custom plugin allows the user to determine how a "valid" serial number is defined, and (crucially) how any given serial number value is incremented to provide the next value in the sequence.
|
||||||
|
|
||||||
|
Implementing custom methods for these two considerations allows for complex serial number schema to be supported with minimal effort.
|
||||||
@@ -4,149 +4,47 @@ title: Stock Tracking
|
|||||||
|
|
||||||
## Stock Tracking
|
## Stock Tracking
|
||||||
|
|
||||||
It may be desirable to track individual stock items, or groups of stock items, with unique identifier values. Stock items may be *tracked* using either *Batch Codes* or *Serial Numbers*.
|
Stock tracking entries record the history of stock item adjustments, including the user who performed the action, the date of the action, and the quantity change. This allows users to maintain a complete history of stock item movements and adjustments over time.
|
||||||
|
|
||||||
Individual stock items can be assigned a batch code, or a serial number, or both, or neither, as requirements dictate.
|
### Tracking Events
|
||||||
|
|
||||||
{{ image("stock/batch_and_serial.png", title="Batch and Serial Number Tracking") }}
|
Stock tracking entries are created automatically whenever a stock item is adjusted, either through manual adjustments or through automated processes such as order fulfillment or build completion.
|
||||||
|
|
||||||
Out of the box, the default implementations for both batch codes and serial numbers are (intentionally) simplistic.
|
Some examples of events that may trigger stock tracking entries include:
|
||||||
|
|
||||||
As the particular requirements for serial number or batch code conventions may vary significantly from one application to another, InvenTree provides the ability for custom plugins to determine exactly how batch codes and serial numbers are implemented.
|
- Manual stock adjustments (e.g. correcting inventory counts)
|
||||||
|
- Creation of new stock items (e.g. receiving new inventory)
|
||||||
|
- Allocation of stock items to orders (e.g. shipping items against sales orders)
|
||||||
|
- Consumption of stock items during build processes (e.g. using items to complete a build order)
|
||||||
|
|
||||||
### Batch Codes
|
## Viewing Stock Tracking History
|
||||||
|
|
||||||
Batch codes can be used to specify a particular "group" of items, and can be assigned to any stock item without restriction. Batch codes are tracked even as stock items are split into separate items.
|
There are multiple ways to view the stock tracking history for a particular stock item or part via the user interface.
|
||||||
|
|
||||||
Multiple stock items may share the same batch code without restriction, even across different parts.
|
### Stock Item Tracking History
|
||||||
|
|
||||||
#### Generating Batch Codes
|
The stock tracking history for a particular stock item can be viewed on the *Stock Item Detail* page, under the *Stock Tracking* tab:
|
||||||
|
|
||||||
Batch codes can be generated automatically based on a provided pattern. The default pattern simply uses the current date-code as the batch number, however this can be customized within a certain scope.
|
{{ image("stock/stock_item_tracking_history.png", title="Stock tracking tab") }}
|
||||||
|
|
||||||
{{ image("stock/batch_code_template.png", title="Batch code pattern") }}
|
This view displays all tracking entries associated with the particular stock item.
|
||||||
|
|
||||||
#### Context Variables
|
### Part Tracking History
|
||||||
|
|
||||||
The following context variables are available by default when generating a batch code using the builtin generation functionality:
|
Additionally, the stock tracking history for a particular part can be viewed on the *Part Detail* page, under the *Stock History* tab:
|
||||||
|
|
||||||
| Variable | Description |
|
{{ image("stock/part_tracking_history.png", title="Part stock tracking history") }}
|
||||||
| --- | --- |
|
|
||||||
| year | The current year e.g. `2024` |
|
|
||||||
| month | The current month number, e.g. `5` |
|
|
||||||
| day | The current day of month, e.g. `21` |
|
|
||||||
| hour | The current hour of day, in 24-hour format, e.g. `23` |
|
|
||||||
| minute | The current minute of hour, e.g. `17` |
|
|
||||||
| week | The current week of year, e.g. `51` |
|
|
||||||
|
|
||||||
#### Plugin Support
|
This view displays all tracking entries associated with any stock item linked to the particular part.
|
||||||
|
|
||||||
To implement custom batch code functionality, refer to the details on the [Validation Plugin Mixin](../plugins/mixins/validation.md#batch-codes).
|
!!! 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).
|
||||||
|
|
||||||
### Serial Numbers
|
## Stock Tracking Settings
|
||||||
|
|
||||||
A serial "number" is used to uniquely identify a single, unique stock item. Note that while *number* is used throughout the documentation, these values are not required to be numeric.
|
There are a number of configuration options available for controlling the behavior of stock tracking functionality in the [system settings view](../settings/global.md):
|
||||||
|
|
||||||
#### Uniqueness Requirements
|
| Name | Description | Default | Units |
|
||||||
|
| ---- | ----------- | ------- | ----- |
|
||||||
By default, serial numbers must be unique across any given [Part](../part/index.md) instance (including any variants of that part).
|
{{ globalsetting("STOCK_TRACKING_DELETE_OLD_ENTRIES") }}
|
||||||
|
{{ globalsetting("STOCK_TRACKING_DELETE_DAYS") }}
|
||||||
However, it is also possible to specify that serial numbers must be globally unique across all types of parts. This is configurable in the settings display (see below):
|
|
||||||
|
|
||||||
{{ image("stock/serial_numbers_unique.png", title="Serial number uniqueness") }}
|
|
||||||
|
|
||||||
#### Generating Serial Numbers
|
|
||||||
|
|
||||||
When creating a group of serialized stock items, it can be very useful for the user to be able to generate a group of unique serial numbers, with one serial number for each serialized stock item.
|
|
||||||
|
|
||||||
{{ image("stock/serial_next.png", title="Serial number entry") }}
|
|
||||||
|
|
||||||
For a given serial number *schema* (either the in-built schema or a custom schema defined by a plugin), a group (or *range*) of serial numbers can be generated using a number of possible patterns:
|
|
||||||
|
|
||||||
##### Comma Separated Values
|
|
||||||
|
|
||||||
Individual serial numbers can be specified by separating using a comma character (`,`).
|
|
||||||
|
|
||||||
| Pattern | Serial Numbers |
|
|
||||||
| --- | --- |
|
|
||||||
| `1, 2, 45, 99, 101` | `1, 2, 45, 99, 101` |
|
|
||||||
|
|
||||||
##### Hyphen Separated Range
|
|
||||||
|
|
||||||
Use a hyphen character (`-`) to specify a *range* of sequential values, inclusive of the two values separated by the hyphen.
|
|
||||||
|
|
||||||
| Pattern | Serial Numbers |
|
|
||||||
| --- | --- |
|
|
||||||
| `10-15` | `10, 11, 12, 13, 14, 15` |
|
|
||||||
|
|
||||||
##### Starting Value Range
|
|
||||||
|
|
||||||
A *starting value* can be supplied, followed by the plus (`+`) character to indicate a number of sequential values following the provided starting value. The `+` character should be followed by an integer value to indicate the number of serial numbers which will be generated.
|
|
||||||
|
|
||||||
| Pattern | Serial Numbers |
|
|
||||||
| --- | --- |
|
|
||||||
| `10+3` | `10, 11, 12, 13` |
|
|
||||||
| `100 + 2` | `100, 101, 102` |
|
|
||||||
|
|
||||||
##### Next Value
|
|
||||||
|
|
||||||
When specifying serial numbers, the tilde (`~`) character is replaced with the next available serial number. It can be used in combination with the available patterns specified above.
|
|
||||||
|
|
||||||
For example, if the *next* available serial number is `100`, the following patterns can be used:
|
|
||||||
|
|
||||||
| Pattern | Serial Numbers |
|
|
||||||
| --- | --- |
|
|
||||||
| `~` | `100` |
|
|
||||||
| `~, ~, ~` | `100, 101, 102` |
|
|
||||||
| `800, ~, 900` | `800, 100, 900` |
|
|
||||||
| `~+5` | `100, 101, 102, 103, 104, 105` |
|
|
||||||
|
|
||||||
##### Combination Groups
|
|
||||||
|
|
||||||
Any of the above patterns can be combined using multiple groups separated by the comma (`,`) character:
|
|
||||||
|
|
||||||
| Pattern | Serial Numbers |
|
|
||||||
| --- | --- |
|
|
||||||
| `1, 2, 4-7, 10` | `1, 2, 4, 5, 6, 7, 10` |
|
|
||||||
| `40+4, 50+4` | `40, 41, 42, 43, 44, 50, 51, 52, 53, 54` |
|
|
||||||
| `10, 14, 20+3, 30-35` | `10, 14, 20, 21, 22, 23, 30, 31, 32, 33, 34, 35` |
|
|
||||||
|
|
||||||
In the default implementation, InvenTree assumes that serial "numbers" are integer values in a simple incrementing sequence e.g. `{1, 2, 3, 4, 5, 6}`. When generating the *next* value for a serial number, the algorithm looks for the *most recent* serial number, and attempts to coerce that value into an integer, and then increment that value.
|
|
||||||
|
|
||||||
While this approach is reasonably robust, it is definitely simplistic and is not expected to meet the requirements of every installation. For this reason, more complex serial number management is intended to be implemented using a custom plugin (see below).
|
|
||||||
|
|
||||||
#### Serial Number Errors
|
|
||||||
|
|
||||||
If a provided serial number (or group of numbers) is not considered valid, an error message is provided to the user.
|
|
||||||
|
|
||||||
##### Example: Invalid Quantity
|
|
||||||
|
|
||||||
{{ image("stock/serial_error_quantity.png", title="Serial number - invalid quantity") }}
|
|
||||||
|
|
||||||
##### Example: Duplicate Serial Numbers
|
|
||||||
|
|
||||||
{{ image("stock/serial_error_unique.png", title="Serial number - duplicate values") }}
|
|
||||||
|
|
||||||
##### Example: Invalid Serial Numbers
|
|
||||||
|
|
||||||
!!! tip "Serial Number Validation"
|
|
||||||
Custom serial number validation can be implemented using an external plugin
|
|
||||||
|
|
||||||
#### Adjusting Serial Numbers
|
|
||||||
|
|
||||||
Once a stock item has been created with a serial number, it is possible to adjust that serial number value if required. This can be achieved by navigating to the stock item's detail page, and selecting the "Edit" option from the actions menu. Here, the serial number value can be modified as required:
|
|
||||||
|
|
||||||
{{ image("stock/serial_edit.png", title="Editing a serial number") }}
|
|
||||||
|
|
||||||
Note that any serial number adjustments are subject to the same validation rules as when the stock item was created. If the new serial number value is not valid, an error message will be displayed to the user:
|
|
||||||
|
|
||||||
{{ image("stock/serial_edit_error.png", title="Error while editing a serial number") }}
|
|
||||||
|
|
||||||
|
|
||||||
#### Plugin Support
|
|
||||||
|
|
||||||
Custom serial number functionality, with any arbitrary requirements or level of complexity, can be implemented using the [Validation Plugin Mixin class](../plugins/mixins/validation.md#serial-numbers). Refer to the documentation for this plugin for technical details.
|
|
||||||
|
|
||||||
A custom plugin allows the user to determine how a "valid" serial number is defined, and (crucially) how any given serial number value is incremented to provide the next value in the sequence.
|
|
||||||
|
|
||||||
Implementing custom methods for these two considerations allows for complex serial number schema to be supported with minimal effort.
|
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ nav:
|
|||||||
- Stock:
|
- Stock:
|
||||||
- Stock Items: stock/index.md
|
- Stock Items: stock/index.md
|
||||||
- Availability: stock/availability.md
|
- Availability: stock/availability.md
|
||||||
|
- Traceability: stock/traceability.md
|
||||||
- Stock Tracking: stock/tracking.md
|
- Stock Tracking: stock/tracking.md
|
||||||
- Stock Status: stock/status.md
|
- Stock Status: stock/status.md
|
||||||
- Adjusting Stock: stock/adjust.md
|
- Adjusting Stock: stock/adjust.md
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 449
|
INVENTREE_API_VERSION = 450
|
||||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
INVENTREE_API_TEXT = """
|
||||||
|
|
||||||
|
v450 -> 2026-02-10 : https://github.com/inventree/InvenTree/pull/11260
|
||||||
|
- Adds "part" field to the StockItemTracking model and API endpoints
|
||||||
|
- Additional filtering options for the StockItemTracking API endpoint
|
||||||
|
|
||||||
v449 -> 2026-02-07 : https://github.com/inventree/InvenTree/pull/11266
|
v449 -> 2026-02-07 : https://github.com/inventree/InvenTree/pull/11266
|
||||||
- Add missing nullable annotations to PartStocktakeSerializer
|
- Add missing nullable annotations to PartStocktakeSerializer
|
||||||
|
|
||||||
|
|||||||
@@ -1173,6 +1173,7 @@ class Build(
|
|||||||
user,
|
user,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
deltas={
|
deltas={
|
||||||
|
'quantity': float(quantity),
|
||||||
'location': location.pk,
|
'location': location.pk,
|
||||||
'status': StockStatus.REJECTED.value,
|
'status': StockStatus.REJECTED.value,
|
||||||
'buildorder': self.pk,
|
'buildorder': self.pk,
|
||||||
@@ -1267,7 +1268,11 @@ class Build(
|
|||||||
|
|
||||||
output.save(add_note=False)
|
output.save(add_note=False)
|
||||||
|
|
||||||
deltas = {'status': status, 'buildorder': self.pk}
|
deltas = {
|
||||||
|
'status': status,
|
||||||
|
'buildorder': self.pk,
|
||||||
|
'quantity': float(output.quantity),
|
||||||
|
}
|
||||||
|
|
||||||
if location:
|
if location:
|
||||||
deltas['location'] = location.pk
|
deltas['location'] = location.pk
|
||||||
|
|||||||
@@ -1099,7 +1099,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
|||||||
'validator': bool,
|
'validator': bool,
|
||||||
},
|
},
|
||||||
'STOCKTAKE_ENABLE': {
|
'STOCKTAKE_ENABLE': {
|
||||||
'name': _('Enable Stock History'),
|
'name': _('Enable Stocktake'),
|
||||||
'description': _(
|
'description': _(
|
||||||
'Enable functionality for recording historical stock levels and value'
|
'Enable functionality for recording historical stock levels and value'
|
||||||
),
|
),
|
||||||
@@ -1109,30 +1109,47 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
|||||||
'STOCKTAKE_EXCLUDE_EXTERNAL': {
|
'STOCKTAKE_EXCLUDE_EXTERNAL': {
|
||||||
'name': _('Exclude External Locations'),
|
'name': _('Exclude External Locations'),
|
||||||
'description': _(
|
'description': _(
|
||||||
'Exclude stock items in external locations from stock history calculations'
|
'Exclude stock items in external locations from stocktake calculations'
|
||||||
),
|
),
|
||||||
'validator': bool,
|
'validator': bool,
|
||||||
'default': False,
|
'default': False,
|
||||||
},
|
},
|
||||||
'STOCKTAKE_AUTO_DAYS': {
|
'STOCKTAKE_AUTO_DAYS': {
|
||||||
'name': _('Automatic Stocktake Period'),
|
'name': _('Automatic Stocktake Period'),
|
||||||
'description': _('Number of days between automatic stock history recording'),
|
'description': _('Number of days between automatic stocktake recording'),
|
||||||
'validator': [int, MinValueValidator(1)],
|
'validator': [int, MinValueValidator(1)],
|
||||||
'default': 7,
|
'default': 7,
|
||||||
'units': _('days'),
|
'units': _('days'),
|
||||||
},
|
},
|
||||||
'STOCKTAKE_DELETE_OLD_ENTRIES': {
|
'STOCKTAKE_DELETE_OLD_ENTRIES': {
|
||||||
'name': _('Delete Old Stock History Entries'),
|
'name': _('Delete Old Stocktake Entries'),
|
||||||
'description': _(
|
'description': _(
|
||||||
'Delete stock history entries older than the specified number of days'
|
'Delete stocktake entries older than the specified number of days'
|
||||||
),
|
),
|
||||||
'default': False,
|
'default': False,
|
||||||
'validator': bool,
|
'validator': bool,
|
||||||
},
|
},
|
||||||
'STOCKTAKE_DELETE_DAYS': {
|
'STOCKTAKE_DELETE_DAYS': {
|
||||||
'name': _('Stock History Deletion Interval'),
|
'name': _('Stocktake Deletion Interval'),
|
||||||
'description': _(
|
'description': _(
|
||||||
'Stock history entries will be deleted after specified number of days'
|
'Stocktake entries will be deleted after specified number of days'
|
||||||
|
),
|
||||||
|
'default': 365,
|
||||||
|
'units': _('days'),
|
||||||
|
'validator': [int, MinValueValidator(30)],
|
||||||
|
},
|
||||||
|
'STOCK_TRACKING_DELETE_OLD_ENTRIES': {
|
||||||
|
'name': _('Delete Old Stock Tracking Entries'),
|
||||||
|
'description': _(
|
||||||
|
'Delete stock tracking entries older than the specified number of days'
|
||||||
|
),
|
||||||
|
'default': False,
|
||||||
|
'validator': bool,
|
||||||
|
},
|
||||||
|
'STOCK_TRACKING_DELETE_DAYS': {
|
||||||
|
'name': _('Stock Tracking Deletion Interval'),
|
||||||
|
'description': _(
|
||||||
|
'Stock tracking entries will be deleted after specified number of days'
|
||||||
),
|
),
|
||||||
'default': 365,
|
'default': 365,
|
||||||
'units': _('days'),
|
'units': _('days'),
|
||||||
|
|||||||
@@ -2915,7 +2915,12 @@ class ReturnOrder(TotalPriceMixin, Order):
|
|||||||
if status is None:
|
if status is None:
|
||||||
status = StockStatus.QUARANTINED.value
|
status = StockStatus.QUARANTINED.value
|
||||||
|
|
||||||
deltas = {'status': status, 'returnorder': self.pk, 'location': location.pk}
|
deltas = {
|
||||||
|
'status': status,
|
||||||
|
'returnorder': self.pk,
|
||||||
|
'location': location.pk,
|
||||||
|
'quantity': float(line.quantity),
|
||||||
|
}
|
||||||
|
|
||||||
if stock_item.customer:
|
if stock_item.customer:
|
||||||
deltas['customer'] = stock_item.customer.pk
|
deltas['customer'] = stock_item.customer.pk
|
||||||
|
|||||||
@@ -1267,7 +1267,7 @@ class PurchaseOrderReceiveTest(OrderTest):
|
|||||||
],
|
],
|
||||||
'location': location.pk,
|
'location': location.pk,
|
||||||
},
|
},
|
||||||
max_query_count=104 + 2 * N_LINES,
|
max_query_count=104 + 3 * N_LINES,
|
||||||
).data
|
).data
|
||||||
|
|
||||||
# Check for expected response
|
# Check for expected response
|
||||||
|
|||||||
@@ -4128,7 +4128,7 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
|
|||||||
|
|
||||||
# Normalize decimal values to ensure consistent representation
|
# Normalize decimal values to ensure consistent representation
|
||||||
# These values are only included if they are non-zero
|
# These values are only included if they are non-zero
|
||||||
# This is to provide some backwards compatibility from before these fields were addede
|
# This is to provide some backwards compatibility from before these fields were added
|
||||||
if value is not None and field in [
|
if value is not None and field in [
|
||||||
'quantity',
|
'quantity',
|
||||||
'attrition',
|
'attrition',
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ def scheduled_stocktake_reports():
|
|||||||
threshold = datetime.now() - timedelta(days=delete_n_days)
|
threshold = datetime.now() - timedelta(days=delete_n_days)
|
||||||
old_entries = PartStocktake.objects.filter(date__lt=threshold)
|
old_entries = PartStocktake.objects.filter(date__lt=threshold)
|
||||||
|
|
||||||
if old_entries.count() > 0:
|
if old_entries.exists():
|
||||||
logger.info('Deleting %s old stock entries', old_entries.count())
|
logger.info('Deleting %s old stock entries', old_entries.count())
|
||||||
old_entries.delete()
|
old_entries.delete()
|
||||||
|
|
||||||
|
|||||||
@@ -1490,6 +1490,54 @@ class StockTrackingOutputOptions(OutputConfiguration):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class StockTrackingFilter(FilterSet):
|
||||||
|
"""API filter options for the StockTrackingList endpoint."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
"""Metaclass options."""
|
||||||
|
|
||||||
|
model = StockItemTracking
|
||||||
|
fields = ['item', 'user']
|
||||||
|
|
||||||
|
include_variants = rest_filters.BooleanFilter(
|
||||||
|
label=_('Include Part Variants'), method='filter_include_variants'
|
||||||
|
)
|
||||||
|
|
||||||
|
def filter_include_variants(self, queryset, name, value):
|
||||||
|
"""Filter by whether or not to include part variants.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
- This filter does nothing by itself, and is only used to modify the behavior of the 'part' filter.
|
||||||
|
- Refer to the 'filter_part' method for more information on how this works.
|
||||||
|
"""
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
part = rest_filters.ModelChoiceFilter(
|
||||||
|
label=_('Part'), queryset=Part.objects.all(), method='filter_part'
|
||||||
|
)
|
||||||
|
|
||||||
|
def filter_part(self, queryset, name, part):
|
||||||
|
"""Filter StockTracking entries by the linked part.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
- This filter behavior also takes into account the 'include_variants' filter, which determines whether or not to include part variants in the results.
|
||||||
|
"""
|
||||||
|
include_variants = str2bool(self.data.get('include_variants', False))
|
||||||
|
|
||||||
|
if include_variants:
|
||||||
|
return queryset.filter(part__in=part.get_descendants(include_self=True))
|
||||||
|
else:
|
||||||
|
return queryset.filter(part=part)
|
||||||
|
|
||||||
|
min_date = InvenTreeDateFilter(
|
||||||
|
label=_('Date after'), field_name='date', lookup_expr='gt'
|
||||||
|
)
|
||||||
|
|
||||||
|
max_date = InvenTreeDateFilter(
|
||||||
|
label=_('Date before'), field_name='date', lookup_expr='lt'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class StockTrackingList(
|
class StockTrackingList(
|
||||||
SerializerContextMixin, DataExportViewMixin, OutputOptionsMixin, ListAPI
|
SerializerContextMixin, DataExportViewMixin, OutputOptionsMixin, ListAPI
|
||||||
):
|
):
|
||||||
@@ -1501,8 +1549,9 @@ class StockTrackingList(
|
|||||||
- GET: Return list of StockItemTracking objects
|
- GET: Return list of StockItemTracking objects
|
||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = StockItemTracking.objects.all()
|
queryset = StockItemTracking.objects.all().prefetch_related('item', 'part')
|
||||||
serializer_class = StockSerializers.StockTrackingSerializer
|
serializer_class = StockSerializers.StockTrackingSerializer
|
||||||
|
filterset_class = StockTrackingFilter
|
||||||
output_options = StockTrackingOutputOptions
|
output_options = StockTrackingOutputOptions
|
||||||
|
|
||||||
def get_delta_model_map(self) -> dict:
|
def get_delta_model_map(self) -> dict:
|
||||||
@@ -1598,8 +1647,6 @@ class StockTrackingList(
|
|||||||
|
|
||||||
filter_backends = SEARCH_ORDER_FILTER
|
filter_backends = SEARCH_ORDER_FILTER
|
||||||
|
|
||||||
filterset_fields = ['item', 'user']
|
|
||||||
|
|
||||||
ordering = '-date'
|
ordering = '-date'
|
||||||
|
|
||||||
ordering_fields = ['date']
|
ordering_fields = ['date']
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Generated by Django 5.2.10 on 2026-02-05 12:17
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("part", "0146_auto_20251203_1241"),
|
||||||
|
("stock", "0116_alter_stockitem_link"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="stockitemtracking",
|
||||||
|
name="part",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="stock_tracking_info",
|
||||||
|
to="part.part",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="stockitemtracking",
|
||||||
|
name="item",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="tracking_info",
|
||||||
|
to="stock.stockitem",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Generated by Django 5.2.10 on 2026-02-05 12:18
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def add_part_links(apps, schema_editor):
|
||||||
|
"""Add links to the Part model for all existing StockItemTracking entries."""
|
||||||
|
|
||||||
|
StockItemTracking = apps.get_model('stock', 'StockItemTracking')
|
||||||
|
|
||||||
|
history_entries = []
|
||||||
|
|
||||||
|
for tracking in StockItemTracking.objects.all():
|
||||||
|
|
||||||
|
item = tracking.item
|
||||||
|
|
||||||
|
if item is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
part = item.part
|
||||||
|
|
||||||
|
if part is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
tracking.part = part
|
||||||
|
history_entries.append(tracking)
|
||||||
|
|
||||||
|
if len(history_entries) > 0:
|
||||||
|
StockItemTracking.objects.bulk_update(history_entries, ['part'])
|
||||||
|
print(f"\nUpdated {len(history_entries)} StockItemTracking entries with part links")
|
||||||
|
|
||||||
|
|
||||||
|
def remove_null_items(apps, schema_editor):
|
||||||
|
"""Reverse migration - remove any StockItemTracking entries which have a null item link."""
|
||||||
|
|
||||||
|
StockItemTracking = apps.get_model('stock', 'StockItemTracking')
|
||||||
|
|
||||||
|
null_items = StockItemTracking.objects.filter(item__isnull=True)
|
||||||
|
|
||||||
|
count = null_items.count()
|
||||||
|
|
||||||
|
if count > 0:
|
||||||
|
null_items.delete()
|
||||||
|
print(f"\nDeleted {count} StockItemTracking entries with null item links")
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("stock", "0117_stockitemtracking_part_alter_stockitemtracking_item"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(
|
||||||
|
add_part_links,
|
||||||
|
reverse_code=remove_null_items,
|
||||||
|
)
|
||||||
|
]
|
||||||
@@ -1360,7 +1360,7 @@ class StockItem(
|
|||||||
item.save(add_note=False)
|
item.save(add_note=False)
|
||||||
|
|
||||||
code = StockHistoryCode.SENT_TO_CUSTOMER
|
code = StockHistoryCode.SENT_TO_CUSTOMER
|
||||||
deltas = {}
|
deltas = {'quantity': float(quantity)}
|
||||||
|
|
||||||
if customer is not None:
|
if customer is not None:
|
||||||
deltas['customer'] = customer.pk
|
deltas['customer'] = customer.pk
|
||||||
@@ -1441,7 +1441,11 @@ class StockItem(
|
|||||||
# Split the stock item
|
# Split the stock item
|
||||||
item = self.splitStock(quantity, None, user)
|
item = self.splitStock(quantity, None, user)
|
||||||
|
|
||||||
tracking_info = {}
|
tracking_info = {
|
||||||
|
'quantity': float(quantity)
|
||||||
|
if quantity is not None
|
||||||
|
else float(item.quantity)
|
||||||
|
}
|
||||||
|
|
||||||
if location:
|
if location:
|
||||||
tracking_info['location'] = location.pk
|
tracking_info['location'] = location.pk
|
||||||
@@ -1651,7 +1655,7 @@ class StockItem(
|
|||||||
stock_item.location = None
|
stock_item.location = None
|
||||||
stock_item.save(add_note=False)
|
stock_item.save(add_note=False)
|
||||||
|
|
||||||
deltas = {'stockitem': self.pk}
|
deltas = {'stockitem': self.pk, 'quantity': float(quantity)}
|
||||||
|
|
||||||
if build is not None:
|
if build is not None:
|
||||||
deltas['buildorder'] = build.pk
|
deltas['buildorder'] = build.pk
|
||||||
@@ -1666,7 +1670,7 @@ class StockItem(
|
|||||||
StockHistoryCode.INSTALLED_CHILD_ITEM,
|
StockHistoryCode.INSTALLED_CHILD_ITEM,
|
||||||
user,
|
user,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
deltas={'stockitem': stock_item.pk},
|
deltas={'stockitem': stock_item.pk, 'quantity': float(quantity)},
|
||||||
)
|
)
|
||||||
|
|
||||||
trigger_event(
|
trigger_event(
|
||||||
@@ -1692,11 +1696,14 @@ class StockItem(
|
|||||||
self.belongs_to.add_tracking_entry(
|
self.belongs_to.add_tracking_entry(
|
||||||
StockHistoryCode.REMOVED_CHILD_ITEM,
|
StockHistoryCode.REMOVED_CHILD_ITEM,
|
||||||
user,
|
user,
|
||||||
deltas={'stockitem': self.pk},
|
deltas={'stockitem': self.pk, 'quantity': float(self.quantity)},
|
||||||
notes=notes,
|
notes=notes,
|
||||||
)
|
)
|
||||||
|
|
||||||
tracking_info = {'stockitem': self.belongs_to.pk}
|
tracking_info = {
|
||||||
|
'stockitem': self.belongs_to.pk,
|
||||||
|
'quantity': float(self.quantity),
|
||||||
|
}
|
||||||
|
|
||||||
self.add_tracking_entry(
|
self.add_tracking_entry(
|
||||||
StockHistoryCode.REMOVED_FROM_ASSEMBLY,
|
StockHistoryCode.REMOVED_FROM_ASSEMBLY,
|
||||||
@@ -1835,6 +1842,7 @@ class StockItem(
|
|||||||
|
|
||||||
entry = StockItemTracking(
|
entry = StockItemTracking(
|
||||||
item=self,
|
item=self,
|
||||||
|
part=self.part,
|
||||||
tracking_type=entry_type.value,
|
tracking_type=entry_type.value,
|
||||||
user=user,
|
user=user,
|
||||||
date=InvenTree.helpers.current_time(),
|
date=InvenTree.helpers.current_time(),
|
||||||
@@ -1964,7 +1972,7 @@ class StockItem(
|
|||||||
|
|
||||||
# Remove the equivalent number of items
|
# Remove the equivalent number of items
|
||||||
self.take_stock(
|
self.take_stock(
|
||||||
quantity, user, code=StockHistoryCode.STOCK_SERIZALIZED, notes=notes
|
quantity, user, code=StockHistoryCode.STOCK_SERIALIZED, notes=notes
|
||||||
)
|
)
|
||||||
|
|
||||||
return items
|
return items
|
||||||
@@ -2175,7 +2183,10 @@ class StockItem(
|
|||||||
user,
|
user,
|
||||||
quantity=self.quantity,
|
quantity=self.quantity,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
deltas={'location': location.pk if location else None},
|
deltas={
|
||||||
|
'location': location.pk if location else None,
|
||||||
|
'quantity': self.quantity,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update the location of the item
|
# Update the location of the item
|
||||||
@@ -2416,7 +2427,7 @@ class StockItem(
|
|||||||
|
|
||||||
self.location = location
|
self.location = location
|
||||||
|
|
||||||
tracking_info = {}
|
tracking_info = {'quantity': float(quantity)}
|
||||||
|
|
||||||
tracking_code = StockHistoryCode.STOCK_MOVE
|
tracking_code = StockHistoryCode.STOCK_MOVE
|
||||||
|
|
||||||
@@ -2869,21 +2880,18 @@ def after_save_stock_item(sender, instance: StockItem, created, **kwargs):
|
|||||||
class StockItemTracking(InvenTree.models.InvenTreeModel):
|
class StockItemTracking(InvenTree.models.InvenTreeModel):
|
||||||
"""Stock tracking entry - used for tracking history of a particular StockItem.
|
"""Stock tracking entry - used for tracking history of a particular StockItem.
|
||||||
|
|
||||||
Note: 2021-05-11
|
|
||||||
The legacy StockTrackingItem model contained very little information about the "history" of the item.
|
|
||||||
In fact, only the "quantity" of the item was recorded at each interaction.
|
|
||||||
Also, the "title" was translated at time of generation, and thus was not really translatable.
|
|
||||||
The "new" system tracks all 'delta' changes to the model,
|
|
||||||
and tracks change "type" which can then later be translated
|
|
||||||
|
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
item: ForeignKey reference to a particular StockItem
|
item: ForeignKey reference to a particular StockItem
|
||||||
|
part: ForeignKey reference to the Part associated with this StockItem
|
||||||
date: Date that this tracking info was created
|
date: Date that this tracking info was created
|
||||||
tracking_type: The type of tracking information
|
tracking_type: The type of tracking information
|
||||||
notes: Associated notes (input by user)
|
notes: Associated notes (input by user)
|
||||||
user: The user associated with this tracking info
|
user: The user associated with this tracking info
|
||||||
deltas: The changes associated with this history item
|
deltas: The changes associated with this history item
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
If the underlying stock item is deleted, the "item" field will be set to null, but the tracking information will be retained.
|
||||||
|
The tracking data will be removed if the associated part is deleted, as the tracking information is not relevant without the part context.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -2896,6 +2904,13 @@ class StockItemTracking(InvenTree.models.InvenTreeModel):
|
|||||||
"""Return API url."""
|
"""Return API url."""
|
||||||
return reverse('api-stock-tracking-list')
|
return reverse('api-stock-tracking-list')
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""Ensure that the 'part' link is always correct."""
|
||||||
|
if self.item:
|
||||||
|
self.part = self.item.part
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
"""Return url for instance."""
|
"""Return url for instance."""
|
||||||
return InvenTree.helpers.pui_url(f'/stock/item/{self.item.id}')
|
return InvenTree.helpers.pui_url(f'/stock/item/{self.item.id}')
|
||||||
@@ -2910,7 +2925,19 @@ class StockItemTracking(InvenTree.models.InvenTreeModel):
|
|||||||
tracking_type = models.IntegerField(default=StockHistoryCode.LEGACY)
|
tracking_type = models.IntegerField(default=StockHistoryCode.LEGACY)
|
||||||
|
|
||||||
item = models.ForeignKey(
|
item = models.ForeignKey(
|
||||||
StockItem, on_delete=models.CASCADE, related_name='tracking_info'
|
StockItem,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=False,
|
||||||
|
related_name='tracking_info',
|
||||||
|
)
|
||||||
|
|
||||||
|
part = models.ForeignKey(
|
||||||
|
'part.part',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='stock_tracking_info',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
date = models.DateTimeField(auto_now_add=True, editable=False)
|
date = models.DateTimeField(auto_now_add=True, editable=False)
|
||||||
|
|||||||
@@ -1248,6 +1248,8 @@ class StockTrackingSerializer(
|
|||||||
'pk',
|
'pk',
|
||||||
'item',
|
'item',
|
||||||
'item_detail',
|
'item_detail',
|
||||||
|
'part',
|
||||||
|
'part_detail',
|
||||||
'date',
|
'date',
|
||||||
'deltas',
|
'deltas',
|
||||||
'label',
|
'label',
|
||||||
@@ -1256,13 +1258,21 @@ class StockTrackingSerializer(
|
|||||||
'user',
|
'user',
|
||||||
'user_detail',
|
'user_detail',
|
||||||
]
|
]
|
||||||
read_only_fields = ['date', 'user', 'label', 'tracking_type']
|
read_only_fields = ['date', 'part', 'user', 'label', 'tracking_type']
|
||||||
|
|
||||||
label = serializers.CharField(read_only=True)
|
label = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
item_detail = enable_filter(
|
item_detail = enable_filter(
|
||||||
StockItemSerializer(source='item', many=False, read_only=True, allow_null=True),
|
StockItemSerializer(source='item', many=False, read_only=True, allow_null=True),
|
||||||
prefetch_fields=['item'],
|
prefetch_fields=['item', 'item__part'],
|
||||||
|
)
|
||||||
|
|
||||||
|
part_detail = enable_filter(
|
||||||
|
part_serializers.PartBriefSerializer(
|
||||||
|
source='part', many=False, read_only=True, allow_null=True
|
||||||
|
),
|
||||||
|
default_include=False,
|
||||||
|
prefetch_fields=['part'],
|
||||||
)
|
)
|
||||||
|
|
||||||
user_detail = enable_filter(
|
user_detail = enable_filter(
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ class StockHistoryCode(StatusCode):
|
|||||||
STOCK_COUNT = 10, _('Stock counted')
|
STOCK_COUNT = 10, _('Stock counted')
|
||||||
STOCK_ADD = 11, _('Stock manually added')
|
STOCK_ADD = 11, _('Stock manually added')
|
||||||
STOCK_REMOVE = 12, _('Stock manually removed')
|
STOCK_REMOVE = 12, _('Stock manually removed')
|
||||||
STOCK_SERIZALIZED = 13, _('Serialized stock items')
|
STOCK_SERIALIZED = 13, _('Serialized stock items')
|
||||||
|
|
||||||
RETURNED_TO_STOCK = 15, _('Returned to stock') # Stock item returned to stock
|
RETURNED_TO_STOCK = 15, _('Returned to stock') # Stock item returned to stock
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
"""Background tasks for the stock app."""
|
"""Background tasks for the stock app."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from opentelemetry import trace
|
from opentelemetry import trace
|
||||||
|
|
||||||
|
from common.settings import get_global_setting
|
||||||
|
from InvenTree.tasks import ScheduledTask, offload_task, scheduled_task
|
||||||
|
|
||||||
tracer = trace.get_tracer(__name__)
|
tracer = trace.get_tracer(__name__)
|
||||||
logger = structlog.get_logger('inventree')
|
logger = structlog.get_logger('inventree')
|
||||||
|
|
||||||
@@ -43,7 +48,6 @@ def rebuild_stock_item_tree(tree_id: int, rebuild_on_fail: bool = True) -> bool:
|
|||||||
"""
|
"""
|
||||||
from InvenTree.exceptions import log_error
|
from InvenTree.exceptions import log_error
|
||||||
from InvenTree.sentry import report_exception
|
from InvenTree.sentry import report_exception
|
||||||
from InvenTree.tasks import offload_task
|
|
||||||
from stock.models import StockItem
|
from stock.models import StockItem
|
||||||
|
|
||||||
if tree_id:
|
if tree_id:
|
||||||
@@ -65,3 +69,27 @@ def rebuild_stock_item_tree(tree_id: int, rebuild_on_fail: bool = True) -> bool:
|
|||||||
# No tree_id provided, so rebuild the entire tree
|
# No tree_id provided, so rebuild the entire tree
|
||||||
StockItem.objects.rebuild()
|
StockItem.objects.rebuild()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@tracer.start_as_current_span('delete_old_stock_tracking')
|
||||||
|
@scheduled_task(ScheduledTask.DAILY)
|
||||||
|
def delete_old_stock_tracking():
|
||||||
|
"""Remove old stock tracking entries before a certain date."""
|
||||||
|
from stock.models import StockItemTracking
|
||||||
|
|
||||||
|
if not get_global_setting('STOCK_TRACKING_DELETE_OLD_ENTRIES', False):
|
||||||
|
return
|
||||||
|
|
||||||
|
delete_n_days = int(get_global_setting('STOCK_TRACKING_DELETE_DAYS', 365))
|
||||||
|
|
||||||
|
threshold = datetime.now() - timedelta(days=delete_n_days)
|
||||||
|
|
||||||
|
old_entries = StockItemTracking.objects.filter(date__lte=threshold)
|
||||||
|
|
||||||
|
if old_entries.exists():
|
||||||
|
logger.info(
|
||||||
|
'Deleting old stock tracking entries',
|
||||||
|
count=old_entries.count(),
|
||||||
|
threshold=threshold,
|
||||||
|
)
|
||||||
|
old_entries.delete()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { Alert, Skeleton, Stack, Text } from '@mantine/core';
|
import { Alert, Divider, Skeleton, Stack, Text, Title } from '@mantine/core';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useStore } from 'zustand';
|
import { useStore } from 'zustand';
|
||||||
@@ -24,11 +24,13 @@ import { SettingItem } from './SettingItem';
|
|||||||
* Display a list of setting items, based on a list of provided keys
|
* Display a list of setting items, based on a list of provided keys
|
||||||
*/
|
*/
|
||||||
export function SettingList({
|
export function SettingList({
|
||||||
|
heading,
|
||||||
settingsState,
|
settingsState,
|
||||||
keys,
|
keys,
|
||||||
onChange,
|
onChange,
|
||||||
onLoaded
|
onLoaded
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
|
heading?: string;
|
||||||
settingsState: SettingsStateProps;
|
settingsState: SettingsStateProps;
|
||||||
keys?: string[];
|
keys?: string[];
|
||||||
onChange?: () => void;
|
onChange?: () => void;
|
||||||
@@ -162,6 +164,8 @@ export function SettingList({
|
|||||||
<>
|
<>
|
||||||
{editSettingModal.modal}
|
{editSettingModal.modal}
|
||||||
<Stack gap='xs'>
|
<Stack gap='xs'>
|
||||||
|
{heading && <Title order={4}>{heading}</Title>}
|
||||||
|
{heading && <Divider />}
|
||||||
{(keys || allKeys)?.map((key, i) => {
|
{(keys || allKeys)?.map((key, i) => {
|
||||||
const setting = settingsState?.settings?.find(
|
const setting = settingsState?.settings?.find(
|
||||||
(s: any) => s.key === key
|
(s: any) => s.key === key
|
||||||
@@ -198,16 +202,26 @@ export function SettingList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserSettingList({ keys }: Readonly<{ keys: string[] }>) {
|
export function UserSettingList({
|
||||||
|
keys,
|
||||||
|
heading
|
||||||
|
}: Readonly<{ keys: string[]; heading?: string }>) {
|
||||||
const userSettings = useUserSettingsState();
|
const userSettings = useUserSettingsState();
|
||||||
|
|
||||||
return <SettingList settingsState={userSettings} keys={keys} />;
|
return (
|
||||||
|
<SettingList settingsState={userSettings} keys={keys} heading={heading} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GlobalSettingList({ keys }: Readonly<{ keys: string[] }>) {
|
export function GlobalSettingList({
|
||||||
|
keys,
|
||||||
|
heading
|
||||||
|
}: Readonly<{ keys: string[]; heading?: string }>) {
|
||||||
const globalSettings = useGlobalSettingsState();
|
const globalSettings = useGlobalSettingsState();
|
||||||
|
|
||||||
return <SettingList settingsState={globalSettings} keys={keys} />;
|
return (
|
||||||
|
<SettingList settingsState={globalSettings} keys={keys} heading={heading} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PluginSettingList({
|
export function PluginSettingList({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
import { Skeleton, Stack } from '@mantine/core';
|
import { Divider, Skeleton, Stack } from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconBellCog,
|
IconBellCog,
|
||||||
IconCategory,
|
IconCategory,
|
||||||
@@ -254,7 +254,9 @@ export default function SystemSettings() {
|
|||||||
label: t`Stock History`,
|
label: t`Stock History`,
|
||||||
icon: <IconClipboardList />,
|
icon: <IconClipboardList />,
|
||||||
content: (
|
content: (
|
||||||
|
<Stack gap='xs'>
|
||||||
<GlobalSettingList
|
<GlobalSettingList
|
||||||
|
heading={t`Part Stocktake`}
|
||||||
keys={[
|
keys={[
|
||||||
'STOCKTAKE_ENABLE',
|
'STOCKTAKE_ENABLE',
|
||||||
'STOCKTAKE_EXCLUDE_EXTERNAL',
|
'STOCKTAKE_EXCLUDE_EXTERNAL',
|
||||||
@@ -263,6 +265,15 @@ export default function SystemSettings() {
|
|||||||
'STOCKTAKE_DELETE_DAYS'
|
'STOCKTAKE_DELETE_DAYS'
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
<Divider />
|
||||||
|
<GlobalSettingList
|
||||||
|
heading={t`Stock Tracking`}
|
||||||
|
keys={[
|
||||||
|
'STOCK_TRACKING_DELETE_OLD_ENTRIES',
|
||||||
|
'STOCK_TRACKING_DELETE_DAYS'
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { TableColumn } from '@lib/types/Tables';
|
|||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
import { type ChartTooltipProps, LineChart } from '@mantine/charts';
|
import { type ChartTooltipProps, LineChart } from '@mantine/charts';
|
||||||
import {
|
import {
|
||||||
|
Accordion,
|
||||||
Center,
|
Center,
|
||||||
Divider,
|
Divider,
|
||||||
Loader,
|
Loader,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { StylishText } from '../../components/items/StylishText';
|
||||||
import { formatDate, formatPriceRange } from '../../defaults/formatters';
|
import { formatDate, formatPriceRange } from '../../defaults/formatters';
|
||||||
import { partStocktakeFields } from '../../forms/PartForms';
|
import { partStocktakeFields } from '../../forms/PartForms';
|
||||||
import {
|
import {
|
||||||
@@ -27,6 +29,7 @@ import { useTable } from '../../hooks/UseTable';
|
|||||||
import { useUserState } from '../../states/UserState';
|
import { useUserState } from '../../states/UserState';
|
||||||
import { DateColumn, DecimalColumn } from '../../tables/ColumnRenderers';
|
import { DateColumn, DecimalColumn } from '../../tables/ColumnRenderers';
|
||||||
import { InvenTreeTable } from '../../tables/InvenTreeTable';
|
import { InvenTreeTable } from '../../tables/InvenTreeTable';
|
||||||
|
import { StockTrackingTable } from '../../tables/stock/StockTrackingTable';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Render a tooltip for the chart, with correct date information
|
* Render a tooltip for the chart, with correct date information
|
||||||
@@ -64,9 +67,7 @@ function ChartTooltip({ label, payload }: Readonly<ChartTooltipProps>) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PartStockHistoryDetail({
|
export function PartStocktakePanel({ partId }: Readonly<{ partId: number }>) {
|
||||||
partId
|
|
||||||
}: Readonly<{ partId: number }>) {
|
|
||||||
const user = useUserState();
|
const user = useUserState();
|
||||||
const table = useTable('part-stocktake');
|
const table = useTable('part-stocktake');
|
||||||
|
|
||||||
@@ -208,7 +209,7 @@ export default function PartStockHistoryDetail({
|
|||||||
{newStocktakeEntry.modal}
|
{newStocktakeEntry.modal}
|
||||||
{editStocktakeEntry.modal}
|
{editStocktakeEntry.modal}
|
||||||
{deleteStocktakeEntry.modal}
|
{deleteStocktakeEntry.modal}
|
||||||
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
<SimpleGrid cols={{ base: 1, lg: 2 }}>
|
||||||
<InvenTreeTable
|
<InvenTreeTable
|
||||||
url={apiUrl(ApiEndpoints.part_stocktake_list)}
|
url={apiUrl(ApiEndpoints.part_stocktake_list)}
|
||||||
tableState={table}
|
tableState={table}
|
||||||
@@ -284,3 +285,28 @@ export default function PartStockHistoryDetail({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function PartStockHistoryDetail({
|
||||||
|
partId
|
||||||
|
}: Readonly<{ partId: number }>) {
|
||||||
|
return (
|
||||||
|
<Accordion multiple defaultValue={['stocktake']}>
|
||||||
|
<Accordion.Item value='tracking'>
|
||||||
|
<Accordion.Control>
|
||||||
|
<StylishText size='lg'>{t`Stock Tracking`}</StylishText>
|
||||||
|
</Accordion.Control>
|
||||||
|
<Accordion.Panel>
|
||||||
|
<StockTrackingTable partId={partId} />
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
<Accordion.Item value='stocktake'>
|
||||||
|
<Accordion.Control>
|
||||||
|
<StylishText size='lg'>{t`Stocktake Entries`}</StylishText>
|
||||||
|
</Accordion.Control>
|
||||||
|
<Accordion.Panel>
|
||||||
|
<PartStocktakePanel partId={partId} />
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
</Accordion>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,8 +24,13 @@ import {
|
|||||||
} from '../../components/render/Stock';
|
} from '../../components/render/Stock';
|
||||||
import { RenderUser } from '../../components/render/User';
|
import { RenderUser } from '../../components/render/User';
|
||||||
import { useTable } from '../../hooks/UseTable';
|
import { useTable } from '../../hooks/UseTable';
|
||||||
import { DateColumn, DescriptionColumn } from '../ColumnRenderers';
|
import { DateColumn, DescriptionColumn, PartColumn } from '../ColumnRenderers';
|
||||||
import { UserFilter } from '../Filter';
|
import {
|
||||||
|
IncludeVariantsFilter,
|
||||||
|
MaxDateFilter,
|
||||||
|
MinDateFilter,
|
||||||
|
UserFilter
|
||||||
|
} from '../Filter';
|
||||||
import { InvenTreeTable } from '../InvenTreeTable';
|
import { InvenTreeTable } from '../InvenTreeTable';
|
||||||
|
|
||||||
type StockTrackingEntry = {
|
type StockTrackingEntry = {
|
||||||
@@ -34,9 +39,15 @@ type StockTrackingEntry = {
|
|||||||
details: ReactNode;
|
details: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function StockTrackingTable({ itemId }: Readonly<{ itemId: number }>) {
|
export function StockTrackingTable({
|
||||||
|
itemId,
|
||||||
|
partId
|
||||||
|
}: Readonly<{
|
||||||
|
itemId?: number;
|
||||||
|
partId?: number;
|
||||||
|
}>) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const table = useTable('stock_tracking');
|
const table = useTable(partId ? 'part_stock_tracking' : 'stock_tracking');
|
||||||
|
|
||||||
// Render "details" for a stock tracking record
|
// Render "details" for a stock tracking record
|
||||||
const renderDetails = useCallback(
|
const renderDetails = useCallback(
|
||||||
@@ -200,6 +211,9 @@ export function StockTrackingTable({ itemId }: Readonly<{ itemId: number }>) {
|
|||||||
|
|
||||||
const filters: TableFilter[] = useMemo(() => {
|
const filters: TableFilter[] = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
|
MinDateFilter(),
|
||||||
|
MaxDateFilter(),
|
||||||
|
IncludeVariantsFilter(),
|
||||||
UserFilter({
|
UserFilter({
|
||||||
name: 'user',
|
name: 'user',
|
||||||
label: t`User`,
|
label: t`User`,
|
||||||
@@ -213,6 +227,43 @@ export function StockTrackingTable({ itemId }: Readonly<{ itemId: number }>) {
|
|||||||
DateColumn({
|
DateColumn({
|
||||||
switchable: false
|
switchable: false
|
||||||
}),
|
}),
|
||||||
|
PartColumn({
|
||||||
|
title: t`Part`,
|
||||||
|
part: 'part_detail',
|
||||||
|
switchable: true,
|
||||||
|
hidden: !partId
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
title: t`IPN`,
|
||||||
|
accessor: 'part_detail.IPN',
|
||||||
|
sortable: true,
|
||||||
|
defaultVisible: false,
|
||||||
|
switchable: true,
|
||||||
|
hidden: !partId
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessor: 'item',
|
||||||
|
title: t`Stock Item`,
|
||||||
|
sortable: false,
|
||||||
|
switchable: false,
|
||||||
|
hidden: !partId,
|
||||||
|
render: (record: any) => {
|
||||||
|
const item = record.item_detail;
|
||||||
|
if (!item) {
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
c='red'
|
||||||
|
size='xs'
|
||||||
|
fs='italic'
|
||||||
|
>{t`Stock item no longer exists`}</Text>
|
||||||
|
);
|
||||||
|
} else if (item.serial && item.quantity == 1) {
|
||||||
|
return `${t`Serial`} #${item.serial}`;
|
||||||
|
} else {
|
||||||
|
return `${t`Item ID`} ${item.pk}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
DescriptionColumn({
|
DescriptionColumn({
|
||||||
accessor: 'label'
|
accessor: 'label'
|
||||||
}),
|
}),
|
||||||
@@ -250,10 +301,15 @@ export function StockTrackingTable({ itemId }: Readonly<{ itemId: number }>) {
|
|||||||
props={{
|
props={{
|
||||||
params: {
|
params: {
|
||||||
item: itemId,
|
item: itemId,
|
||||||
|
part: partId,
|
||||||
|
part_detail: partId ? true : undefined,
|
||||||
|
item_detail: partId ? true : undefined,
|
||||||
user_detail: true
|
user_detail: true
|
||||||
},
|
},
|
||||||
enableDownload: true,
|
enableDownload: true,
|
||||||
tableFilters: filters
|
tableFilters: filters,
|
||||||
|
modelType: partId ? ModelType.stockitem : undefined,
|
||||||
|
modelField: 'item'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -423,6 +423,55 @@ test('Stock - Tracking', async ({ browser }) => {
|
|||||||
await page.getByText('- - Factory/Office Block/Room').first().waitFor();
|
await page.getByText('- - Factory/Office Block/Room').first().waitFor();
|
||||||
await page.getByRole('link', { name: 'Widget Assembly' }).waitFor();
|
await page.getByRole('link', { name: 'Widget Assembly' }).waitFor();
|
||||||
await page.getByRole('cell', { name: 'Installed into assembly' }).waitFor();
|
await page.getByRole('cell', { name: 'Installed into assembly' }).waitFor();
|
||||||
|
|
||||||
|
/* Add some more stock items and tracking information:
|
||||||
|
* - Duplicate this stock item
|
||||||
|
* - Give it a unique serial number
|
||||||
|
* - Ensure the tracking information is duplicated correctly
|
||||||
|
* - Delete the new stock item
|
||||||
|
* - Ensure that the tracking information is retained against the base part
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Duplicate the stock item
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'action-menu-stock-item-actions' })
|
||||||
|
.click();
|
||||||
|
await page
|
||||||
|
.getByRole('menuitem', { name: 'action-menu-stock-item-actions-duplicate' })
|
||||||
|
.click();
|
||||||
|
await page
|
||||||
|
.getByRole('textbox', { name: 'text-field-serial_numbers' })
|
||||||
|
.fill('9876');
|
||||||
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
|
|
||||||
|
// Check stock tracking information is correct
|
||||||
|
await page.getByText('Serial Number: 9876').first().waitFor();
|
||||||
|
await loadTab(page, 'Stock Tracking');
|
||||||
|
await page
|
||||||
|
.getByRole('cell', { name: 'Stock item created' })
|
||||||
|
.first()
|
||||||
|
.waitFor();
|
||||||
|
|
||||||
|
// Delete this stock item
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'action-menu-stock-item-actions' })
|
||||||
|
.click();
|
||||||
|
await page
|
||||||
|
.getByRole('menuitem', { name: 'action-menu-stock-item-actions-delete' })
|
||||||
|
.click();
|
||||||
|
await page.getByRole('button', { name: 'Delete' }).click();
|
||||||
|
|
||||||
|
// Check stock tracking for base part
|
||||||
|
await loadTab(page, 'Stock History');
|
||||||
|
await page.getByRole('button', { name: 'Stock Tracking' }).click();
|
||||||
|
|
||||||
|
await page.getByText('Stock item no longer exists').first().waitFor();
|
||||||
|
await page
|
||||||
|
.getByRole('cell', { name: 'Thumbnail Blue Widget' })
|
||||||
|
.first()
|
||||||
|
.waitFor();
|
||||||
|
await page.getByRole('cell', { name: 'Item ID 232' }).first().waitFor();
|
||||||
|
await page.getByRole('cell', { name: 'Serial #116' }).first().waitFor();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Stock - Location', async ({ browser }) => {
|
test('Stock - Location', async ({ browser }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user