Merge commit '27ca0836e7c7e46ebb41ea98d507972ae7a6b018' into block-notes
This commit is contained in:
commit
5cd7bfa1e4
|
|
@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Breaking Changes
|
||||
|
||||
- [#9604](https://github.com/inventree/InvenTree/pull/9604) - refactors user API endpoint to be less ambiguous
|
||||
- [#11893](https://github.com/inventree/InvenTree/pull/11893) bumps Node environment to version 24 LTS - this is only relevant if you build the frontend assets yourself
|
||||
|
||||
### Added
|
||||
|
|
@ -23,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- [#11778](https://github.com/inventree/InvenTree/pull/11778) adds inline supplier part creation to po line item addition dialog.
|
||||
- [#11772](https://github.com/inventree/InvenTree/pull/11772) the UI now warns if you navigate away from a note panel with unsaved changes
|
||||
- [#11788](https://github.com/inventree/InvenTree/pull/11788) adds support for custom permissions checks on database models defined in plugins. If a model defines a `check_user_permission` classmethod, this will be called to determine if a user has permission to view the model. This is required for plugin models which do not have the required ruleset definitions for the standard permission system.
|
||||
- [#9570](https://github.com/inventree/InvenTree/pull/9570) adds support for defining primary actions via plugins
|
||||
|
||||
### Changed
|
||||
|
||||
|
|
|
|||
|
|
@ -139,9 +139,9 @@ charset-normalizer==3.4.7 \
|
|||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# requests
|
||||
idna==3.13 \
|
||||
--hash=sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242 \
|
||||
--hash=sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3
|
||||
idna==3.15 \
|
||||
--hash=sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8 \
|
||||
--hash=sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# requests
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ Each user is assigned an authentication token which can be used to access the AP
|
|||
|
||||
If a user does not know their access token, it can be requested via the API interface itself, using a basic authentication request.
|
||||
|
||||
To obtain a valid token, perform a GET request to `/api/user/token/`. No data are required, but a valid username / password combination must be supplied in the authentication headers.
|
||||
To obtain a valid token, perform a GET request to `/api/user/me/token/`. No data are required, but a valid username / password combination must be supplied in the authentication headers.
|
||||
|
||||
!!! info "Credentials"
|
||||
Ensure that a valid username:password combination are supplied as basic authorization headers.
|
||||
|
|
@ -146,7 +146,7 @@ r:delete:stock
|
|||
Users can only perform REST API actions which align with their assigned [role permissions](../settings/permissions.md#roles).
|
||||
Once a user has *authenticated* via the API, a list of the available roles can be retrieved from:
|
||||
|
||||
`/api/user/roles/`
|
||||
`/api/user/me/roles/`
|
||||
|
||||
For example, when accessing the API from a *superuser* account:
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ The API schema as documented below is generated using the [drf-spectactular](htt
|
|||
|
||||
## API Version
|
||||
|
||||
This documentation is for API version: `449`
|
||||
This documentation is for API version: `459`
|
||||
|
||||
!!! 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/).
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
|
|
@ -25,6 +25,19 @@ The following types of attachments are supported:
|
|||
|
||||
File attachments allow users to upload files directly to InvenTree. These files are stored on the server and can be downloaded or viewed by users with appropriate permissions.
|
||||
|
||||
### Image Thumbnails
|
||||
|
||||
When a file attachment is uploaded, InvenTree automatically determines whether the file is a valid image. If it is, a thumbnail is generated and stored alongside the attachment.
|
||||
|
||||
- The thumbnail is created with a reduced image size, while preserving the original aspect ratio.
|
||||
- Thumbnail generation is performed in the background after upload.
|
||||
- The `is_image` flag on the attachment record is set to `True` for valid images, and `False` for all other file types.
|
||||
- If the uploaded file has an image extension but contains invalid or corrupt image data, no thumbnail is generated and `is_image` remains `False`.
|
||||
- Link attachments (external URLs) are never assigned a thumbnail.
|
||||
|
||||
!!! info "Supported Formats"
|
||||
Any image format recognised by the [Pillow](https://pillow.readthedocs.io/) library (e.g. PNG, JPEG, GIF, BMP, WEBP) will be treated as a valid image and have a thumbnail generated automatically.
|
||||
|
||||
### Link Attachments
|
||||
|
||||
Link attachments allow users to associate external URLs with an object. This can be useful for linking to external documentation, resources, or other relevant web content.
|
||||
|
|
|
|||
|
|
@ -17,3 +17,4 @@ Custom states can be defined for the following models:
|
|||
- [Purchase Order](../purchasing/purchase_order.md)
|
||||
- [Sales Order](../sales/sales_order.md)
|
||||
- [Return Order](../sales/return_order.md)
|
||||
- [Transfer Order](../stock/transfer_order.md)
|
||||
|
|
|
|||
|
|
@ -183,6 +183,20 @@ The `get_ui_template_previews` feature type can be used to provide custom templa
|
|||
summary: False
|
||||
members: []
|
||||
|
||||
### Primary Actions
|
||||
|
||||
The `get_ui_primary_actions` method can be used to provide custom primary action, which are rendered in the header of the page, next to the title/name and any status indicators. These primary actions are typically used to provide quick access to common actions related to the current page.
|
||||
|
||||
::: plugin.base.ui.mixins.UserInterfaceMixin.get_ui_primary_actions
|
||||
options:
|
||||
show_bases: False
|
||||
show_root_heading: False
|
||||
show_root_toc_entry: False
|
||||
extra:
|
||||
show_source: True
|
||||
summary: False
|
||||
members: []
|
||||
|
||||
## Plugin Context
|
||||
|
||||
When rendering certain content in the user interface, the rendering functions are passed a `context` object which contains information about the current page being rendered. The type of the `context` object is defined in the `PluginContext` file:
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ Configuration of basic server settings:
|
|||
{{ globalsetting("DISPLAY_FULL_NAMES") }}
|
||||
{{ globalsetting("DISPLAY_PROFILE_INFO") }}
|
||||
{{ globalsetting("WEEK_STARTS_ON") }}
|
||||
{{ globalsetting("INVENTREE_UPLOAD_MAX_SIZE") }}
|
||||
{{ globalsetting("INVENTREE_STRICT_URLS") }}
|
||||
|
||||
Configuration of various scheduled tasks:
|
||||
|
|
@ -240,6 +241,10 @@ Refer to the [sales order settings](../sales/sales_order.md#sales-order-settings
|
|||
|
||||
Refer to the [return order settings](../sales/return_order.md#return-order-settings).
|
||||
|
||||
### Transfer Orders
|
||||
|
||||
Refer to the [transfer order settings](../stock/transfer_order.md#transfer-order-settings).
|
||||
|
||||
### Plugin Settings
|
||||
|
||||
| Name | Description | Default | Units |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,150 @@
|
|||
|
||||
---
|
||||
title: Transfer Orders
|
||||
---
|
||||
|
||||
## Transfer Orders
|
||||
|
||||
Transfer orders provide a method for requesting stock to be moved from one location to another. It does not replace the existing on-demand stock transaction options, but lets you "document" many transactions from a single view.
|
||||
|
||||
### View Transfer Orders
|
||||
|
||||
To navigate to the Transfer Order display, select *Stock* from the main navigation menu, and *Transfer Orders* from the sidebar:
|
||||
|
||||
{{ image("stock/transfer_order_display.png", "Transfer Order display") }}
|
||||
|
||||
The following view modes are available:
|
||||
|
||||
#### Table View
|
||||
|
||||
*Table View* provides a list of Transfer Orders, which can be filtered to display a subset of orders according to user supplied parameters.
|
||||
|
||||
{{ image("stock/transfer_order_list.png", "Transfer Order list") }}
|
||||
|
||||
#### Calendar View
|
||||
|
||||
*Calendar View* shows a calendar display with outstanding transfer orders.
|
||||
|
||||
{{ image("stock/transfer_order_calendar.png", "Transfer Order calendar") }}
|
||||
|
||||
### Transfer Order Status Codes
|
||||
|
||||
Each Transfer Order has a specific status code, which represents the state of the order:
|
||||
|
||||
| Status | Description |
|
||||
| --- | --- |
|
||||
| Pending | The transfer order has been created, but has not been finalized or submitted |
|
||||
| Issued | The transfer order has been issued, and is in progress |
|
||||
| On Hold | The transfer order has been placed on hold, but is still active |
|
||||
| Complete | The transfer order is fully completed, and is now closed |
|
||||
| Cancelled | The transfer order was cancelled, and is now closed |
|
||||
|
||||
**Source Code**
|
||||
|
||||
Refer to the source code for the Transfer Order status codes:
|
||||
|
||||
::: order.status_codes.TransferOrderStatus
|
||||
options:
|
||||
show_bases: False
|
||||
show_root_heading: False
|
||||
show_root_toc_entry: False
|
||||
show_source: True
|
||||
members: []
|
||||
|
||||
Transfer Order Status supports [custom states](../concepts/custom_states.md).
|
||||
|
||||
### Transfer Order Parameters
|
||||
|
||||
The following parameters are available for each Transfer Order, and can be edited by the user:
|
||||
|
||||
| Parameter | Description |
|
||||
| --- | --- |
|
||||
| Reference | Transfer Order reference e.g. '001' |
|
||||
| Description | Description of the Transfer Order |
|
||||
| Project Code | Project Code of the Transfer Order |
|
||||
| Source Location | Stock location to source stock items from (blank = all locations) |
|
||||
| Destination Location | Stock location where the stock will be transferred |
|
||||
| Consume Stock | Rather than transfer the stock to the destination, "consume" it by removing the specified quantity from the allocated stock item
|
||||
| Start Date | The scheduled start date for the transfer |
|
||||
| Target Date | Target date for transfer completion |
|
||||
| External Link | Link to external webpage |
|
||||
| Responsible | User (or group of users) who is responsible for the transfer |
|
||||
| Notes | Transfer notes, supports markdown |
|
||||
|
||||
## Create a Transfer Order
|
||||
|
||||
Once the transfer order page is loaded, click on <span class="badge inventree add">{{ icon("plus-circle") }} New Transfer Order</span> which opens the "Create Transfer Order" form.
|
||||
|
||||
Fill out the rest of the form with the transfer order information then click on <span class="badge inventree confirm">Submit</span> to create the order.
|
||||
|
||||
### Transfer Order Reference
|
||||
|
||||
Each Transfer Order is uniquely identified by its *Reference* field. Read more about [reference fields](../settings/reference.md).
|
||||
|
||||
### Add Line Items
|
||||
|
||||
On the transfer order detail page, user can link parts to the transfer order selecting the <span class="badge inventree nav side">{{ icon("list") }}</span> Line Items</span> tab then clicking on the <span class="badge inventree add">{{ icon("plus-circle") }} Add Line Item</span> button.
|
||||
|
||||
Once the "Add Line Item" form opens, select a part in the list.
|
||||
|
||||
!!! warning
|
||||
Only parts that have the "Virtual" attribute disabled will be shown and can be selected.
|
||||
|
||||
Fill out the rest of the form then click on <span class="badge inventree confirm">Submit</span>
|
||||
|
||||
### Allocate Stock Items
|
||||
|
||||
After line items were created, user can either:
|
||||
|
||||
* Allocate stock items for that part to the transfer order (click on {{ icon("arrow-right") }} button)
|
||||
* Create a build order for that part to cover the quantity of the transfer order (click on {{ icon("tools") }} button)
|
||||
|
||||
### Complete Order
|
||||
|
||||
Once all items in the transfer order have been allocated, click on <span class="badge inventree add">{{ icon("circle-check", color="green") }} Complete Order</span> to mark the transfer order as complete. Confirm then click on <span class="badge inventree confirm">Submit</span> to complete the order.
|
||||
|
||||
### Transferred Stock
|
||||
|
||||
After completing the transfer order, a <span class="badge inventree nav side">{{ icon("list") }}</span> Transferred Stock</span> tab will appear showing which stock was affected.
|
||||
|
||||
!!! warning
|
||||
Similar to received stock on purchase orders, this tab will only be accurate while the affected stock items still exist. Furthermore, if the stock item is depleted while using the "consume" parameter, it will not appear here unless "delete on deplete" is turned off for this stock item
|
||||
|
||||
### Cancel Order
|
||||
|
||||
To cancel the order, click on the {{ icon("tools") }} menu button next to the <span class="badge inventree add">{{ icon("circle-check", color="green") }} Complete Order</span> button, then click on the "{{ icon("tools") }} Cancel Order" menu option. Confirm then click on the <span class="badge inventree confirm">Submit</span> to cancel the order.
|
||||
|
||||
## Order Scheduling
|
||||
|
||||
Transfer orders can be scheduled for a future date, to allow for order scheduling.
|
||||
|
||||
### Start Date
|
||||
|
||||
The *Start Date* of the transfer order is the date on which the order is scheduled to be issued, allowing work to begin on the order.
|
||||
|
||||
### Target Date
|
||||
|
||||
The *Target Date* of the transfer order is the date on which the order is scheduled to be completed.
|
||||
|
||||
### Overdue Orders
|
||||
|
||||
If the *Target Date* of the transfer order has passed, the order will be marked as *overdue*.
|
||||
|
||||
## Calendar view
|
||||
|
||||
Using the button to the top right of the list of Transfer Orders, the view can be switched to a calendar view using the button {{ icon("calendar") }}. This view shows orders with a defined target date only.
|
||||
|
||||
This view can be accessed externally as an ICS calendar using a URL like the following:
|
||||
`http://inventree.example.org/api/order/calendar/transfer-order/calendar.ics`
|
||||
|
||||
By default, completed orders are not exported. These can be included by appending `?include_completed=True` to the URL.
|
||||
|
||||
## Transfer Order Settings
|
||||
|
||||
The following [global settings](../settings/global.md) are available for transfer orders:
|
||||
|
||||
| Name | Description | Default | Units |
|
||||
| ---- | ----------- | ------- | ----- |
|
||||
{{ globalsetting("TRANSFERORDER_ENABLED") }}
|
||||
{{ globalsetting("TRANSFERORDER_REFERENCE_PATTERN") }}
|
||||
{{ globalsetting("TRANSFERORDER_REQUIRE_RESPONSIBLE") }}
|
||||
|
|
@ -145,6 +145,7 @@ nav:
|
|||
- Stock Expiry: stock/expiry.md
|
||||
- Stock Ownership: stock/owner.md
|
||||
- Test Results: stock/test.md
|
||||
- Transfer Orders: stock/transfer_order.md
|
||||
- Manufacturing:
|
||||
- Manufacturing: manufacturing/index.md
|
||||
- Bill of Materials: manufacturing/bom.md
|
||||
|
|
|
|||
|
|
@ -227,9 +227,9 @@ httpx==0.28.1 \
|
|||
--hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \
|
||||
--hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
|
||||
# via neoteroi-mkdocs
|
||||
idna==3.13 \
|
||||
--hash=sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242 \
|
||||
--hash=sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3
|
||||
idna==3.15 \
|
||||
--hash=sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8 \
|
||||
--hash=sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# anyio
|
||||
|
|
|
|||
|
|
@ -1,11 +1,24 @@
|
|||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 489
|
||||
INVENTREE_API_VERSION = 493
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v493 -> 2026-05-18 : https://github.com/inventree/InvenTree/pull/11961
|
||||
- Adds "thumbnail" field to the Attachment API endpoint, which provides a URL to a thumbnail image for image attachments (if available)
|
||||
|
||||
v492 -> 2026-05-22 : https://github.com/inventree/InvenTree/pull/11281
|
||||
- Add Transfer Order model and associated API endpoint
|
||||
|
||||
v491 -> 2026-05-21 : https://github.com/inventree/InvenTree/pull/11979
|
||||
- Add API serializer for deleting a part category
|
||||
- Add API serializer for deleting a stock location
|
||||
|
||||
v490 -> 2026-05-19 : https://github.com/inventree/InvenTree/pull/11963
|
||||
- moves user-self-filtered endpoints to /user/me/ to make their security boundaries clearer
|
||||
|
||||
v489 -> 2026-05-18 : https://github.com/inventree/InvenTree/pull/11962
|
||||
- Removes the "remote_image" field from the Part API endpoint
|
||||
- Removes the "remote_image" field from the Company API endpoint
|
||||
|
|
|
|||
|
|
@ -341,3 +341,24 @@ def schema_for_view_output_options(view_class):
|
|||
view_class
|
||||
)
|
||||
return extended_view
|
||||
|
||||
|
||||
def exclude_from_schema(klass: type, alternative_path: str) -> type:
|
||||
"""Decorator to exclude a view from the OpenAPI schema.
|
||||
|
||||
This is used to hide legacy endpoints from the schema, while still retaining them for backwards compatibility.
|
||||
"""
|
||||
|
||||
class LegacyView(klass):
|
||||
"""Dummy doc."""
|
||||
|
||||
LegacyView.__name__ = klass.__name__ + ' - Legacy'
|
||||
LegacyView.__doc__ = f'This is a legacy endpoint, retained for backwards compatibility. Consider migrating to the new endpoint under {alternative_path}.'
|
||||
|
||||
# Exclude all default operations from the schema
|
||||
for operation in ['get', 'post', 'put', 'patch', 'delete']:
|
||||
if hasattr(klass, operation):
|
||||
LegacyView = extend_schema_view(**{operation: extend_schema(exclude=True)})(
|
||||
LegacyView
|
||||
)
|
||||
return LegacyView
|
||||
|
|
|
|||
|
|
@ -717,7 +717,17 @@ class AttachmentFilter(FilterSet):
|
|||
"""Metaclass options."""
|
||||
|
||||
model = common.models.Attachment
|
||||
fields = ['model_type', 'model_id', 'upload_user']
|
||||
fields = ['model_type', 'model_id', 'upload_user', 'is_image']
|
||||
|
||||
has_thumbnail = rest_filters.BooleanFilter(
|
||||
label=_('Has Thumbnail'), method='filter_has_thumbnail'
|
||||
)
|
||||
|
||||
def filter_has_thumbnail(self, queryset, name, value):
|
||||
"""Filter attachments based on whether they have a thumbnail or not."""
|
||||
if value:
|
||||
return queryset.exclude(thumbnail=None).exclude(thumbnail='')
|
||||
return queryset.filter(Q(thumbnail=None) | Q(thumbnail='')).distinct()
|
||||
|
||||
is_link = rest_filters.BooleanFilter(label=_('Is Link'), method='filter_is_link')
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
# Generated by Django 5.2.14 on 2026-05-18 11:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import common.models
|
||||
import common.validators
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("common", "0041_auto_20251203_1244"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="attachment",
|
||||
name="thumbnail",
|
||||
field=models.ImageField(
|
||||
blank=True,
|
||||
help_text="Thumbnail image for this attachment",
|
||||
null=True,
|
||||
upload_to="",
|
||||
verbose_name="Thumbnail",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="attachment",
|
||||
name="is_image",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="True if this attachment is a valid image file",
|
||||
verbose_name="Is image",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="attachment",
|
||||
name="attachment",
|
||||
field=models.FileField(
|
||||
blank=True,
|
||||
help_text="Select file to attach",
|
||||
null=True,
|
||||
upload_to=common.models.rename_attachment,
|
||||
validators=[common.validators.validate_attachment_file],
|
||||
verbose_name="Attachment",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
# Generated by Django 5.2.14 on 2026-05-18 12:06
|
||||
|
||||
import io
|
||||
import os
|
||||
|
||||
from PIL import Image
|
||||
from tqdm import tqdm
|
||||
|
||||
from time import sleep
|
||||
|
||||
from django.db import migrations
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
|
||||
def update_image_attachments(apps, schema_editor):
|
||||
"""Update existing attachments to ensure that image attachments have thumbnails.
|
||||
|
||||
For each existing attachment, check if it is an image.
|
||||
If it is, generate a thumbnail for it.
|
||||
|
||||
Note: This function mirrors the logic used in the Attachment model's
|
||||
check_is_image method, at the time of writing this migration (2026-05-18).
|
||||
"""
|
||||
|
||||
import common.tasks
|
||||
|
||||
Attachment = apps.get_model('common', 'Attachment')
|
||||
|
||||
# Find all Attachment instances which (potentially) have a file attached
|
||||
attachments = Attachment.objects.exclude(attachment__isnull=True).exclude(attachment='')
|
||||
|
||||
if attachments.count() == 0:
|
||||
return
|
||||
|
||||
progress = tqdm(total=attachments.count(), desc='Migration common.0043: Updating attachments')
|
||||
|
||||
for attachment in attachments:
|
||||
progress.update(1)
|
||||
|
||||
if not attachment.attachment:
|
||||
continue
|
||||
|
||||
if not attachment.attachment.name or not default_storage.exists(attachment.attachment.name):
|
||||
continue
|
||||
|
||||
common.tasks.rebuild_attachment(attachment.pk)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("common", "0042_attachment_is_image_attachment_thumbnail"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(update_image_attachments, reverse_code=migrations.RunPython.noop),
|
||||
]
|
||||
|
|
@ -48,6 +48,7 @@ from django_q.signals import post_spawn
|
|||
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||
from djmoney.contrib.exchange.models import convert_money
|
||||
from opentelemetry import trace
|
||||
from PIL import Image
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
|
|
@ -1932,6 +1933,8 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
|
|||
model_id: The ID of the model to which this attachment is linked
|
||||
attachment: The uploaded file
|
||||
url: An external URL
|
||||
thumbnail: A generated thumbnail for the uploaded file (if applicable)
|
||||
is_image: True if this attachment is a valid image file
|
||||
comment: A comment or description for the attachment
|
||||
user: The user who uploaded the attachment
|
||||
upload_date: The date the attachment was uploaded
|
||||
|
|
@ -1940,6 +1943,8 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
|
|||
tags: Tags for the attachment
|
||||
"""
|
||||
|
||||
THUMBNAIL_SIZE = 256
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
|
|
@ -1956,9 +1961,11 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
|
|||
- Ensure that the attached file is deleted from storage when the database entry is removed
|
||||
"""
|
||||
attachment = self.attachment
|
||||
thumbnail = self.thumbnail
|
||||
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
# Delete the associated files from storage (if they exist)W
|
||||
if attachment and default_storage.exists(attachment.name):
|
||||
try:
|
||||
# Remove the attached file from storage
|
||||
|
|
@ -1966,6 +1973,13 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
|
|||
except Exception: # pragma: no cover
|
||||
pass
|
||||
|
||||
if thumbnail and default_storage.exists(thumbnail.name):
|
||||
try:
|
||||
# Remove the thumbnail file from storage
|
||||
default_storage.delete(thumbnail.name)
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Custom 'save' method for the Attachment model.
|
||||
|
||||
|
|
@ -1973,6 +1987,10 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
|
|||
- Ensure that the 'content_type' and 'object_id' fields are set
|
||||
- Run extra validations
|
||||
"""
|
||||
import common.tasks
|
||||
|
||||
rebuild = kwargs.pop('rebuild', True)
|
||||
|
||||
# Either 'attachment' or 'link' must be specified!
|
||||
if not self.attachment and not self.link:
|
||||
raise ValidationError({
|
||||
|
|
@ -2000,6 +2018,12 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
|
|||
if self.file_size != 0:
|
||||
super().save()
|
||||
|
||||
# Offload a background task to update the thumbnail for this attachment
|
||||
if rebuild:
|
||||
InvenTree.tasks.offload_task(
|
||||
common.tasks.rebuild_attachment, self.pk, group='attachments'
|
||||
)
|
||||
|
||||
def clean_svg(self, field):
|
||||
"""Sanitize SVG file before saving."""
|
||||
cleaned = sanitize_svg(field.file.read())
|
||||
|
|
@ -2077,11 +2101,19 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
|
|||
attachment = models.FileField(
|
||||
upload_to=rename_attachment,
|
||||
verbose_name=_('Attachment'),
|
||||
validators=[common.validators.validate_attachment_file],
|
||||
help_text=_('Select file to attach'),
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
thumbnail = models.ImageField(
|
||||
verbose_name=_('Thumbnail'),
|
||||
help_text=_('Thumbnail image for this attachment'),
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
link = InvenTree.fields.InvenTreeURLField(
|
||||
blank=True,
|
||||
null=True,
|
||||
|
|
@ -2114,6 +2146,12 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
|
|||
help_text=_('Date the file was uploaded'),
|
||||
)
|
||||
|
||||
is_image = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('Is image'),
|
||||
help_text=_('True if this attachment is a valid image file'),
|
||||
)
|
||||
|
||||
file_size = models.PositiveIntegerField(
|
||||
default=0, verbose_name=_('File size'), help_text=_('File size in bytes')
|
||||
)
|
||||
|
|
@ -2157,6 +2195,69 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
|
|||
|
||||
return model_class.check_related_permission(permission, user)
|
||||
|
||||
def check_is_image(self) -> bool:
|
||||
"""Check if the attached file is an image.
|
||||
|
||||
We consider it a valid image if:
|
||||
|
||||
- The file exists in storage
|
||||
- The file can be opened and verified by the PIL library
|
||||
|
||||
"""
|
||||
if not self.attachment:
|
||||
return False
|
||||
|
||||
if not self.attachment.name:
|
||||
return False
|
||||
|
||||
try:
|
||||
if not default_storage.exists(self.attachment.name):
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
img_data = default_storage.open(self.attachment.name).read()
|
||||
|
||||
try:
|
||||
Image.open(BytesIO(img_data)).verify()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def generate_thumbnail(self):
|
||||
"""Generate a thumbnail for the attached image."""
|
||||
# Remove any existing thumbnail
|
||||
if self.thumbnail:
|
||||
self.thumbnail.delete(save=False)
|
||||
|
||||
if not self.attachment:
|
||||
return
|
||||
|
||||
if not self.attachment.name or not default_storage.exists(self.attachment.name):
|
||||
return
|
||||
|
||||
# TODO: Offload to plugins, for creating custom thumbnails for different file types
|
||||
# TODO: If a plugin provides a thumbnail, return early
|
||||
|
||||
# Default action is to generate a thumbnail for image files
|
||||
try:
|
||||
img_data = default_storage.open(self.attachment.name).read()
|
||||
except Exception:
|
||||
# No file found, or file cannot be read - cannot generate thumbnail
|
||||
return
|
||||
|
||||
try:
|
||||
img = Image.open(BytesIO(img_data))
|
||||
img.thumbnail((self.THUMBNAIL_SIZE, self.THUMBNAIL_SIZE))
|
||||
thumb_io = BytesIO()
|
||||
img.save(thumb_io, format='PNG')
|
||||
thumb_io.seek(0)
|
||||
|
||||
thumb_name = f'thumb_{os.path.basename(self.attachment.name)}'
|
||||
self.thumbnail.save(thumb_name, ContentFile(thumb_io.read()), save=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class InvenTreeCustomUserStateModel(models.Model):
|
||||
"""Custom model to extends any registered state with extra custom, user defined states.
|
||||
|
|
@ -2684,7 +2785,7 @@ def post_save_parameter_template(sender, instance, created, **kwargs):
|
|||
common.tasks.rebuild_parameters,
|
||||
instance.pk,
|
||||
force_async=True,
|
||||
group='part',
|
||||
group='parameters',
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -730,9 +730,11 @@ class AttachmentSerializer(FilterableSerializerMixin, InvenTreeModelSerializer):
|
|||
fields = [
|
||||
'pk',
|
||||
'attachment',
|
||||
'thumbnail',
|
||||
'filename',
|
||||
'link',
|
||||
'comment',
|
||||
'is_image',
|
||||
'upload_date',
|
||||
'upload_user',
|
||||
'user_detail',
|
||||
|
|
@ -742,7 +744,14 @@ class AttachmentSerializer(FilterableSerializerMixin, InvenTreeModelSerializer):
|
|||
'tags',
|
||||
]
|
||||
|
||||
read_only_fields = ['pk', 'file_size', 'upload_date', 'upload_user', 'filename']
|
||||
read_only_fields = [
|
||||
'pk',
|
||||
'file_size',
|
||||
'upload_date',
|
||||
'upload_user',
|
||||
'filename',
|
||||
'is_image',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Override the model_type field to provide dynamic choices."""
|
||||
|
|
@ -759,6 +768,8 @@ class AttachmentSerializer(FilterableSerializerMixin, InvenTreeModelSerializer):
|
|||
|
||||
attachment = InvenTreeAttachmentSerializerField(required=False, allow_null=True)
|
||||
|
||||
thumbnail = InvenTreeImageSerializerField(read_only=True, allow_null=True)
|
||||
|
||||
# The 'filename' field must be present in the serializer
|
||||
filename = serializers.CharField(
|
||||
label=_('Filename'), required=False, source='basename', allow_blank=False
|
||||
|
|
|
|||
|
|
@ -286,6 +286,13 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
|||
'choices': common.currency.currency_exchange_plugins,
|
||||
'default': 'inventreecurrencyexchange',
|
||||
},
|
||||
'INVENTREE_UPLOAD_MAX_SIZE': {
|
||||
'name': _('Upload Size Limit'),
|
||||
'description': _('Maximum allowable upload size for images and files'),
|
||||
'units': 'MB',
|
||||
'default': 10,
|
||||
'validator': [int, MinValueValidator(1)],
|
||||
},
|
||||
'INVENTREE_STRICT_URLS': {
|
||||
'name': _('Strict URL Validation'),
|
||||
'description': _('Require schema specification when validating URLs'),
|
||||
|
|
@ -904,6 +911,26 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
|||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'TRANSFERORDER_ENABLED': {
|
||||
'name': _('Enable Transfer Orders'),
|
||||
'description': _('Enable transfer order functionality in the user interface'),
|
||||
'validator': bool,
|
||||
'default': False,
|
||||
},
|
||||
'TRANSFERORDER_REFERENCE_PATTERN': {
|
||||
'name': _('Transfer Order Reference Pattern'),
|
||||
'description': _(
|
||||
'Required pattern for generating Transfer Order reference field'
|
||||
),
|
||||
'default': 'TO-{ref:04d}',
|
||||
'validator': order.validators.validate_transfer_order_reference_pattern,
|
||||
},
|
||||
'TRANSFERORDER_REQUIRE_RESPONSIBLE': {
|
||||
'name': _('Require Responsible Owner'),
|
||||
'description': _('A responsible owner must be assigned to each order'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'SALESORDER_BLOCK_INCOMPLETE_ITEM_TESTS': {
|
||||
'name': _('Block Incomplete Item Tests'),
|
||||
'description': _(
|
||||
|
|
|
|||
|
|
@ -204,3 +204,21 @@ def rebuild_parameters(template_id):
|
|||
|
||||
if n > 0:
|
||||
logger.info("Rebuilt %s parameters for template '%s'", n, template.name)
|
||||
|
||||
|
||||
@tracer.start_as_current_span('rebuild_attachment')
|
||||
def rebuild_attachment(attachment_id: int):
|
||||
"""Rebuild the given attachment, if possible.
|
||||
|
||||
This task is called whenever an attachment is saved, and perform the following tasks:
|
||||
|
||||
- Check if the attachment is an image file, and update the "is_image" field accordingly
|
||||
- Attempt to generate a thumbnail for the attachment
|
||||
"""
|
||||
from common.models import Attachment
|
||||
|
||||
attachment = Attachment.objects.get(pk=attachment_id)
|
||||
|
||||
attachment.is_image = attachment.check_is_image()
|
||||
attachment.generate_thumbnail()
|
||||
attachment.save(rebuild=False)
|
||||
|
|
|
|||
|
|
@ -4,8 +4,11 @@ import io
|
|||
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.urls import reverse
|
||||
|
||||
from PIL import Image
|
||||
|
||||
import common.models
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||
|
||||
|
|
@ -885,3 +888,210 @@ class NoteAPITests(InvenTreeAPITestCase):
|
|||
# The note on Part A should still be primary
|
||||
note_a_detail = self.get(self._note_url(note_a.data['pk']), expected_code=200)
|
||||
self.assertTrue(note_a_detail.data['primary'])
|
||||
|
||||
|
||||
class AttachmentThumbnailAPITests(InvenTreeAPITestCase):
|
||||
"""Tests for thumbnail generation when uploading attachments via the API."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up a Part instance and required roles."""
|
||||
from part.models import Part
|
||||
|
||||
super().setUp()
|
||||
self.assignRole('part.add')
|
||||
self.assignRole('part.delete')
|
||||
self.part = Part.objects.create(
|
||||
name='Thumbnail Test Part', description='Part for thumbnail testing'
|
||||
)
|
||||
|
||||
def _make_image_file(self, name='test.png', size=(100, 100), color='red'):
|
||||
"""Return a SimpleUploadedFile containing a valid PNG image."""
|
||||
buf = io.BytesIO()
|
||||
Image.new('RGB', size, color=color).save(buf, format='PNG')
|
||||
return SimpleUploadedFile(name, buf.getvalue(), content_type='image/png')
|
||||
|
||||
def _upload_attachment(self, file_obj, expected_code=201):
|
||||
"""Upload a file attachment against the test part and return the response."""
|
||||
return self.post(
|
||||
reverse('api-attachment-list'),
|
||||
data={
|
||||
'model_type': 'part',
|
||||
'model_id': self.part.pk,
|
||||
'attachment': file_obj,
|
||||
},
|
||||
format='multipart',
|
||||
expected_code=expected_code,
|
||||
)
|
||||
|
||||
def test_thumbnail_valid_image(self):
|
||||
"""Uploading a valid image file should set is_image=True and generate a thumbnail."""
|
||||
from common.models import Attachment
|
||||
|
||||
response = self._upload_attachment(self._make_image_file())
|
||||
att = Attachment.objects.get(pk=response.data['pk'])
|
||||
|
||||
self.assertTrue(att.is_image)
|
||||
self.assertTrue(att.thumbnail)
|
||||
self.assertTrue(default_storage.exists(att.thumbnail.name))
|
||||
|
||||
def test_thumbnail_invalid_image(self):
|
||||
"""Uploading a file with an image extension but invalid image data should not create a thumbnail."""
|
||||
from common.models import Attachment
|
||||
|
||||
bad_file = SimpleUploadedFile(
|
||||
'corrupt.png', b'this is not image data', content_type='image/png'
|
||||
)
|
||||
response = self._upload_attachment(bad_file)
|
||||
att = Attachment.objects.get(pk=response.data['pk'])
|
||||
|
||||
self.assertFalse(att.is_image)
|
||||
self.assertFalse(att.thumbnail)
|
||||
|
||||
def test_thumbnail_non_image_file(self):
|
||||
"""Uploading a non-image file should leave is_image=False with no thumbnail."""
|
||||
from common.models import Attachment
|
||||
|
||||
txt_file = SimpleUploadedFile(
|
||||
'document.txt', b'Hello, InvenTree!', content_type='text/plain'
|
||||
)
|
||||
response = self._upload_attachment(txt_file)
|
||||
att = Attachment.objects.get(pk=response.data['pk'])
|
||||
|
||||
self.assertFalse(att.is_image)
|
||||
self.assertFalse(att.thumbnail)
|
||||
|
||||
def test_thumbnail_large_image(self):
|
||||
"""A large image attachment should produce a thumbnail no larger than THUMBNAIL_SIZE on each side."""
|
||||
from common.models import Attachment
|
||||
|
||||
response = self._upload_attachment(self._make_image_file(size=(1000, 1000)))
|
||||
att = Attachment.objects.get(pk=response.data['pk'])
|
||||
|
||||
self.assertTrue(att.is_image)
|
||||
self.assertTrue(att.thumbnail)
|
||||
|
||||
thumb_data = default_storage.open(att.thumbnail.name).read()
|
||||
thumb_img = Image.open(io.BytesIO(thumb_data))
|
||||
self.assertLessEqual(thumb_img.width, Attachment.THUMBNAIL_SIZE)
|
||||
self.assertLessEqual(thumb_img.height, Attachment.THUMBNAIL_SIZE)
|
||||
|
||||
def test_thumbnail_deleted_with_attachment(self):
|
||||
"""Deleting an attachment via the API should also remove its thumbnail from storage."""
|
||||
from common.models import Attachment
|
||||
|
||||
response = self._upload_attachment(self._make_image_file())
|
||||
att = Attachment.objects.get(pk=response.data['pk'])
|
||||
|
||||
self.assertTrue(att.thumbnail)
|
||||
thumb_name = att.thumbnail.name
|
||||
att_name = att.attachment.name
|
||||
|
||||
self.assertTrue(default_storage.exists(att_name))
|
||||
self.assertTrue(default_storage.exists(thumb_name))
|
||||
|
||||
self.delete(
|
||||
reverse('api-attachment-detail', kwargs={'pk': att.pk}), expected_code=204
|
||||
)
|
||||
|
||||
self.assertFalse(default_storage.exists(att_name))
|
||||
self.assertFalse(default_storage.exists(thumb_name))
|
||||
|
||||
def test_thumbnail_zero_byte_file(self):
|
||||
"""Uploading a zero-byte file should be rejected by Django's file validation before reaching thumbnail logic."""
|
||||
empty_file = SimpleUploadedFile('empty.png', b'', content_type='image/png')
|
||||
# Django's FileField rejects empty uploads at the serializer/validation layer
|
||||
response = self._upload_attachment(empty_file, expected_code=400)
|
||||
self.assertIn('attachment', response.data)
|
||||
|
||||
def test_thumbnail_link_attachment(self):
|
||||
"""An attachment created with an external link (no file) should not generate a thumbnail."""
|
||||
from common.models import Attachment
|
||||
|
||||
response = self.post(
|
||||
reverse('api-attachment-list'),
|
||||
data={
|
||||
'model_type': 'part',
|
||||
'model_id': self.part.pk,
|
||||
'link': 'https://example.com/some/resource',
|
||||
},
|
||||
format='multipart',
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
att = Attachment.objects.get(pk=response.data['pk'])
|
||||
|
||||
self.assertFalse(att.is_image)
|
||||
self.assertFalse(att.thumbnail)
|
||||
|
||||
def test_is_image_filter(self):
|
||||
"""The is_image filter on the attachment list endpoint should return only matching attachments."""
|
||||
url = reverse('api-attachment-list')
|
||||
base_filters = {'model_type': 'part', 'model_id': self.part.pk}
|
||||
|
||||
# Upload one valid image and three non-image attachments
|
||||
self._upload_attachment(self._make_image_file('img1.png'))
|
||||
self._upload_attachment(
|
||||
SimpleUploadedFile(
|
||||
'corrupt.png', b'not image data', content_type='image/png'
|
||||
)
|
||||
)
|
||||
self._upload_attachment(
|
||||
SimpleUploadedFile('doc.txt', b'hello', content_type='text/plain')
|
||||
)
|
||||
self.post(
|
||||
url,
|
||||
data={**base_filters, 'link': 'https://example.com/resource'},
|
||||
format='multipart',
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
all_attachments = self.get(url, base_filters, expected_code=200).data
|
||||
self.assertEqual(len(all_attachments), 4)
|
||||
|
||||
# is_image=true → only the valid image
|
||||
images = self.get(
|
||||
url, {**base_filters, 'is_image': 'true'}, expected_code=200
|
||||
).data
|
||||
self.assertEqual(len(images), 1)
|
||||
self.assertTrue(images[0]['is_image'])
|
||||
|
||||
# is_image=false → the three non-image attachments
|
||||
non_images = self.get(
|
||||
url, {**base_filters, 'is_image': 'false'}, expected_code=200
|
||||
).data
|
||||
self.assertEqual(len(non_images), 3)
|
||||
self.assertTrue(all(not a['is_image'] for a in non_images))
|
||||
|
||||
def test_upload_exceeds_size_limit(self):
|
||||
"""Uploading a file that exceeds INVENTREE_UPLOAD_MAX_SIZE should be rejected with a 400 error."""
|
||||
from common.settings import get_global_setting, set_global_setting
|
||||
|
||||
original_limit = get_global_setting('INVENTREE_UPLOAD_MAX_SIZE')
|
||||
# Use a 1 MB ceiling so the test file stays small and fast
|
||||
set_global_setting('INVENTREE_UPLOAD_MAX_SIZE', 1, change_user=None)
|
||||
|
||||
limit_bytes = 1 * 1024 * 1024
|
||||
|
||||
try:
|
||||
# File exactly at the limit — validator uses >, so this must be accepted
|
||||
self._upload_attachment(
|
||||
SimpleUploadedFile(
|
||||
'at_limit.txt', b'\x00' * limit_bytes, content_type='text/plain'
|
||||
),
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
# File one byte over the limit — must be rejected
|
||||
response = self._upload_attachment(
|
||||
SimpleUploadedFile(
|
||||
'over_limit.txt',
|
||||
b'\x00' * (limit_bytes + 1),
|
||||
content_type='text/plain',
|
||||
),
|
||||
expected_code=400,
|
||||
)
|
||||
self.assertIn('attachment', response.data)
|
||||
finally:
|
||||
set_global_setting(
|
||||
'INVENTREE_UPLOAD_MAX_SIZE', original_limit, change_user=None
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import io
|
|||
import os
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
from django_test_migrations.contrib.unittest_case import MigratorTestCase
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def get_legacy_models():
|
||||
|
|
@ -209,6 +211,85 @@ class TestForwardMigrations(MigratorTestCase):
|
|||
self.assertEqual(Attachment.objects.filter(model_type=model).count(), 2)
|
||||
|
||||
|
||||
class TestAttachmentThumbnailMigration(MigratorTestCase):
|
||||
"""Test that migration 0043 correctly populates is_image and generates thumbnails for existing attachments."""
|
||||
|
||||
migrate_from = ('common', '0041_auto_20251203_1244')
|
||||
migrate_to = ('common', '0043_auto_20260518_1206')
|
||||
|
||||
def prepare(self):
|
||||
"""Create a set of attachments with different file types in the pre-migration state.
|
||||
|
||||
At this point the Attachment model has no is_image or thumbnail fields yet.
|
||||
Files are written to storage directly through the FileField so that the
|
||||
data migration can find them at their stored paths.
|
||||
"""
|
||||
Attachment = self.old_state.apps.get_model('common', 'Attachment')
|
||||
|
||||
# 1. Valid PNG image — migration should set is_image=True and create a thumbnail
|
||||
buf = io.BytesIO()
|
||||
Image.new('RGB', (100, 100), color='blue').save(buf, format='PNG')
|
||||
Attachment.objects.create(
|
||||
model_type='part',
|
||||
model_id=1,
|
||||
attachment=ContentFile(buf.getvalue(), name='valid_image.png'),
|
||||
comment='valid_image',
|
||||
)
|
||||
|
||||
# 2. File with a .png extension but non-image content — migration should leave is_image=False
|
||||
Attachment.objects.create(
|
||||
model_type='part',
|
||||
model_id=1,
|
||||
attachment=ContentFile(b'this is not image data', name='corrupt.png'),
|
||||
comment='corrupt_image',
|
||||
)
|
||||
|
||||
# 3. Plain text file — migration should leave is_image=False with no thumbnail
|
||||
Attachment.objects.create(
|
||||
model_type='part',
|
||||
model_id=1,
|
||||
attachment=ContentFile(b'Hello, InvenTree!', name='document.txt'),
|
||||
comment='text_file',
|
||||
)
|
||||
|
||||
# 4. Link attachment (no file at all) — migration should skip it entirely
|
||||
Attachment.objects.create(
|
||||
model_type='part',
|
||||
model_id=1,
|
||||
link='https://example.com/resource',
|
||||
comment='link_attachment',
|
||||
)
|
||||
|
||||
self.assertEqual(Attachment.objects.count(), 4)
|
||||
|
||||
def test_attachment_thumbnails_after_migration(self):
|
||||
"""After applying migrations 0042 and 0043, verify is_image and thumbnail are correct."""
|
||||
Attachment = self.new_state.apps.get_model('common', 'Attachment')
|
||||
|
||||
self.assertEqual(Attachment.objects.count(), 4)
|
||||
|
||||
# Valid image → is_image set, thumbnail file created in storage
|
||||
att = Attachment.objects.get(comment='valid_image')
|
||||
self.assertTrue(att.is_image)
|
||||
self.assertTrue(att.thumbnail)
|
||||
self.assertTrue(default_storage.exists(att.thumbnail.name))
|
||||
|
||||
# Corrupt image → is_image not set, no thumbnail
|
||||
att = Attachment.objects.get(comment='corrupt_image')
|
||||
self.assertFalse(att.is_image)
|
||||
self.assertFalse(att.thumbnail)
|
||||
|
||||
# Text file → is_image not set, no thumbnail
|
||||
att = Attachment.objects.get(comment='text_file')
|
||||
self.assertFalse(att.is_image)
|
||||
self.assertFalse(att.thumbnail)
|
||||
|
||||
# Link attachment → is_image not set, no thumbnail
|
||||
att = Attachment.objects.get(comment='link_attachment')
|
||||
self.assertFalse(att.is_image)
|
||||
self.assertFalse(att.thumbnail)
|
||||
|
||||
|
||||
def prep_currency_migration(self, vals: str):
|
||||
"""Prepare the environment for the currency migration tests."""
|
||||
# Set keys
|
||||
|
|
|
|||
|
|
@ -86,6 +86,11 @@ class AttachmentTest(InvenTreeAPITestCase):
|
|||
}
|
||||
|
||||
for fn, expected in filenames.items():
|
||||
expected_path = f'attachments/part/{part.pk}/{expected}'
|
||||
# Remove the file if it already exists (i.e. from a previous test run)
|
||||
if default_storage.exists(expected_path):
|
||||
default_storage.delete(expected_path)
|
||||
|
||||
attachment = Attachment.objects.create(
|
||||
attachment=self.generate_file(fn),
|
||||
comment=f'Testing filename: {fn}',
|
||||
|
|
@ -93,7 +98,6 @@ class AttachmentTest(InvenTreeAPITestCase):
|
|||
model_id=part.pk,
|
||||
)
|
||||
|
||||
expected_path = f'attachments/part/{part.pk}/{expected}'
|
||||
self.assertEqual(attachment.attachment.name, expected_path)
|
||||
self.assertEqual(attachment.file_size, 15)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
import re
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.exceptions import SuspiciousFileOperation, ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import common.icons
|
||||
|
|
@ -93,6 +94,21 @@ def validate_attachment_model_type(value):
|
|||
raise ValidationError('Model type does not support attachments')
|
||||
|
||||
|
||||
def validate_attachment_file(attachment):
|
||||
"""Ensure that the provided attachment file is valid."""
|
||||
max_size = get_global_setting('INVENTREE_UPLOAD_MAX_SIZE', create=False)
|
||||
|
||||
if attachment.size > (max_size * 1024 * 1024):
|
||||
raise ValidationError(
|
||||
_(f'File size exceeds maximum upload limit of {max_size} MB')
|
||||
)
|
||||
|
||||
try:
|
||||
default_storage.generate_filename(attachment.name)
|
||||
except SuspiciousFileOperation: # pragma: no cover
|
||||
raise ValidationError(_('Invalid file name'))
|
||||
|
||||
|
||||
def validate_notes_model_type(value):
|
||||
"""Ensure that the provided model type is valid.
|
||||
|
||||
|
|
|
|||
|
|
@ -232,8 +232,8 @@ class ApiTests(InvenTreeAPITestCase):
|
|||
"""Test the API endpoint for listing all status models."""
|
||||
response = self.get(reverse('api-status-all'))
|
||||
|
||||
# 10 built-in state classes, plus the added GeneralState class
|
||||
self.assertEqual(len(response.data), 11)
|
||||
# 11 built-in state classes, plus the added GeneralState class
|
||||
self.assertEqual(len(response.data), 12)
|
||||
|
||||
# Test the BuildStatus model
|
||||
build_status = response.data['BuildStatus']
|
||||
|
|
@ -273,7 +273,7 @@ class ApiTests(InvenTreeAPITestCase):
|
|||
)
|
||||
response = self.get(reverse('api-status-all'))
|
||||
|
||||
self.assertEqual(len(response.data), 11)
|
||||
self.assertEqual(len(response.data), 12)
|
||||
|
||||
stock_status_cstm = response.data['StockStatus']
|
||||
self.assertEqual(stock_status_cstm['status_class'], 'StockStatus')
|
||||
|
|
|
|||
|
|
@ -170,3 +170,41 @@ class ReturnOrderLineItemAdmin(admin.ModelAdmin):
|
|||
@admin.register(models.ReturnOrderExtraLine)
|
||||
class ReturnOrdeerExtraLineAdmin(GeneralExtraLineAdmin, admin.ModelAdmin):
|
||||
"""Admin class for the ReturnOrderExtraLine model."""
|
||||
|
||||
|
||||
class TransferOrderLineItemInlineAdmin(admin.StackedInline):
|
||||
"""Inline admin class for the TransferOrderLineItem model."""
|
||||
|
||||
autocomplete_fields = ['part']
|
||||
|
||||
model = models.TransferOrderLineItem
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(models.TransferOrder)
|
||||
class TransferOrderAdmin(admin.ModelAdmin):
|
||||
"""Admin class for the TransferOrder model."""
|
||||
|
||||
exclude = ['reference_int', 'address', 'contact']
|
||||
|
||||
list_display = (
|
||||
'reference',
|
||||
'status',
|
||||
'description',
|
||||
'take_from',
|
||||
'destination',
|
||||
'consume',
|
||||
'creation_date',
|
||||
)
|
||||
|
||||
search_fields = ['reference', 'description']
|
||||
|
||||
inlines = [TransferOrderLineItemInlineAdmin]
|
||||
|
||||
autocomplete_fields = [
|
||||
'created_by',
|
||||
'take_from',
|
||||
'destination',
|
||||
'project_code',
|
||||
'responsible',
|
||||
]
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ from order.status_codes import (
|
|||
ReturnOrderStatus,
|
||||
SalesOrderStatus,
|
||||
SalesOrderStatusGroups,
|
||||
TransferOrderStatus,
|
||||
TransferOrderStatusGroups,
|
||||
)
|
||||
from part.models import Part
|
||||
from users.models import Owner
|
||||
|
|
@ -1766,6 +1768,521 @@ class ReturnOrderExtraLineDetail(RetrieveUpdateDestroyAPI):
|
|||
serializer_class = serializers.ReturnOrderExtraLineSerializer
|
||||
|
||||
|
||||
class TransferOrderFilter(OrderFilter):
|
||||
"""Custom API filters for the TransferOrderList endpoint."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = models.TransferOrder
|
||||
fields = []
|
||||
|
||||
include_variants = rest_filters.BooleanFilter(
|
||||
label=_('Include Variants'), method='filter_include_variants'
|
||||
)
|
||||
|
||||
def filter_include_variants(self, queryset, name, value):
|
||||
"""Filter by whether or not to include variants of the selected part.
|
||||
|
||||
Note:
|
||||
- This filter does nothing by itself, and requires the 'part' filter to be set.
|
||||
- Refer to the 'filter_part' method for more information.
|
||||
"""
|
||||
return queryset
|
||||
|
||||
part = rest_filters.ModelChoiceFilter(
|
||||
queryset=Part.objects.all(), field_name='part', method='filter_part'
|
||||
)
|
||||
|
||||
@extend_schema_field(OpenApiTypes.INT)
|
||||
def filter_part(self, queryset, name, part):
|
||||
"""Filter by selected 'part'.
|
||||
|
||||
Note:
|
||||
- If 'include_variants' is set to True, then all variants of the selected part will be included.
|
||||
- Otherwise, just filter by the selected part.
|
||||
"""
|
||||
include_variants = str2bool(self.data.get('include_variants', False))
|
||||
|
||||
if include_variants:
|
||||
parts = part.get_descendants(include_self=True)
|
||||
else:
|
||||
parts = Part.objects.filter(pk=part.pk)
|
||||
|
||||
# Now that we have a queryset of parts, find all the matching return orders
|
||||
line_items = models.TransferOrderLineItem.objects.filter(part__in=parts)
|
||||
|
||||
# Generate a list of ID values for the matching transfer orders
|
||||
transfer_orders = line_items.values_list('order', flat=True).distinct()
|
||||
|
||||
# Now we have a list of matching IDs, filter the queryset
|
||||
return queryset.filter(pk__in=transfer_orders)
|
||||
|
||||
completed_before = InvenTreeDateFilter(
|
||||
label=_('Completed Before'), field_name='complete_date', lookup_expr='lt'
|
||||
)
|
||||
|
||||
completed_after = InvenTreeDateFilter(
|
||||
label=_('Completed After'), field_name='complete_date', lookup_expr='gt'
|
||||
)
|
||||
|
||||
|
||||
class TransferOrderMixin(SerializerContextMixin):
|
||||
"""Mixin class for TransferOrder endpoints."""
|
||||
|
||||
queryset = models.TransferOrder.objects.all()
|
||||
serializer_class = serializers.TransferOrderSerializer
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
"""Return annotated queryset for this endpoint."""
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
queryset = serializers.TransferOrderSerializer.annotate_queryset(queryset)
|
||||
queryset = queryset.prefetch_related('created_by', 'responsible')
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class TransferOrderList(
|
||||
TransferOrderMixin,
|
||||
OrderCreateMixin,
|
||||
DataExportViewMixin,
|
||||
OutputOptionsMixin,
|
||||
ParameterListMixin,
|
||||
ListCreateAPI,
|
||||
):
|
||||
"""API endpoint for accessing a list of TransferOrder objects."""
|
||||
|
||||
filterset_class = TransferOrderFilter
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
# TODO:
|
||||
# output_options = TransferOrderOutputOptions
|
||||
|
||||
ordering_field_aliases = {
|
||||
'reference': ['reference_int', 'reference'],
|
||||
'project_code': ['project_code__code'],
|
||||
}
|
||||
|
||||
ordering_fields = [
|
||||
'creation_date',
|
||||
'created_by',
|
||||
'reference',
|
||||
'line_items',
|
||||
'status',
|
||||
'start_date',
|
||||
'target_date',
|
||||
'complete_date',
|
||||
'project_code',
|
||||
]
|
||||
|
||||
search_fields = ['reference', 'description', 'project_code__code']
|
||||
|
||||
ordering = '-reference'
|
||||
|
||||
|
||||
class TransferOrderDetail(
|
||||
TransferOrderMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI
|
||||
):
|
||||
"""API endpoint for detail view of a single TransferOrder object."""
|
||||
|
||||
# output_options = TransferOrderOutputOptions
|
||||
|
||||
|
||||
class TransferOrderContextMixin:
|
||||
"""Simple mixin class to add a TransferOrder to the serializer context."""
|
||||
|
||||
queryset = models.TransferOrder.objects.all()
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""Add the TransferOrder object to the serializer context."""
|
||||
context = super().get_serializer_context()
|
||||
|
||||
# Pass the Transfer instance through to the serializer for validation
|
||||
try:
|
||||
context['order'] = models.TransferOrder.objects.get(
|
||||
pk=self.kwargs.get('pk', None)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
context['request'] = self.request
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class TransferOrderCancel(TransferOrderContextMixin, CreateAPI):
|
||||
"""API endpoint to cancel a TransferOrder."""
|
||||
|
||||
serializer_class = serializers.TransferOrderCancelSerializer
|
||||
|
||||
|
||||
class TransferOrderHold(TransferOrderContextMixin, CreateAPI):
|
||||
"""API endpoint to hold a TransferOrder."""
|
||||
|
||||
serializer_class = serializers.TransferOrderHoldSerializer
|
||||
|
||||
|
||||
class TransferOrderComplete(TransferOrderContextMixin, CreateAPI):
|
||||
"""API endpoint to complete a TransferOrder."""
|
||||
|
||||
serializer_class = serializers.TransferOrderCompleteSerializer
|
||||
|
||||
|
||||
class TransferOrderIssue(TransferOrderContextMixin, CreateAPI):
|
||||
"""API endpoint to issue a Transfer Order."""
|
||||
|
||||
serializer_class = serializers.TransferOrderIssueSerializer
|
||||
|
||||
|
||||
class TransferOrderAllocateSerials(TransferOrderContextMixin, CreateAPI):
|
||||
"""API endpoint to allocation stock items against a TransferOrder, by specifying serial numbers."""
|
||||
|
||||
queryset = models.TransferOrder.objects.none()
|
||||
serializer_class = serializers.TransferOrderSerialAllocationSerializer
|
||||
|
||||
|
||||
class TransferOrderAllocate(TransferOrderContextMixin, CreateAPI):
|
||||
"""API endpoint to allocate stock items against a TransferOrder.
|
||||
|
||||
- The TransferOrder is specified in the URL
|
||||
- See the TransferOrderAllocationSerializer class
|
||||
"""
|
||||
|
||||
queryset = models.TransferOrder.objects.none()
|
||||
serializer_class = serializers.TransferOrderLineItemAllocationSerializer
|
||||
|
||||
|
||||
class TransferOrderAllocationFilter(FilterSet):
|
||||
"""Custom filterset for the TransferOrderAllocationList endpoint."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = models.TransferOrderAllocation
|
||||
fields = ['line', 'item']
|
||||
|
||||
order = rest_filters.ModelChoiceFilter(
|
||||
queryset=models.TransferOrder.objects.all(),
|
||||
field_name='line__order',
|
||||
label=_('Order'),
|
||||
)
|
||||
|
||||
include_variants = rest_filters.BooleanFilter(
|
||||
label=_('Include Variants'), method='filter_include_variants'
|
||||
)
|
||||
|
||||
def filter_include_variants(self, queryset, name, value):
|
||||
"""Filter by whether or not to include variants of the selected part.
|
||||
|
||||
Note:
|
||||
- This filter does nothing by itself, and requires the 'part' filter to be set.
|
||||
- Refer to the 'filter_part' method for more information.
|
||||
"""
|
||||
return queryset
|
||||
|
||||
part = rest_filters.ModelChoiceFilter(
|
||||
queryset=Part.objects.all(), method='filter_part', label=_('Part')
|
||||
)
|
||||
|
||||
@extend_schema_field(rest_framework.serializers.IntegerField(help_text=_('Part')))
|
||||
def filter_part(self, queryset, name, part):
|
||||
"""Filter by the 'part' attribute.
|
||||
|
||||
Note:
|
||||
- If "include_variants" is True, include all variants of the selected part
|
||||
- Otherwise, just filter by the selected part
|
||||
"""
|
||||
include_variants = str2bool(self.data.get('include_variants', False))
|
||||
|
||||
if include_variants:
|
||||
parts = part.get_descendants(include_self=True)
|
||||
return queryset.filter(item__part__in=parts)
|
||||
else:
|
||||
return queryset.filter(item__part=part)
|
||||
|
||||
outstanding = rest_filters.BooleanFilter(
|
||||
label=_('Outstanding'), method='filter_outstanding'
|
||||
)
|
||||
|
||||
def filter_outstanding(self, queryset, name, value):
|
||||
"""Filter by "outstanding" status (boolean)."""
|
||||
if str2bool(value):
|
||||
return queryset.filter(
|
||||
line__order__status__in=TransferOrderStatusGroups.OPEN
|
||||
# TODO: is there an additional filter here if we aren't using a "shipment"
|
||||
# shipment__shipment_date=None,
|
||||
)
|
||||
return queryset.exclude(
|
||||
# TODO: is there an additional filter here if we aren't using a "shipment"
|
||||
# shipment__shipment_date=None,
|
||||
line__order__status__in=TransferOrderStatusGroups.OPEN
|
||||
)
|
||||
|
||||
location = rest_filters.ModelChoiceFilter(
|
||||
queryset=stock_models.StockLocation.objects.all(),
|
||||
label=_('Location'),
|
||||
method='filter_location',
|
||||
)
|
||||
|
||||
@extend_schema_field(
|
||||
rest_framework.serializers.IntegerField(help_text=_('Location'))
|
||||
)
|
||||
def filter_location(self, queryset, name, location):
|
||||
"""Filter by the location of the allocated StockItem."""
|
||||
locations = location.get_descendants(include_self=True)
|
||||
return queryset.filter(item__location__in=locations)
|
||||
|
||||
|
||||
class TransferOrderAllocationMixin:
|
||||
"""Mixin class for TransferOrderAllocation endpoints."""
|
||||
|
||||
queryset = models.TransferOrderAllocation.objects.all()
|
||||
serializer_class = serializers.TransferOrderAllocationSerializer
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
"""Annotate the queryset for this endpoint."""
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
queryset = queryset.prefetch_related(
|
||||
'item',
|
||||
'item__sales_order',
|
||||
'item__part',
|
||||
'line__part',
|
||||
'item__location',
|
||||
'line__order',
|
||||
'line__order__responsible',
|
||||
'line__order__project_code',
|
||||
'line__order__project_code__responsible',
|
||||
).select_related('line__part__pricing_data', 'item__part__pricing_data')
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class TransferOrderAllocationOutputOptions(OutputConfiguration):
|
||||
"""Output options for the TransferOrderAllocation endpoint."""
|
||||
|
||||
OPTIONS = [
|
||||
InvenTreeOutputOption('part_detail'),
|
||||
InvenTreeOutputOption('item_detail'),
|
||||
InvenTreeOutputOption('order_detail'),
|
||||
InvenTreeOutputOption('location_detail'),
|
||||
]
|
||||
|
||||
|
||||
class TransferOrderAllocationList(
|
||||
TransferOrderAllocationMixin, BulkUpdateMixin, OutputOptionsMixin, ListAPI
|
||||
):
|
||||
"""API endpoint for listing TransferOrderAllocation objects."""
|
||||
|
||||
filterset_class = TransferOrderAllocationFilter
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
output_options = TransferOrderAllocationOutputOptions
|
||||
|
||||
ordering_fields = [
|
||||
'quantity',
|
||||
'part',
|
||||
'serial',
|
||||
'IPN',
|
||||
'batch',
|
||||
'location',
|
||||
'order',
|
||||
]
|
||||
|
||||
ordering_field_aliases = {
|
||||
'IPN': 'item__part__IPN',
|
||||
'part': 'item__part__name',
|
||||
'serial': ['item__serial_int', 'item__serial'],
|
||||
'batch': 'item__batch',
|
||||
'location': 'item__location__name',
|
||||
'order': 'line__order__reference',
|
||||
}
|
||||
|
||||
search_fields = {
|
||||
'item__part__name',
|
||||
'item__part__IPN',
|
||||
'item__serial',
|
||||
'item__batch',
|
||||
}
|
||||
|
||||
|
||||
class TransferOrderAllocationDetail(
|
||||
TransferOrderAllocationMixin, RetrieveUpdateDestroyAPI
|
||||
):
|
||||
"""API endpoint for detail view of a TransferOrderAllocation object."""
|
||||
|
||||
|
||||
class TransferOrderLineItemFilter(LineItemFilter):
|
||||
"""Custom filters for TransferOrderLineItemList endpoint."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = models.TransferOrderLineItem
|
||||
fields = []
|
||||
|
||||
order = rest_filters.ModelChoiceFilter(
|
||||
queryset=models.TransferOrder.objects.all(),
|
||||
field_name='order',
|
||||
label=_('Order'),
|
||||
)
|
||||
|
||||
def filter_include_variants(self, queryset, name, value):
|
||||
"""Filter by whether or not to include variants of the selected part.
|
||||
|
||||
Note:
|
||||
- This filter does nothing by itself, and requires the 'part' filter to be set.
|
||||
- Refer to the 'filter_part' method for more information.
|
||||
"""
|
||||
return queryset
|
||||
|
||||
part = rest_filters.ModelChoiceFilter(
|
||||
queryset=Part.objects.all(),
|
||||
field_name='part',
|
||||
label=_('Part'),
|
||||
method='filter_part',
|
||||
)
|
||||
|
||||
@extend_schema_field(OpenApiTypes.INT)
|
||||
def filter_part(self, queryset, name, part):
|
||||
"""Filter TransferOrderLineItem by selected 'part'.
|
||||
|
||||
Note:
|
||||
- If 'include_variants' is set to True, then all variants of the selected part will be included.
|
||||
- Otherwise, just filter by the selected part.
|
||||
"""
|
||||
include_variants = str2bool(self.data.get('include_variants', False))
|
||||
|
||||
# Construct a queryset of parts to filter by
|
||||
if include_variants:
|
||||
parts = part.get_descendants(include_self=True)
|
||||
else:
|
||||
parts = Part.objects.filter(pk=part.pk)
|
||||
|
||||
return queryset.filter(part__in=parts)
|
||||
|
||||
allocated = rest_filters.BooleanFilter(
|
||||
label=_('Allocated'), method='filter_allocated'
|
||||
)
|
||||
|
||||
def filter_allocated(self, queryset, name, value):
|
||||
"""Filter by lines which are 'allocated'.
|
||||
|
||||
A line is 'allocated' when allocated >= quantity
|
||||
"""
|
||||
q = Q(allocated__gte=F('quantity'))
|
||||
|
||||
if str2bool(value):
|
||||
return queryset.filter(q)
|
||||
return queryset.exclude(q)
|
||||
|
||||
completed = rest_filters.BooleanFilter(
|
||||
label=_('Completed'), method='filter_completed'
|
||||
)
|
||||
|
||||
def filter_completed(self, queryset, name, value):
|
||||
"""Filter by lines which are "completed".
|
||||
|
||||
A line is 'completed' when transferred >= quantity
|
||||
"""
|
||||
q = Q(transferred__gte=F('quantity'))
|
||||
|
||||
if str2bool(value):
|
||||
return queryset.filter(q)
|
||||
return queryset.exclude(q)
|
||||
|
||||
order_complete = rest_filters.BooleanFilter(
|
||||
label=_('Order Complete'), method='filter_order_complete'
|
||||
)
|
||||
|
||||
def filter_order_complete(self, queryset, name, value):
|
||||
"""Filter by whether the order is 'complete' or not."""
|
||||
if str2bool(value):
|
||||
return queryset.filter(order__status__in=TransferOrderStatusGroups.COMPLETE)
|
||||
|
||||
return queryset.exclude(order__status__in=TransferOrderStatusGroups.COMPLETE)
|
||||
|
||||
order_outstanding = rest_filters.BooleanFilter(
|
||||
label=_('Order Outstanding'), method='filter_order_outstanding'
|
||||
)
|
||||
|
||||
def filter_order_outstanding(self, queryset, name, value):
|
||||
"""Filter by whether the order is 'outstanding' or not."""
|
||||
if str2bool(value):
|
||||
return queryset.filter(order__status__in=TransferOrderStatusGroups.OPEN)
|
||||
|
||||
return queryset.exclude(order__status__in=TransferOrderStatusGroups.OPEN)
|
||||
|
||||
|
||||
class TransferOrderLineItemMixin(SerializerContextMixin):
|
||||
"""Mixin class for TransferOrderLineItem endpoints."""
|
||||
|
||||
queryset = models.TransferOrderLineItem.objects.all()
|
||||
serializer_class = serializers.TransferOrderLineItemSerializer
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
"""Return annotated queryset for this endpoint."""
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
queryset = queryset.prefetch_related(
|
||||
'part',
|
||||
'allocations',
|
||||
# 'allocations__transfer',
|
||||
'allocations__item__part',
|
||||
'allocations__item__location',
|
||||
'order',
|
||||
)
|
||||
|
||||
queryset = serializers.TransferOrderLineItemSerializer.annotate_queryset(
|
||||
queryset
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class TransferOrderLineItemOutputOptions(OutputConfiguration):
|
||||
"""Output options for the TransferOrderAllocation endpoint."""
|
||||
|
||||
OPTIONS = [
|
||||
InvenTreeOutputOption('part_detail'),
|
||||
InvenTreeOutputOption('order_detail'),
|
||||
]
|
||||
|
||||
|
||||
class TransferOrderLineItemList(
|
||||
TransferOrderLineItemMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateAPI
|
||||
):
|
||||
"""API endpoint for accessing a list of TransferOrderLineItem objects."""
|
||||
|
||||
filterset_class = TransferOrderLineItemFilter
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
output_options = TransferOrderLineItemOutputOptions
|
||||
|
||||
ordering_fields = [
|
||||
'order',
|
||||
'part',
|
||||
'part__name',
|
||||
'quantity',
|
||||
'allocated',
|
||||
'transferred',
|
||||
'reference',
|
||||
'target_date',
|
||||
]
|
||||
|
||||
ordering_field_aliases = {'part': 'part__name', 'order': 'order__reference'}
|
||||
|
||||
search_fields = ['part__name', 'quantity', 'reference']
|
||||
|
||||
|
||||
class TransferOrderLineItemDetail(
|
||||
TransferOrderLineItemMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI
|
||||
):
|
||||
"""API endpoint for detail view of a TransferOrderLineItem object."""
|
||||
|
||||
output_options = TransferOrderLineItemOutputOptions
|
||||
|
||||
|
||||
class OrderCalendarExport(ICalFeed):
|
||||
"""Calendar export for Purchase/Sales Orders.
|
||||
|
||||
|
|
@ -1844,6 +2361,8 @@ class OrderCalendarExport(ICalFeed):
|
|||
ordertype_title = _('Sales Order')
|
||||
elif obj['ordertype'] == 'return-order':
|
||||
ordertype_title = _('Return Order')
|
||||
elif obj['ordertype'] == 'transfer-order':
|
||||
ordertype_title = _('Transfer Order')
|
||||
else:
|
||||
ordertype_title = _('Unknown')
|
||||
|
||||
|
|
@ -1889,6 +2408,15 @@ class OrderCalendarExport(ICalFeed):
|
|||
).filter(status__lt=ReturnOrderStatus.COMPLETE.value)
|
||||
else:
|
||||
outlist = models.ReturnOrder.objects.filter(target_date__isnull=False)
|
||||
elif obj['ordertype'] == 'transfer-order':
|
||||
if obj['include_completed'] is False:
|
||||
# Do not include completed orders from list in this case
|
||||
# Complete status = 30
|
||||
outlist = models.TransferOrder.objects.filter(
|
||||
target_date__isnull=False
|
||||
).filter(status__lt=TransferOrderStatus.COMPLETE.value)
|
||||
else:
|
||||
outlist = models.TransferOrder.objects.filter(target_date__isnull=False)
|
||||
else:
|
||||
outlist = []
|
||||
|
||||
|
|
@ -1900,7 +2428,12 @@ class OrderCalendarExport(ICalFeed):
|
|||
|
||||
def item_description(self, item):
|
||||
"""Set the event description."""
|
||||
return f'Company: {item.company.name}\nStatus: {item.get_status_display()}\nDescription: {item.description}'
|
||||
if hasattr(item, 'company') and item.company:
|
||||
return f'Company: {item.company.name}\nStatus: {item.get_status_display()}\nDescription: {item.description}'
|
||||
else:
|
||||
return (
|
||||
f'Status: {item.get_status_display()}\nDescription: {item.description}'
|
||||
)
|
||||
|
||||
def item_start_datetime(self, item):
|
||||
"""Set event start to target date. Goal is all-day event."""
|
||||
|
|
@ -2216,9 +2749,97 @@ order_api_urls = [
|
|||
),
|
||||
]),
|
||||
),
|
||||
# API endpoints for transfer orders
|
||||
path(
|
||||
'transfer-order/',
|
||||
include([
|
||||
# Transfer Order detail endpoints
|
||||
path(
|
||||
'<int:pk>/',
|
||||
include([
|
||||
path(
|
||||
'allocate/',
|
||||
TransferOrderAllocate.as_view(),
|
||||
name='api-transfer-order-allocate',
|
||||
),
|
||||
path(
|
||||
'allocate-serials/',
|
||||
TransferOrderAllocateSerials.as_view(),
|
||||
name='api-transfer-order-allocate-serials',
|
||||
),
|
||||
path(
|
||||
'cancel/',
|
||||
TransferOrderCancel.as_view(),
|
||||
name='api-transfer-order-cancel',
|
||||
),
|
||||
path(
|
||||
'hold/',
|
||||
TransferOrderHold.as_view(),
|
||||
name='api-transfer-order-hold',
|
||||
),
|
||||
path(
|
||||
'complete/',
|
||||
TransferOrderComplete.as_view(),
|
||||
name='api-transfer-order-complete',
|
||||
),
|
||||
path(
|
||||
'issue/',
|
||||
TransferOrderIssue.as_view(),
|
||||
name='api-transfer-order-issue',
|
||||
),
|
||||
meta_path(models.TransferOrder),
|
||||
path(
|
||||
'',
|
||||
TransferOrderDetail.as_view(),
|
||||
name='api-transfer-order-detail',
|
||||
),
|
||||
]),
|
||||
),
|
||||
# Transfer Order list
|
||||
path('', TransferOrderList.as_view(), name='api-transfer-order-list'),
|
||||
]),
|
||||
),
|
||||
# API endpoints for transfer order line items
|
||||
path(
|
||||
'transfer-order-line/',
|
||||
include([
|
||||
path(
|
||||
'<int:pk>/',
|
||||
include([
|
||||
meta_path(models.TransferOrderLineItem),
|
||||
path(
|
||||
'',
|
||||
TransferOrderLineItemDetail.as_view(),
|
||||
name='api-transfer-order-line-detail',
|
||||
),
|
||||
]),
|
||||
),
|
||||
path(
|
||||
'',
|
||||
TransferOrderLineItemList.as_view(),
|
||||
name='api-transfer-order-line-list',
|
||||
),
|
||||
]),
|
||||
),
|
||||
# API endpoints for sales order allocations
|
||||
path(
|
||||
'transfer-order-allocation/',
|
||||
include([
|
||||
path(
|
||||
'<int:pk>/',
|
||||
TransferOrderAllocationDetail.as_view(),
|
||||
name='api-transfer-order-allocation-detail',
|
||||
),
|
||||
path(
|
||||
'',
|
||||
TransferOrderAllocationList.as_view(),
|
||||
name='api-transfer-order-allocation-list',
|
||||
),
|
||||
]),
|
||||
),
|
||||
# API endpoint for subscribing to ICS calendar of purchase/sales/return orders
|
||||
re_path(
|
||||
r'^calendar/(?P<ordertype>purchase-order|sales-order|return-order)/calendar.ics',
|
||||
r'^calendar/(?P<ordertype>purchase-order|sales-order|return-order|transfer-order)/calendar.ics',
|
||||
OrderCalendarExport(),
|
||||
name='api-po-so-calendar',
|
||||
),
|
||||
|
|
|
|||
|
|
@ -37,3 +37,12 @@ class ReturnOrderEvents(BaseEventEnum):
|
|||
COMPLETED = 'returnorder.completed'
|
||||
CANCELLED = 'returnorder.cancelled'
|
||||
HOLD = 'returnorder.hold'
|
||||
|
||||
|
||||
class TransferOrderEvents(BaseEventEnum):
|
||||
"""Event enumeration for the PurchaseOrder models."""
|
||||
|
||||
ISSUED = 'transferorder.placed'
|
||||
COMPLETED = 'transferorder.completed'
|
||||
CANCELLED = 'transferorder.cancelled'
|
||||
HOLD = 'transferorder.hold'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
- model: order.transferorder
|
||||
pk: 1
|
||||
fields:
|
||||
reference: 'TO-123'
|
||||
description: "One transfer order, please"
|
||||
status: 10 # Pending
|
||||
|
||||
- model: order.transferorder
|
||||
pk: 2
|
||||
fields:
|
||||
reference: 'TO-124'
|
||||
description: "One transfer order, please"
|
||||
status: 40 # Cancelled
|
||||
|
||||
- model: order.transferorder
|
||||
pk: 3
|
||||
fields:
|
||||
reference: 'TO-125'
|
||||
description: "One transfer order, please"
|
||||
status: 25 # On Hold
|
||||
|
||||
- model: order.transferorder
|
||||
pk: 4
|
||||
fields:
|
||||
reference: 'TO-126'
|
||||
description: "One transfer order, please"
|
||||
status: 20 # Issued
|
||||
|
||||
- model: order.transferorder
|
||||
pk: 5
|
||||
fields:
|
||||
reference: 'TO-127'
|
||||
description: "One transfer order, please"
|
||||
status: 30 # Complete
|
||||
|
||||
|
||||
# Line items for transfer orders
|
||||
- model: order.transferorderlineitem
|
||||
pk: 1
|
||||
fields:
|
||||
order: 5 # the completed order
|
||||
part: 10001 # blue chair
|
||||
quantity: 1
|
||||
|
||||
- model: order.transferorderlineitem
|
||||
pk: 2
|
||||
fields:
|
||||
order: 4 # the issued order
|
||||
part: 10001 # blue chair
|
||||
quantity: 1
|
||||
transferred: 1
|
||||
|
||||
# Allocations for transfer orders
|
||||
# an 'allocated' allocation
|
||||
- model: order.transferorderallocation
|
||||
pk: 1
|
||||
fields:
|
||||
line: 1 # the line item on the completed order
|
||||
item: 1 # stock item
|
||||
quantity: 1
|
||||
|
||||
# a 'complete' allocation
|
||||
- model: order.transferorderallocation
|
||||
pk: 2
|
||||
fields:
|
||||
line: 2 # the line item on the issued order
|
||||
item: 500 # stock item for the blue chair
|
||||
quantity: 1
|
||||
|
|
@ -0,0 +1,482 @@
|
|||
# Generated by Django 5.2.11 on 2026-02-27 22:00
|
||||
|
||||
import InvenTree.fields
|
||||
import InvenTree.models
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import generic.states.fields
|
||||
import generic.states.states
|
||||
import generic.states.transition
|
||||
import generic.states.validators
|
||||
import order.status_codes
|
||||
import order.validators
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("common", "0041_auto_20251203_1244"),
|
||||
("company", "0077_delete_manufacturerpartparameter"),
|
||||
("order", "0117_purchaseorderextraline_line_int_and_more"),
|
||||
("part", "0146_auto_20251203_1241"),
|
||||
("stock", "0116_alter_stockitem_link"),
|
||||
("users", "0005_owner_model"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="TransferOrder",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"metadata",
|
||||
models.JSONField(
|
||||
blank=True,
|
||||
help_text="JSON metadata field, for use by external plugins",
|
||||
null=True,
|
||||
verbose_name="Plugin Metadata",
|
||||
),
|
||||
),
|
||||
("reference_int", models.BigIntegerField(default=0)),
|
||||
(
|
||||
"notes",
|
||||
InvenTree.fields.InvenTreeNotesField(
|
||||
blank=True,
|
||||
help_text="Markdown notes (optional)",
|
||||
max_length=50000,
|
||||
null=True,
|
||||
verbose_name="Notes",
|
||||
),
|
||||
),
|
||||
(
|
||||
"barcode_data",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Third party barcode data",
|
||||
max_length=500,
|
||||
verbose_name="Barcode Data",
|
||||
),
|
||||
),
|
||||
(
|
||||
"barcode_hash",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Unique hash of barcode data",
|
||||
max_length=128,
|
||||
verbose_name="Barcode Hash",
|
||||
),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Order description (optional)",
|
||||
max_length=250,
|
||||
verbose_name="Description",
|
||||
),
|
||||
),
|
||||
(
|
||||
"link",
|
||||
InvenTree.fields.InvenTreeURLField(
|
||||
blank=True,
|
||||
help_text="Link to external page",
|
||||
max_length=2000,
|
||||
verbose_name="Link",
|
||||
),
|
||||
),
|
||||
(
|
||||
"start_date",
|
||||
models.DateField(
|
||||
blank=True,
|
||||
help_text="Scheduled start date for this order",
|
||||
null=True,
|
||||
verbose_name="Start date",
|
||||
),
|
||||
),
|
||||
(
|
||||
"target_date",
|
||||
models.DateField(
|
||||
blank=True,
|
||||
help_text="Expected date for order delivery. Order will be overdue after this date.",
|
||||
null=True,
|
||||
verbose_name="Target Date",
|
||||
),
|
||||
),
|
||||
(
|
||||
"creation_date",
|
||||
models.DateField(
|
||||
blank=True, null=True, verbose_name="Creation Date"
|
||||
),
|
||||
),
|
||||
(
|
||||
"issue_date",
|
||||
models.DateField(
|
||||
blank=True,
|
||||
help_text="Date order was issued",
|
||||
null=True,
|
||||
verbose_name="Issue Date",
|
||||
),
|
||||
),
|
||||
(
|
||||
"reference",
|
||||
models.CharField(
|
||||
default=order.validators.generate_next_transfer_order_reference,
|
||||
help_text="Transfer Order Reference",
|
||||
max_length=64,
|
||||
unique=True,
|
||||
validators=[order.validators.validate_transfer_order_reference],
|
||||
verbose_name="Reference",
|
||||
),
|
||||
),
|
||||
(
|
||||
"consume",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text='Rather than transfer the stock to the destination, "consume" it, by removing transferred quantity from the allocated stock item',
|
||||
verbose_name="Consume Stock",
|
||||
),
|
||||
),
|
||||
(
|
||||
"complete_date",
|
||||
models.DateField(
|
||||
blank=True,
|
||||
help_text="Date order was completed",
|
||||
null=True,
|
||||
verbose_name="Completion Date",
|
||||
),
|
||||
),
|
||||
(
|
||||
"status_custom_key",
|
||||
generic.states.fields.ExtraInvenTreeCustomStatusModelField(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="Additional status information for this item",
|
||||
null=True,
|
||||
validators=[
|
||||
generic.states.validators.CustomStatusCodeValidator(
|
||||
status_class=order.status_codes.TransferOrderStatus
|
||||
)
|
||||
],
|
||||
verbose_name="Custom status key",
|
||||
),
|
||||
),
|
||||
(
|
||||
"status",
|
||||
generic.states.fields.InvenTreeCustomStatusModelField(
|
||||
choices=[
|
||||
(10, "Pending"),
|
||||
(20, "Issued"),
|
||||
(25, "On Hold"),
|
||||
(30, "Complete"),
|
||||
(40, "Cancelled"),
|
||||
],
|
||||
default=10,
|
||||
help_text="Transfer order status",
|
||||
validators=[
|
||||
generic.states.validators.CustomStatusCodeValidator(
|
||||
status_class=order.status_codes.TransferOrderStatus
|
||||
)
|
||||
],
|
||||
verbose_name="Status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"address",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Company address for this order",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="+",
|
||||
to="company.address",
|
||||
verbose_name="Address",
|
||||
),
|
||||
),
|
||||
(
|
||||
"contact",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Point of contact for this order",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="+",
|
||||
to="company.contact",
|
||||
verbose_name="Contact",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"destination",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Destination for transferred items",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="incoming_transfers",
|
||||
to="stock.stocklocation",
|
||||
verbose_name="Destination Location",
|
||||
),
|
||||
),
|
||||
(
|
||||
"project_code",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Select project code for this order",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="common.projectcode",
|
||||
verbose_name="Project Code",
|
||||
),
|
||||
),
|
||||
(
|
||||
"responsible",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="User or group responsible for this order",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="+",
|
||||
to="users.owner",
|
||||
verbose_name="Responsible",
|
||||
),
|
||||
),
|
||||
(
|
||||
"take_from",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Source for transferred items",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="sourcing_transfers",
|
||||
to="stock.stocklocation",
|
||||
verbose_name="Source Location",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="Timestamp of last update",
|
||||
null=True,
|
||||
verbose_name="Updated At",
|
||||
),
|
||||
)
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Transfer Order",
|
||||
},
|
||||
bases=(
|
||||
generic.states.states.StatusCodeMixin,
|
||||
generic.states.transition.StateTransitionMixin,
|
||||
InvenTree.models.InvenTreeAttachmentMixin,
|
||||
InvenTree.models.InvenTreePermissionCheckMixin,
|
||||
InvenTree.models.ContentTypeMixin,
|
||||
InvenTree.models.PluginValidationMixin,
|
||||
models.Model,
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="TransferOrderLineItem",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"metadata",
|
||||
models.JSONField(
|
||||
blank=True,
|
||||
help_text="JSON metadata field, for use by external plugins",
|
||||
null=True,
|
||||
verbose_name="Plugin Metadata",
|
||||
),
|
||||
),
|
||||
(
|
||||
"quantity",
|
||||
InvenTree.fields.RoundingDecimalField(
|
||||
decimal_places=5,
|
||||
default=1,
|
||||
help_text="Item quantity",
|
||||
max_digits=15,
|
||||
validators=[django.core.validators.MinValueValidator(0)],
|
||||
verbose_name="Quantity",
|
||||
),
|
||||
),
|
||||
(
|
||||
"reference",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Line item reference",
|
||||
max_length=100,
|
||||
verbose_name="Reference",
|
||||
),
|
||||
),
|
||||
(
|
||||
"notes",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Line item notes",
|
||||
max_length=500,
|
||||
verbose_name="Notes",
|
||||
),
|
||||
),
|
||||
(
|
||||
"link",
|
||||
InvenTree.fields.InvenTreeURLField(
|
||||
blank=True,
|
||||
help_text="Link to external page",
|
||||
max_length=2000,
|
||||
verbose_name="Link",
|
||||
),
|
||||
),
|
||||
(
|
||||
"target_date",
|
||||
models.DateField(
|
||||
blank=True,
|
||||
help_text="Target date for this line item (leave blank to use the target date from the order)",
|
||||
null=True,
|
||||
verbose_name="Target Date",
|
||||
),
|
||||
),
|
||||
(
|
||||
"order",
|
||||
models.ForeignKey(
|
||||
help_text="Transfer Order",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="lines",
|
||||
to="order.transferorder",
|
||||
verbose_name="Order",
|
||||
),
|
||||
),
|
||||
(
|
||||
"part",
|
||||
models.ForeignKey(
|
||||
help_text="Part",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="transfer_order_line_items",
|
||||
to="part.part",
|
||||
verbose_name="Part",
|
||||
),
|
||||
),
|
||||
(
|
||||
"project_code",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Select project code for this order",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="common.projectcode",
|
||||
verbose_name="Project Code",
|
||||
),
|
||||
),
|
||||
(
|
||||
"transferred",
|
||||
InvenTree.fields.RoundingDecimalField(
|
||||
decimal_places=5,
|
||||
default=0,
|
||||
help_text="transferred quantity",
|
||||
max_digits=15,
|
||||
validators=[django.core.validators.MinValueValidator(0)],
|
||||
verbose_name="transferred",
|
||||
),
|
||||
),
|
||||
(
|
||||
"line",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="Line number for this item (optional)",
|
||||
max_length=20,
|
||||
verbose_name="Line Number",
|
||||
),
|
||||
)
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Transfer Order Line Item",
|
||||
},
|
||||
bases=(
|
||||
InvenTree.models.ContentTypeMixin,
|
||||
InvenTree.models.PluginValidationMixin,
|
||||
models.Model,
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="TransferOrderAllocation",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"quantity",
|
||||
InvenTree.fields.RoundingDecimalField(
|
||||
decimal_places=5,
|
||||
default=1,
|
||||
help_text="Enter stock allocation quantity",
|
||||
max_digits=15,
|
||||
validators=[django.core.validators.MinValueValidator(0)],
|
||||
verbose_name="Quantity",
|
||||
),
|
||||
),
|
||||
(
|
||||
"item",
|
||||
models.ForeignKey(
|
||||
help_text="Select stock item to allocate",
|
||||
limit_choices_to={
|
||||
"belongs_to": None,
|
||||
"part__virtual": False,
|
||||
"sales_order": None,
|
||||
},
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="transfer_order_allocations",
|
||||
to="stock.stockitem",
|
||||
verbose_name="Item",
|
||||
),
|
||||
),
|
||||
(
|
||||
"line",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="allocations",
|
||||
to="order.transferorderlineitem",
|
||||
verbose_name="Line",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Transfer Order Allocation",
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.2.13 on 2026-05-11 21:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("order", "0118_transferorder"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="transferorderlineitem",
|
||||
name="line_int",
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
]
|
||||
|
|
@ -45,7 +45,12 @@ from InvenTree.fields import (
|
|||
)
|
||||
from InvenTree.helpers import decimal2string, pui_url
|
||||
from InvenTree.helpers_model import notify_responsible
|
||||
from order.events import PurchaseOrderEvents, ReturnOrderEvents, SalesOrderEvents
|
||||
from order.events import (
|
||||
PurchaseOrderEvents,
|
||||
ReturnOrderEvents,
|
||||
SalesOrderEvents,
|
||||
TransferOrderEvents,
|
||||
)
|
||||
from order.status_codes import (
|
||||
PurchaseOrderStatus,
|
||||
PurchaseOrderStatusGroups,
|
||||
|
|
@ -54,6 +59,8 @@ from order.status_codes import (
|
|||
ReturnOrderStatusGroups,
|
||||
SalesOrderStatus,
|
||||
SalesOrderStatusGroups,
|
||||
TransferOrderStatus,
|
||||
TransferOrderStatusGroups,
|
||||
)
|
||||
from part import models as PartModels
|
||||
from plugin.events import trigger_event
|
||||
|
|
@ -265,6 +272,27 @@ class ReturnOrderReportContext(report.mixins.BaseReportContext, TypedDict):
|
|||
customer: Optional[Company]
|
||||
|
||||
|
||||
class TransferOrderReportContext(report.mixins.BaseReportContext, TypedDict):
|
||||
"""Context for the transfer order model.
|
||||
|
||||
Attributes:
|
||||
description: The description field of the TransferOrder
|
||||
reference: The reference field of the TransferOrder
|
||||
title: The title (string representation) of the TransferOrder
|
||||
lines: Query set of all line items associated with the TransferOrder
|
||||
order: The TransferOrder instance itself
|
||||
"""
|
||||
|
||||
description: str
|
||||
reference: str
|
||||
title: str
|
||||
lines: report.mixins.QuerySet['TransferOrderLineItem']
|
||||
order: 'TransferOrder'
|
||||
take_from: 'stock.models.StockLocation'
|
||||
destination: 'stock.models.StockLocation'
|
||||
consume: bool
|
||||
|
||||
|
||||
class Order(
|
||||
StatusCodeMixin,
|
||||
StateTransitionMixin,
|
||||
|
|
@ -375,11 +403,16 @@ class Order(
|
|||
})
|
||||
|
||||
# Check that the referenced 'contact' matches the correct 'company'
|
||||
if self.company and self.contact:
|
||||
if self.contact.company != self.company:
|
||||
raise ValidationError({
|
||||
'contact': _('Contact does not match selected company')
|
||||
})
|
||||
if (
|
||||
hasattr(self, 'company')
|
||||
and hasattr(self, 'contact')
|
||||
and self.company
|
||||
and self.contact
|
||||
and (self.contact.company != self.company)
|
||||
):
|
||||
raise ValidationError({
|
||||
'contact': _('Contact does not match selected company')
|
||||
})
|
||||
|
||||
# Target date should be *after* the start date
|
||||
if self.start_date and self.target_date and self.start_date > self.target_date:
|
||||
|
|
@ -389,11 +422,15 @@ class Order(
|
|||
})
|
||||
|
||||
# Check that the referenced 'address' matches the correct 'company'
|
||||
if self.company and self.address:
|
||||
if self.address.company != self.company:
|
||||
raise ValidationError({
|
||||
'address': _('Address does not match selected company')
|
||||
})
|
||||
if (
|
||||
hasattr(self, 'company')
|
||||
and self.company
|
||||
and self.address
|
||||
and (self.address.company != self.company)
|
||||
):
|
||||
raise ValidationError({
|
||||
'address': _('Address does not match selected company')
|
||||
})
|
||||
|
||||
def clean_line_item(self, line):
|
||||
"""Clean a line item for this order.
|
||||
|
|
@ -409,7 +446,9 @@ class Order(
|
|||
"""Generate context data for the reporting interface."""
|
||||
return {
|
||||
'description': self.description,
|
||||
'extra_lines': self.extra_lines,
|
||||
'extra_lines': getattr(
|
||||
self, 'extra_lines', None
|
||||
), # Transfer Order doesn't have extra lines
|
||||
'lines': self.lines,
|
||||
'order': self,
|
||||
'reference': self.reference,
|
||||
|
|
@ -3157,6 +3196,628 @@ class ReturnOrderExtraLine(OrderExtraLine):
|
|||
)
|
||||
|
||||
|
||||
class TransferOrder(Order):
|
||||
"""A Transfer Order represents a request to transfer stock from one location to another. It provides a place to queue and review changes before execution.
|
||||
|
||||
Attributes:
|
||||
take_from: The stock location to source items from (or null to )
|
||||
destination: The stock location to move items to
|
||||
consume: Rather than move the stock, "consume" it. Helpful if you want to queue up removing stock from inventory
|
||||
"""
|
||||
|
||||
# Global setting for specifying reference pattern
|
||||
REFERENCE_PATTERN_SETTING = 'TRANSFERORDER_REFERENCE_PATTERN'
|
||||
REQUIRE_RESPONSIBLE_SETTING = 'TRANSFERORDER_REQUIRE_RESPONSIBLE'
|
||||
STATUS_CLASS = TransferOrderStatus
|
||||
# UNLOCK_SETTING = 'TRANSFERORDER_EDIT_COMPLETED_ORDERS'
|
||||
|
||||
class Meta:
|
||||
"""Model meta options."""
|
||||
|
||||
verbose_name = _('Transfer Order')
|
||||
|
||||
def report_context(self) -> TransferOrderReportContext:
|
||||
"""Return report context data for this TransferOrder."""
|
||||
return {
|
||||
**super().report_context(),
|
||||
'take_from': self.take_from,
|
||||
'destination': self.destination,
|
||||
'consume': self.consume,
|
||||
}
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
"""Get the 'web' URL for this order."""
|
||||
return pui_url(f'/stock/transfer-order/{self.pk}')
|
||||
|
||||
@staticmethod
|
||||
def get_api_url() -> str:
|
||||
"""Return the API URL associated with the TransferOrder model."""
|
||||
return reverse('api-transfer-order-list')
|
||||
|
||||
@classmethod
|
||||
def get_status_class(cls):
|
||||
"""Return the TransferOrderStatus class."""
|
||||
return TransferOrderStatusGroups
|
||||
|
||||
@classmethod
|
||||
def api_defaults(cls, request=None):
|
||||
"""Return default values for this model when issuing an API OPTIONS request."""
|
||||
defaults = {
|
||||
'reference': order.validators.generate_next_transfer_order_reference()
|
||||
}
|
||||
|
||||
return defaults
|
||||
|
||||
@classmethod
|
||||
def barcode_model_type_code(cls):
|
||||
"""Return the associated barcode model type code for this model."""
|
||||
return 'TO'
|
||||
|
||||
def subscribed_users(self) -> list[User]:
|
||||
"""Return a list of users subscribed to this TransferOrder.
|
||||
|
||||
By this, we mean users to are interested in any of the parts associated with this order.
|
||||
"""
|
||||
subscribed_users = set()
|
||||
|
||||
for line in self.lines.all():
|
||||
if line.part:
|
||||
# Add the part to the list of subscribed users
|
||||
for user in line.part.get_subscribers():
|
||||
subscribed_users.add(user)
|
||||
|
||||
return list(subscribed_users)
|
||||
|
||||
def clean_line_item(self, line):
|
||||
"""Clean a line item for this PurchaseOrder."""
|
||||
super().clean_line_item(line)
|
||||
line.transferred = 0
|
||||
|
||||
def __str__(self):
|
||||
"""Render a string representation of this TransferOrder."""
|
||||
return f'{self.reference} - {self.take_from.name if self.take_from else _("deleted")} --> {self.destination.name if self.destination else _("deleted")}'
|
||||
|
||||
reference = models.CharField(
|
||||
unique=True,
|
||||
max_length=64,
|
||||
blank=False,
|
||||
help_text=_('Transfer Order Reference'),
|
||||
verbose_name=_('Reference'),
|
||||
default=order.validators.generate_next_transfer_order_reference,
|
||||
validators=[order.validators.validate_transfer_order_reference],
|
||||
)
|
||||
|
||||
status = InvenTreeCustomStatusModelField(
|
||||
default=TransferOrderStatus.PENDING.value,
|
||||
choices=TransferOrderStatus.items(),
|
||||
status_class=TransferOrderStatus,
|
||||
verbose_name=_('Status'),
|
||||
help_text=_('Transfer order status'),
|
||||
)
|
||||
|
||||
@property
|
||||
def status_text(self):
|
||||
"""Return the text representation of the status field."""
|
||||
return TransferOrderStatus.text(self.status)
|
||||
|
||||
take_from = models.ForeignKey(
|
||||
'stock.StockLocation',
|
||||
verbose_name=_('Source Location'),
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='sourcing_transfers',
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_('Source for transferred items'),
|
||||
)
|
||||
|
||||
destination = models.ForeignKey(
|
||||
'stock.StockLocation',
|
||||
verbose_name=_('Destination Location'),
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='incoming_transfers',
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_('Destination for transferred items'),
|
||||
)
|
||||
|
||||
consume = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('Consume Stock'),
|
||||
help_text=_(
|
||||
'Rather than transfer the stock to the destination, "consume" it, by removing transferred quantity from the allocated stock item'
|
||||
),
|
||||
)
|
||||
|
||||
complete_date = models.DateField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Completion Date'),
|
||||
help_text=_('Date order was completed'),
|
||||
)
|
||||
|
||||
@property
|
||||
def company(self) -> None:
|
||||
"""Required accessor helper for Order base class."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
"""Return True if the TransferOrder is 'pending'."""
|
||||
return self.status == TransferOrderStatus.PENDING.value
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
"""Return True if the TransferOrder is 'open'."""
|
||||
return self.status in TransferOrderStatusGroups.OPEN
|
||||
|
||||
@property
|
||||
def stock_allocations(self) -> QuerySet:
|
||||
"""Return a queryset containing all allocations for this order."""
|
||||
return TransferOrderAllocation.objects.filter(
|
||||
line__in=[line.pk for line in self.lines.all()]
|
||||
)
|
||||
|
||||
def is_fully_allocated(self) -> bool:
|
||||
"""Return True if all line items are fully allocated."""
|
||||
return all(line.is_fully_allocated() for line in self.lines.all())
|
||||
|
||||
def is_overallocated(self) -> bool:
|
||||
"""Return true if any lines in the order are over-allocated."""
|
||||
return any(line.is_overallocated() for line in self.lines.all())
|
||||
|
||||
def is_completed(self) -> bool:
|
||||
"""Check if this order is "transferred" (all line items transferred)."""
|
||||
return all(line.is_completed() for line in self.lines.all())
|
||||
|
||||
def can_complete(
|
||||
self, raise_error: bool = False, allow_incomplete_lines: bool = False
|
||||
) -> bool:
|
||||
"""Test if this TransferOrder can be completed."""
|
||||
try:
|
||||
if self.status == TransferOrderStatus.COMPLETE.value:
|
||||
raise ValidationError(_('Order is already complete'))
|
||||
|
||||
if self.status == TransferOrderStatus.CANCELLED.value:
|
||||
raise ValidationError(_('Order is already cancelled'))
|
||||
|
||||
if not self.consume and not self.destination:
|
||||
raise ValidationError(
|
||||
_('Order cannot be completed until a destination location is set')
|
||||
)
|
||||
|
||||
if not (self.is_fully_allocated() or allow_incomplete_lines):
|
||||
raise ValidationError(
|
||||
_('Order cannot be completed until it is fully allocated')
|
||||
)
|
||||
except ValidationError as e:
|
||||
if raise_error:
|
||||
raise e
|
||||
else:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@property
|
||||
def can_issue(self) -> bool:
|
||||
"""Return True if this order can be issued."""
|
||||
return self.status in [
|
||||
TransferOrderStatus.PENDING.value,
|
||||
TransferOrderStatus.ON_HOLD.value,
|
||||
]
|
||||
|
||||
@transaction.atomic
|
||||
def issue_order(self):
|
||||
"""Attempt to transition to PLACED status."""
|
||||
return self.handle_transition(
|
||||
self.status, TransferOrderStatus.ISSUED.value, self, self._action_issue
|
||||
)
|
||||
|
||||
# region state changes
|
||||
def _action_issue(self, *args, **kwargs):
|
||||
"""Marks the TransferOrder as ISSUED.
|
||||
|
||||
Order must be currently PENDING.
|
||||
"""
|
||||
if self.can_issue:
|
||||
self.status = TransferOrderStatus.ISSUED.value
|
||||
self.issue_date = InvenTree.helpers.current_date()
|
||||
self.save()
|
||||
|
||||
trigger_event(TransferOrderEvents.ISSUED, id=self.pk)
|
||||
|
||||
# Notify users that the order has been issued
|
||||
notify_responsible(
|
||||
self,
|
||||
TransferOrder,
|
||||
exclude=self.created_by,
|
||||
content=InvenTreeNotificationBodies.NewOrder,
|
||||
extra_users=self.subscribed_users(),
|
||||
)
|
||||
|
||||
@property
|
||||
def can_hold(self) -> bool:
|
||||
"""Return True if this order can be placed on hold."""
|
||||
return self.status in [
|
||||
TransferOrderStatus.PENDING.value,
|
||||
TransferOrderStatus.ISSUED.value,
|
||||
]
|
||||
|
||||
def _action_hold(self, *args, **kwargs):
|
||||
"""Mark this transfer order as 'on hold'."""
|
||||
if self.can_hold:
|
||||
self.status = TransferOrderStatus.ON_HOLD.value
|
||||
self.save()
|
||||
|
||||
trigger_event(TransferOrderEvents.HOLD, id=self.pk)
|
||||
|
||||
@transaction.atomic
|
||||
def _action_complete(self, *args, **kwargs):
|
||||
"""Marks the TransferOrder as COMPLETE.
|
||||
|
||||
Order must be currently ISSUED.
|
||||
"""
|
||||
user = kwargs.pop('user', None)
|
||||
|
||||
if not self.can_complete(raise_error=True, **kwargs):
|
||||
return False
|
||||
|
||||
if self.status == TransferOrderStatus.ISSUED:
|
||||
for allocation in self.allocations():
|
||||
# execute each transfer
|
||||
allocation.complete_allocation(user)
|
||||
|
||||
self.status = TransferOrderStatus.COMPLETE.value
|
||||
self.complete_date = InvenTree.helpers.current_date()
|
||||
|
||||
self.save()
|
||||
|
||||
trigger_event(TransferOrderEvents.COMPLETED, id=self.pk)
|
||||
|
||||
return True
|
||||
|
||||
@transaction.atomic
|
||||
def complete_order(self, user, **kwargs):
|
||||
"""Attempt to transition to COMPLETE status."""
|
||||
return self.handle_transition(
|
||||
self.status,
|
||||
TransferOrderStatus.COMPLETE.value,
|
||||
self,
|
||||
self._action_complete,
|
||||
user=user,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def hold_order(self):
|
||||
"""Attempt to transition to ON_HOLD status."""
|
||||
return self.handle_transition(
|
||||
self.status, TransferOrderStatus.ON_HOLD.value, self, self._action_hold
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def cancel_order(self):
|
||||
"""Attempt to transition to CANCELLED status."""
|
||||
return self.handle_transition(
|
||||
self.status, TransferOrderStatus.CANCELLED.value, self, self._action_cancel
|
||||
)
|
||||
|
||||
@property
|
||||
def can_cancel(self) -> bool:
|
||||
"""A TransferOrder can only be cancelled under the following circumstances.
|
||||
|
||||
- Status is ISSUED
|
||||
- Status is PENDING (or ON_HOLD)
|
||||
"""
|
||||
return self.status in TransferOrderStatusGroups.OPEN
|
||||
|
||||
def _action_cancel(self, *args, **kwargs):
|
||||
"""Cancel this TransferOrder (only if we're allowed to).
|
||||
|
||||
Executes:
|
||||
- Mark the order as 'cancelled'
|
||||
- Delete any StockItems which have been allocated
|
||||
"""
|
||||
if not self.can_cancel:
|
||||
return False
|
||||
|
||||
self.status = TransferOrderStatus.CANCELLED.value
|
||||
self.save()
|
||||
|
||||
# delete allocations
|
||||
for line in self.lines.all():
|
||||
for allocation in line.allocations.all():
|
||||
allocation.delete()
|
||||
|
||||
trigger_event(TransferOrderEvents.CANCELLED, id=self.pk)
|
||||
|
||||
# Notify users that the order has been canceled
|
||||
notify_responsible(
|
||||
self,
|
||||
TransferOrder,
|
||||
exclude=self.created_by,
|
||||
content=InvenTreeNotificationBodies.OrderCanceled,
|
||||
extra_users=self.subscribed_users(),
|
||||
)
|
||||
|
||||
# endregion
|
||||
|
||||
@property
|
||||
def line_count(self) -> int:
|
||||
"""Return the total number of lines associated with this order."""
|
||||
return self.lines.count()
|
||||
|
||||
def completed_line_items(self) -> QuerySet:
|
||||
"""Return a queryset of the completed line items for this order."""
|
||||
return self.lines.filter(transferred__gte=F('quantity'))
|
||||
|
||||
def pending_line_items(self) -> QuerySet:
|
||||
"""Return a queryset of the pending line items for this order."""
|
||||
return self.lines.filter(transferred__lt=F('quantity'))
|
||||
|
||||
@property
|
||||
def completed_line_count(self) -> int:
|
||||
"""Return the number of completed lines for this order."""
|
||||
return self.completed_line_items().count()
|
||||
|
||||
@property
|
||||
def pending_line_count(self) -> int:
|
||||
"""Return the number of pending (incomplete) lines associated with this order."""
|
||||
return self.pending_line_items().count()
|
||||
|
||||
def allocations(self) -> QuerySet:
|
||||
"""Return a queryset of all allocations for this order."""
|
||||
return TransferOrderAllocation.objects.filter(line__order=self)
|
||||
|
||||
|
||||
class TransferOrderLineItem(OrderLineItem):
|
||||
"""Model for a single LineItem in a TransferOrder.
|
||||
|
||||
Attributes:
|
||||
order: Link to the TransferOrder that this line item belongs to
|
||||
part: Link to a Part object (may be null)
|
||||
transferred: The number of items which have actually transferred against this line item
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Model meta options."""
|
||||
|
||||
verbose_name = _('Transfer Order Line Item')
|
||||
|
||||
# Filter for determining if a particular TransferOrderLineItem is overdue
|
||||
OVERDUE_FILTER = (
|
||||
Q(transferred__lt=F('quantity'))
|
||||
& ~Q(target_date=None)
|
||||
& Q(target_date__lt=InvenTree.helpers.current_date())
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with the TransferOrderLineItem model."""
|
||||
return reverse('api-transfer-order-line-list')
|
||||
|
||||
order = models.ForeignKey(
|
||||
TransferOrder,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='lines',
|
||||
verbose_name=_('Order'),
|
||||
help_text=_('Transfer Order'),
|
||||
)
|
||||
|
||||
part = models.ForeignKey(
|
||||
'part.Part',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='transfer_order_line_items',
|
||||
null=True,
|
||||
verbose_name=_('Part'),
|
||||
help_text=_('Part'),
|
||||
# limit_choices_to={'salable': True},
|
||||
)
|
||||
|
||||
transferred = RoundingDecimalField(
|
||||
verbose_name=_('transferred'),
|
||||
help_text=_('transferred quantity'),
|
||||
default=0,
|
||||
max_digits=15,
|
||||
decimal_places=5,
|
||||
validators=[MinValueValidator(0)],
|
||||
)
|
||||
|
||||
def allocated_quantity(self):
|
||||
"""Return the total stock quantity allocated to this LineItem.
|
||||
|
||||
This is a summation of the quantity of each attached StockItem
|
||||
"""
|
||||
if not self.pk:
|
||||
return 0
|
||||
|
||||
query = self.allocations.aggregate(
|
||||
allocated=Coalesce(Sum('quantity'), Decimal(0))
|
||||
)
|
||||
|
||||
return query['allocated']
|
||||
|
||||
def is_fully_allocated(self) -> bool:
|
||||
"""Return True if this line item is fully allocated."""
|
||||
# If the linked part is "virtual", then we cannot allocate stock against it
|
||||
if self.part and self.part.virtual:
|
||||
return True
|
||||
|
||||
return self.allocated_quantity() >= self.quantity
|
||||
|
||||
def is_overallocated(self) -> bool:
|
||||
"""Return True if this line item is over allocated."""
|
||||
return self.allocated_quantity() > self.quantity
|
||||
|
||||
def is_completed(self) -> bool:
|
||||
"""Return True if this line item is completed (has been fully shipped)."""
|
||||
# A "virtual" part is always considered to be "completed"
|
||||
if self.part and self.part.virtual:
|
||||
return True
|
||||
|
||||
return self.transferred >= self.quantity
|
||||
|
||||
|
||||
class TransferOrderAllocation(models.Model):
|
||||
"""This model is used to 'allocate' stock items to a TransferOrder. Items that are "allocated" to a TransferOrder are not yet "attached" to the order, but they will be once the order is fulfilled.
|
||||
|
||||
Attributes:
|
||||
line: TransferOrderLineItem reference
|
||||
item: StockItem reference
|
||||
quantity: Quantity to take from the StockItem
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Model meta options."""
|
||||
|
||||
verbose_name = _('Transfer Order Allocation')
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with the TransferOrderAllocation model."""
|
||||
return reverse('api-transfer-order-allocation-list')
|
||||
|
||||
def clean(self):
|
||||
"""Validate the TransferOrderAllocation object.
|
||||
|
||||
Executes:
|
||||
- Cannot allocate stock to a line item without a part reference
|
||||
- The referenced part must match the part associated with the line item
|
||||
- Allocated quantity cannot exceed the quantity of the stock item
|
||||
- Allocation quantity must be "1" if the StockItem is serialized
|
||||
- Allocation quantity cannot be zero
|
||||
"""
|
||||
super().clean()
|
||||
|
||||
errors = {}
|
||||
|
||||
try:
|
||||
if not self.item:
|
||||
raise ValidationError({'item': _('Stock item has not been assigned')})
|
||||
except stock.models.StockItem.DoesNotExist:
|
||||
raise ValidationError({'item': _('Stock item has not been assigned')})
|
||||
|
||||
try:
|
||||
if self.line.part != self.item.part:
|
||||
variants = self.line.part.get_descendants(include_self=True)
|
||||
if self.line.part not in variants:
|
||||
errors['item'] = _(
|
||||
'Cannot allocate stock item to a line with a different part'
|
||||
)
|
||||
except PartModels.Part.DoesNotExist:
|
||||
errors['line'] = _('Cannot allocate stock to a line without a part')
|
||||
|
||||
if self.quantity > self.item.quantity:
|
||||
errors['quantity'] = _('Allocation quantity cannot exceed stock quantity')
|
||||
|
||||
# Ensure that we do not 'over allocate' a stock item
|
||||
build_allocation_count = self.item.build_allocation_count()
|
||||
sales_allocation_count = self.item.sales_order_allocation_count(
|
||||
exclude_allocations={'pk': self.pk}
|
||||
)
|
||||
|
||||
total_allocation = (
|
||||
build_allocation_count + sales_allocation_count + self.quantity
|
||||
)
|
||||
|
||||
if total_allocation > self.item.quantity:
|
||||
errors['quantity'] = _('Stock item is over-allocated')
|
||||
|
||||
if self.quantity <= 0:
|
||||
errors['quantity'] = _('Allocation quantity must be greater than zero')
|
||||
|
||||
if self.item.serial and self.quantity != 1:
|
||||
errors['quantity'] = _('Quantity must be 1 for serialized stock item')
|
||||
|
||||
if len(errors) > 0:
|
||||
raise ValidationError(errors)
|
||||
|
||||
line = models.ForeignKey(
|
||||
TransferOrderLineItem,
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name=_('Line'),
|
||||
related_name='allocations',
|
||||
)
|
||||
|
||||
item = models.ForeignKey(
|
||||
'stock.StockItem',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='transfer_order_allocations',
|
||||
limit_choices_to={
|
||||
'part__virtual': False,
|
||||
'belongs_to': None,
|
||||
'sales_order': None,
|
||||
},
|
||||
verbose_name=_('Item'),
|
||||
help_text=_('Select stock item to allocate'),
|
||||
)
|
||||
|
||||
quantity = RoundingDecimalField(
|
||||
max_digits=15,
|
||||
decimal_places=5,
|
||||
validators=[MinValueValidator(0)],
|
||||
default=1,
|
||||
verbose_name=_('Quantity'),
|
||||
help_text=_('Enter stock allocation quantity'),
|
||||
)
|
||||
|
||||
def get_location(self):
|
||||
"""Return the <pk> value of the location associated with this allocation."""
|
||||
return self.item.location.id if self.item.location else None
|
||||
|
||||
def get_po(self):
|
||||
"""Return the PurchaseOrder associated with this allocation."""
|
||||
return self.item.purchase_order
|
||||
|
||||
def complete_allocation(self, user):
|
||||
"""Complete this allocation (called when the parent TransferOrder is marked as "completed").
|
||||
|
||||
Executes:
|
||||
- Determine if the referenced StockItem needs to be "split" (if allocated quantity != stock quantity)
|
||||
- Move the StockItem to the new location
|
||||
- Updates the transferred qty
|
||||
- If order is marked as "consume", reduce quantity rather than move
|
||||
"""
|
||||
order: TransferOrder = self.line.order
|
||||
self.item: stock.models.StockItem # for type hints
|
||||
self.line: TransferOrderLineItem # for type hints
|
||||
|
||||
# The allocation is the only thing linking this stock item to the transfer
|
||||
# As a result, we must keep the allocation present even after completion
|
||||
# This means allocations to transfer orders don't affect "available" stock
|
||||
# (otherwise it would permanently reduce available stock)
|
||||
|
||||
if order.consume:
|
||||
# rather than transferring the stock, we simply reduce its quantity to release it from tracked inventory
|
||||
# NOTE: if delete_on_deplete is enabled, this will result in the "transferred stock" panel being empty
|
||||
# after completion. A more sophesticated immutable tracking that doesn't rely on allocations
|
||||
# would be helpful here
|
||||
self.item.take_stock(
|
||||
quantity=self.quantity,
|
||||
user=user,
|
||||
code=StockHistoryCode.STOCK_REMOVE,
|
||||
transferorder=order,
|
||||
)
|
||||
else:
|
||||
if self.quantity < self.item.quantity:
|
||||
# update our own reference to the StockItem which was split
|
||||
self.item = self.item.splitStock(
|
||||
quantity=self.quantity,
|
||||
location=order.destination,
|
||||
user=user,
|
||||
transferorder=order,
|
||||
)
|
||||
self.save()
|
||||
else:
|
||||
# move item directly, we don't have to split
|
||||
self.item.move(
|
||||
location=order.destination, user=user, transferorder=order, notes=''
|
||||
)
|
||||
|
||||
# Update the transferred qty
|
||||
self.line.transferred += self.quantity
|
||||
self.line.save()
|
||||
|
||||
|
||||
def _touch_order_updated_at(instance):
|
||||
"""Bump updated_at on the parent order without triggering a full save."""
|
||||
if not InvenTree.ready.canAppAccessDatabase(allow_test=True):
|
||||
|
|
@ -3192,6 +3853,16 @@ def _touch_order_updated_at(instance):
|
|||
@receiver(
|
||||
post_delete, sender=ReturnOrderExtraLine, dispatch_uid='ro_extraline_post_delete'
|
||||
)
|
||||
@receiver(
|
||||
post_save,
|
||||
sender=TransferOrderLineItem,
|
||||
dispatch_uid='transfer_order_lineitem_post_save',
|
||||
)
|
||||
@receiver(
|
||||
post_delete,
|
||||
sender=TransferOrderLineItem,
|
||||
dispatch_uid='transfer_order_lineitem_post_delete',
|
||||
)
|
||||
def update_order_on_lineitem_change(sender, instance, **kwargs):
|
||||
"""Update parent order updated_at when any line item is saved or deleted."""
|
||||
_touch_order_updated_at(instance)
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ from order.status_codes import (
|
|||
ReturnOrderLineStatus,
|
||||
ReturnOrderStatus,
|
||||
SalesOrderStatusGroups,
|
||||
TransferOrderStatusGroups,
|
||||
)
|
||||
from part.serializers import PartBriefSerializer
|
||||
from stock.status_codes import StockStatus
|
||||
|
|
@ -2298,3 +2299,622 @@ class ReturnOrderExtraLineSerializer(
|
|||
'allow_null': True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@register_importer()
|
||||
class TransferOrderSerializer(
|
||||
NotesFieldMixin,
|
||||
InvenTreeCustomStatusSerializerMixin,
|
||||
AbstractOrderSerializer,
|
||||
InvenTreeModelSerializer,
|
||||
):
|
||||
"""Serializer for a TransferOrder object."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = order.models.TransferOrder
|
||||
fields = AbstractOrderSerializer.order_fields([
|
||||
'take_from',
|
||||
'take_from_detail',
|
||||
'destination',
|
||||
'destination_detail',
|
||||
'consume',
|
||||
'complete_date',
|
||||
])
|
||||
read_only_fields = ['creation_date']
|
||||
extra_kwargs = {}
|
||||
|
||||
def skip_create_fields(self):
|
||||
"""Skip these fields when instantiating a new object."""
|
||||
fields = super().skip_create_fields()
|
||||
|
||||
return [*fields, 'duplicate']
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""Add extra information to the queryset.
|
||||
|
||||
- Number of line items in the TransferOrder
|
||||
- Number of completed line items in the TransferOrder
|
||||
- Overdue status of the TransferOrder
|
||||
"""
|
||||
queryset = AbstractOrderSerializer.annotate_queryset(queryset)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
completed_lines=SubqueryCount(
|
||||
'lines', filter=Q(quantity__lte=F('transferred'))
|
||||
)
|
||||
)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
overdue=Case(
|
||||
When(
|
||||
order.models.TransferOrder.overdue_filter(),
|
||||
then=Value(True, output_field=BooleanField()),
|
||||
),
|
||||
default=Value(False, output_field=BooleanField()),
|
||||
)
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
take_from_detail = OptionalField(
|
||||
serializer_class=stock.serializers.LocationSerializer,
|
||||
serializer_kwargs={
|
||||
'source': 'take_from',
|
||||
'many': False,
|
||||
'read_only': True,
|
||||
'allow_null': True,
|
||||
},
|
||||
default_include=True,
|
||||
)
|
||||
|
||||
destination_detail = OptionalField(
|
||||
serializer_class=stock.serializers.LocationSerializer,
|
||||
serializer_kwargs={
|
||||
'source': 'destination',
|
||||
'many': False,
|
||||
'read_only': True,
|
||||
'allow_null': True,
|
||||
},
|
||||
default_include=True,
|
||||
)
|
||||
|
||||
|
||||
class TransferOrderHoldSerializer(OrderAdjustSerializer):
|
||||
"""Serializer for placing a TransferOrder on hold."""
|
||||
|
||||
def save(self):
|
||||
"""Save the serializer to 'hold' the order."""
|
||||
self.order.hold_order()
|
||||
|
||||
|
||||
class TransferOrderIssueSerializer(OrderAdjustSerializer):
|
||||
"""Serializer for issuing a transfer order."""
|
||||
|
||||
def save(self):
|
||||
"""Save the serializer to 'issue' the order."""
|
||||
self.order.issue_order()
|
||||
|
||||
|
||||
class TransferOrderCancelSerializer(OrderAdjustSerializer):
|
||||
"""Serializer for cancelling a TransferOrder."""
|
||||
|
||||
def save(self):
|
||||
"""Save the serializer to 'cancel' the order."""
|
||||
if not self.order.can_cancel:
|
||||
raise ValidationError(_('Order cannot be cancelled'))
|
||||
|
||||
self.order.cancel_order()
|
||||
|
||||
|
||||
class TransferOrderCompleteSerializer(OrderAdjustSerializer):
|
||||
"""Serializer for completing a transfer order."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
fields = ['accept_incomplete_allocation']
|
||||
|
||||
accept_incomplete_allocation = serializers.BooleanField(
|
||||
label=_('Accept Incomplete Allocation'),
|
||||
help_text=_('Allow order to complete with incomplete allocations'),
|
||||
required=False,
|
||||
default=False,
|
||||
)
|
||||
|
||||
def validate_accept_incomplete_allocation(self, value):
|
||||
"""Check if the 'accept_incomplete_allocation' field is required."""
|
||||
order = self.context['order']
|
||||
|
||||
if not value and not order.is_fully_allocated():
|
||||
raise ValidationError(_('Order has incomplete allocations'))
|
||||
|
||||
return value
|
||||
|
||||
def get_context_data(self):
|
||||
"""Custom context information for this serializer."""
|
||||
order = self.context['order']
|
||||
|
||||
return {'is_complete': order.is_completed()}
|
||||
|
||||
def validate(self, data):
|
||||
"""Custom validation for the serializer."""
|
||||
data = super().validate(data)
|
||||
self.order.can_complete(
|
||||
raise_error=True,
|
||||
allow_incomplete_lines=str2bool(
|
||||
data.get('accept_incomplete_allocation', False)
|
||||
),
|
||||
)
|
||||
return data
|
||||
|
||||
def save(self):
|
||||
"""Save the serializer to 'complete' the order."""
|
||||
request = self.context.get('request')
|
||||
data = self.validated_data
|
||||
user = request.user if request else None
|
||||
|
||||
self.order.complete_order(
|
||||
user=user,
|
||||
allow_incomplete_lines=data.get('accept_incomplete_allocation', False),
|
||||
)
|
||||
|
||||
|
||||
@register_importer()
|
||||
class TransferOrderLineItemSerializer(
|
||||
DataImportExportSerializerMixin,
|
||||
AbstractLineItemSerializer,
|
||||
InvenTreeModelSerializer,
|
||||
):
|
||||
"""Serializer for a TransferOrderLineItem object."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = order.models.TransferOrderLineItem
|
||||
fields = AbstractLineItemSerializer.line_fields([
|
||||
'allocated',
|
||||
'overdue',
|
||||
'part',
|
||||
'part_detail',
|
||||
'transferred',
|
||||
# Annotated fields for part stocking information
|
||||
'available_stock',
|
||||
'available_variant_stock',
|
||||
'building',
|
||||
'on_order',
|
||||
# Filterable detail fields
|
||||
])
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""Add some extra annotations to this queryset.
|
||||
|
||||
- "overdue" status (boolean field)
|
||||
- "available_quantity"
|
||||
- "building"
|
||||
- "on_order"
|
||||
"""
|
||||
queryset = queryset.annotate(
|
||||
overdue=Case(
|
||||
When(
|
||||
Q(order__status__in=TransferOrderStatusGroups.OPEN)
|
||||
& order.models.TransferOrderLineItem.OVERDUE_FILTER,
|
||||
then=Value(True, output_field=BooleanField()),
|
||||
),
|
||||
default=Value(False, output_field=BooleanField()),
|
||||
)
|
||||
)
|
||||
|
||||
# Annotate each line with the available stock quantity
|
||||
# To do this, we need to look at the total stock and any allocations
|
||||
queryset = queryset.alias(
|
||||
total_stock=part_filters.annotate_total_stock(reference='part__'),
|
||||
allocated_to_sales_orders=part_filters.annotate_sales_order_allocations(
|
||||
reference='part__'
|
||||
),
|
||||
allocated_to_build_orders=part_filters.annotate_build_order_allocations(
|
||||
reference='part__'
|
||||
),
|
||||
)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
available_stock=Greatest(
|
||||
ExpressionWrapper(
|
||||
F('total_stock')
|
||||
- F('allocated_to_sales_orders')
|
||||
- F('allocated_to_build_orders'),
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
0,
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
# Add information about the quantity of parts currently on order
|
||||
queryset = queryset.annotate(
|
||||
on_order=part_filters.annotate_on_order_quantity(reference='part__')
|
||||
)
|
||||
|
||||
# Add information about the quantity of parts currently in production
|
||||
queryset = queryset.annotate(
|
||||
building=part_filters.annotate_in_production_quantity(reference='part__')
|
||||
)
|
||||
|
||||
# Annotate total 'allocated' stock quantity
|
||||
queryset = queryset.annotate(
|
||||
allocated=Coalesce(
|
||||
SubquerySum('allocations__quantity'),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
order_detail = OptionalField(
|
||||
serializer_class=TransferOrderSerializer,
|
||||
serializer_kwargs={
|
||||
'source': 'order',
|
||||
'many': False,
|
||||
'read_only': True,
|
||||
'allow_null': True,
|
||||
},
|
||||
prefetch_fields=[
|
||||
'order__created_by',
|
||||
'order__responsible',
|
||||
'order__project_code',
|
||||
],
|
||||
)
|
||||
|
||||
part_detail = OptionalField(
|
||||
serializer_class=PartBriefSerializer,
|
||||
serializer_kwargs={
|
||||
'source': 'part',
|
||||
'many': False,
|
||||
'read_only': True,
|
||||
'allow_null': True,
|
||||
},
|
||||
prefetch_fields=['part__pricing_data'],
|
||||
)
|
||||
|
||||
# Annotated fields
|
||||
overdue = serializers.BooleanField(read_only=True, allow_null=True)
|
||||
available_stock = serializers.FloatField(read_only=True)
|
||||
available_variant_stock = serializers.FloatField(read_only=True)
|
||||
on_order = serializers.FloatField(label=_('On Order'), read_only=True)
|
||||
building = serializers.FloatField(label=_('In Production'), read_only=True)
|
||||
|
||||
quantity = InvenTreeDecimalField()
|
||||
|
||||
allocated = serializers.FloatField(read_only=True)
|
||||
|
||||
transferred = InvenTreeDecimalField(read_only=True)
|
||||
|
||||
|
||||
class TransferOrderAllocationItemSerializer(serializers.Serializer):
|
||||
"""A serializer for allocating a single stock-item against a TransferOrder line item."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
fields = ['line_item', 'stock_item', 'quantity']
|
||||
|
||||
line_item = serializers.PrimaryKeyRelatedField(
|
||||
queryset=order.models.TransferOrderLineItem.objects.all(),
|
||||
many=False,
|
||||
allow_null=False,
|
||||
required=True,
|
||||
label=_('Stock Item'),
|
||||
)
|
||||
|
||||
def validate_line_item(self, line_item):
|
||||
"""Custom validation for the 'line_item' field.
|
||||
|
||||
- Ensure the line_item is associated with the particular TransferOrder
|
||||
"""
|
||||
order = self.context['order']
|
||||
|
||||
# Ensure that the line item points to the correct order
|
||||
if line_item.order != order:
|
||||
raise ValidationError(_('Line item is not associated with this order'))
|
||||
|
||||
return line_item
|
||||
|
||||
stock_item = serializers.PrimaryKeyRelatedField(
|
||||
queryset=stock.models.StockItem.objects.all(),
|
||||
many=False,
|
||||
allow_null=False,
|
||||
required=True,
|
||||
label=_('Stock Item'),
|
||||
)
|
||||
|
||||
quantity = serializers.DecimalField(
|
||||
max_digits=15, decimal_places=5, min_value=Decimal(0), required=True
|
||||
)
|
||||
|
||||
def validate_quantity(self, quantity):
|
||||
"""Custom validation for the 'quantity' field."""
|
||||
if quantity <= 0:
|
||||
raise ValidationError(_('Quantity must be positive'))
|
||||
|
||||
return quantity
|
||||
|
||||
def validate(self, data):
|
||||
"""Custom validation for the serializer.
|
||||
|
||||
- Ensure that the quantity is 1 for serialized stock
|
||||
- Quantity cannot exceed the available amount
|
||||
"""
|
||||
data = super().validate(data)
|
||||
|
||||
stock_item = data['stock_item']
|
||||
quantity = data['quantity']
|
||||
|
||||
if stock_item.serialized and quantity != 1:
|
||||
raise ValidationError({
|
||||
'quantity': _('Quantity must be 1 for serialized stock item')
|
||||
})
|
||||
|
||||
q = normalize(stock_item.unallocated_quantity())
|
||||
|
||||
if quantity > q:
|
||||
raise ValidationError({'quantity': _(f'Available quantity ({q}) exceeded')})
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class TransferOrderLineItemAllocationSerializer(serializers.Serializer):
|
||||
"""DRF serializer for allocation of stock items against a transfer order line item."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
fields = ['items']
|
||||
|
||||
items = TransferOrderAllocationItemSerializer(many=True)
|
||||
|
||||
def validate(self, data):
|
||||
"""Serializer validation."""
|
||||
data = super().validate(data)
|
||||
|
||||
# Extract TransferOrder from serializer context
|
||||
# order = self.context['order']
|
||||
|
||||
items = data.get('items', [])
|
||||
|
||||
if len(items) == 0:
|
||||
raise ValidationError(_('Allocation items must be provided'))
|
||||
|
||||
return data
|
||||
|
||||
def save(self):
|
||||
"""Perform the allocation of items against this order."""
|
||||
data = self.validated_data
|
||||
|
||||
items = data['items']
|
||||
|
||||
with transaction.atomic():
|
||||
for entry in items:
|
||||
# Create a new TransferOrderAllocation
|
||||
allocation = order.models.TransferOrderAllocation(
|
||||
line=entry.get('line_item'),
|
||||
item=entry.get('stock_item'),
|
||||
quantity=entry.get('quantity'),
|
||||
)
|
||||
|
||||
allocation.full_clean()
|
||||
allocation.save()
|
||||
|
||||
|
||||
class TransferOrderAllocationSerializer(
|
||||
FilterableSerializerMixin, InvenTreeModelSerializer
|
||||
):
|
||||
"""Serializer for the TransferOrderAllocation model.
|
||||
|
||||
This includes some fields from the related model objects.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = order.models.TransferOrderAllocation
|
||||
fields = [
|
||||
'pk',
|
||||
'item',
|
||||
'quantity',
|
||||
# Annotated read-only fields
|
||||
'line',
|
||||
'part',
|
||||
'order',
|
||||
'serial',
|
||||
'location',
|
||||
# Extra detail fields
|
||||
'item_detail',
|
||||
'part_detail',
|
||||
'order_detail',
|
||||
'location_detail',
|
||||
]
|
||||
read_only_fields = ['line', '']
|
||||
|
||||
part = serializers.PrimaryKeyRelatedField(source='item.part', read_only=True)
|
||||
order = serializers.PrimaryKeyRelatedField(
|
||||
source='line.order', many=False, read_only=True
|
||||
)
|
||||
serial = serializers.CharField(source='get_serial', read_only=True, allow_null=True)
|
||||
quantity = serializers.FloatField(read_only=False)
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
source='item.location', many=False, read_only=True
|
||||
)
|
||||
|
||||
# Extra detail fields
|
||||
order_detail = OptionalField(
|
||||
serializer_class=TransferOrderSerializer,
|
||||
serializer_kwargs={
|
||||
'source': 'line.order',
|
||||
'many': False,
|
||||
'read_only': True,
|
||||
'allow_null': True,
|
||||
},
|
||||
)
|
||||
|
||||
part_detail = OptionalField(
|
||||
serializer_class=PartBriefSerializer,
|
||||
serializer_kwargs={
|
||||
'source': 'item.part',
|
||||
'many': False,
|
||||
'read_only': True,
|
||||
'allow_null': True,
|
||||
},
|
||||
)
|
||||
|
||||
item_detail = OptionalField(
|
||||
serializer_class=stock.serializers.StockItemSerializer,
|
||||
serializer_kwargs={
|
||||
'source': 'item',
|
||||
'many': False,
|
||||
'read_only': True,
|
||||
'allow_null': True,
|
||||
'part_detail': False,
|
||||
'location_detail': False,
|
||||
'supplier_part_detail': False,
|
||||
},
|
||||
)
|
||||
|
||||
location_detail = OptionalField(
|
||||
serializer_class=stock.serializers.LocationBriefSerializer,
|
||||
serializer_kwargs={
|
||||
'source': 'item.location',
|
||||
'many': False,
|
||||
'read_only': True,
|
||||
'allow_null': True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TransferOrderSerialAllocationSerializer(serializers.Serializer):
|
||||
"""DRF serializer for allocation of serial numbers against a transfer order."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
fields = ['line_item', 'quantity', 'serial_numbers']
|
||||
|
||||
line_item = serializers.PrimaryKeyRelatedField(
|
||||
queryset=order.models.TransferOrderLineItem.objects.all(),
|
||||
many=False,
|
||||
required=True,
|
||||
allow_null=False,
|
||||
label=_('Line Item'),
|
||||
)
|
||||
|
||||
def validate_line_item(self, line_item):
|
||||
"""Ensure that the line_item is valid."""
|
||||
order = self.context['order']
|
||||
|
||||
# Ensure that the line item points to the correct order
|
||||
if line_item.order != order:
|
||||
raise ValidationError(_('Line item is not associated with this order'))
|
||||
|
||||
return line_item
|
||||
|
||||
quantity = serializers.IntegerField(
|
||||
min_value=1, required=True, allow_null=False, label=_('Quantity')
|
||||
)
|
||||
|
||||
serial_numbers = serializers.CharField(
|
||||
label=_('Serial Numbers'),
|
||||
help_text=_('Enter serial numbers to allocate'),
|
||||
required=True,
|
||||
allow_blank=False,
|
||||
)
|
||||
|
||||
def validate(self, data):
|
||||
"""Validation for the serializer.
|
||||
|
||||
- Ensure the serial_numbers and quantity fields match
|
||||
- Check that all serial numbers exist
|
||||
- Check that the serial numbers are not yet allocated
|
||||
"""
|
||||
data = super().validate(data)
|
||||
|
||||
line_item = data['line_item']
|
||||
quantity = data['quantity']
|
||||
serial_numbers = data['serial_numbers']
|
||||
|
||||
part = line_item.part
|
||||
|
||||
try:
|
||||
data['serials'] = extract_serial_numbers(
|
||||
serial_numbers, quantity, part.get_latest_serial_number(), part=part
|
||||
)
|
||||
except DjangoValidationError as e:
|
||||
raise ValidationError({'serial_numbers': e.messages})
|
||||
|
||||
serials_not_exist = set()
|
||||
serials_unavailable = set()
|
||||
stock_items_to_allocate = []
|
||||
|
||||
for serial in data['serials']:
|
||||
serial = str(serial).strip()
|
||||
|
||||
items = stock.models.StockItem.objects.filter(
|
||||
part=part, serial=serial, quantity=1
|
||||
)
|
||||
|
||||
if not items.exists():
|
||||
serials_not_exist.add(str(serial))
|
||||
continue
|
||||
|
||||
stock_item = items[0]
|
||||
|
||||
if not stock_item.in_stock:
|
||||
serials_unavailable.add(str(serial))
|
||||
continue
|
||||
|
||||
if stock_item.unallocated_quantity() < 1:
|
||||
serials_unavailable.add(str(serial))
|
||||
continue
|
||||
|
||||
# At this point, the serial number is valid, and can be added to the list
|
||||
stock_items_to_allocate.append(stock_item)
|
||||
|
||||
if len(serials_not_exist) > 0:
|
||||
error_msg = _('No match found for the following serial numbers')
|
||||
error_msg += ': '
|
||||
error_msg += ','.join(sorted(serials_not_exist))
|
||||
|
||||
raise ValidationError({'serial_numbers': error_msg})
|
||||
|
||||
if len(serials_unavailable) > 0:
|
||||
error_msg = _('The following serial numbers are unavailable')
|
||||
error_msg += ': '
|
||||
error_msg += ','.join(sorted(serials_unavailable))
|
||||
|
||||
raise ValidationError({'serial_numbers': error_msg})
|
||||
|
||||
data['stock_items'] = stock_items_to_allocate
|
||||
|
||||
return data
|
||||
|
||||
def save(self):
|
||||
"""Allocate stock items against the transfer order."""
|
||||
data = self.validated_data
|
||||
|
||||
line_item = data['line_item']
|
||||
stock_items = data['stock_items']
|
||||
|
||||
allocations = []
|
||||
|
||||
for stock_item in stock_items:
|
||||
# Create a new TransferOrderAllocation
|
||||
allocations.append(
|
||||
order.models.TransferOrderAllocation(
|
||||
line=line_item, item=stock_item, quantity=1
|
||||
)
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
order.models.TransferOrderAllocation.objects.bulk_create(allocations)
|
||||
|
|
|
|||
|
|
@ -115,3 +115,30 @@ class ReturnOrderLineStatus(StatusCode):
|
|||
|
||||
# Item is rejected
|
||||
REJECT = 60, _('Reject'), ColorEnum.danger
|
||||
|
||||
|
||||
class TransferOrderStatus(StatusCode):
|
||||
"""Defines a set of status codes for a TransferOrder."""
|
||||
|
||||
# Order status codes
|
||||
PENDING = 10, _('Pending'), ColorEnum.secondary # Order is pending (not yet issued)
|
||||
ISSUED = 20, _('Issued'), ColorEnum.primary # Order has been issued
|
||||
ON_HOLD = 25, _('On Hold'), ColorEnum.warning # Order is on hold
|
||||
COMPLETE = 30, _('Complete'), ColorEnum.success # Order has been completed
|
||||
CANCELLED = 40, _('Cancelled'), ColorEnum.danger # Order was cancelled
|
||||
|
||||
|
||||
class TransferOrderStatusGroups:
|
||||
"""Groups for TransferOrderStatus codes."""
|
||||
|
||||
# Open orders
|
||||
OPEN = [
|
||||
TransferOrderStatus.PENDING.value,
|
||||
TransferOrderStatus.ON_HOLD.value,
|
||||
TransferOrderStatus.ISSUED.value,
|
||||
]
|
||||
|
||||
# Failed orders
|
||||
FAILED = [TransferOrderStatus.CANCELLED.value]
|
||||
|
||||
COMPLETE = [TransferOrderStatus.COMPLETE.value]
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ from order.status_codes import (
|
|||
ReturnOrderStatus,
|
||||
SalesOrderStatus,
|
||||
SalesOrderStatusGroups,
|
||||
TransferOrderStatus,
|
||||
TransferOrderStatusGroups,
|
||||
)
|
||||
from part.models import Part
|
||||
from stock.models import StockItem, StockLocation
|
||||
|
|
@ -46,9 +48,10 @@ class OrderTest(InvenTreeAPITestCase):
|
|||
'stock',
|
||||
'order',
|
||||
'sales_order',
|
||||
'transfer_order',
|
||||
]
|
||||
|
||||
roles = ['purchase_order.change', 'sales_order.change']
|
||||
roles = ['purchase_order.change', 'sales_order.change', 'transfer_order.change']
|
||||
|
||||
def filter(self, filters, count):
|
||||
"""Test API filters."""
|
||||
|
|
@ -2892,3 +2895,848 @@ class ReturnOrderLineItemTests(InvenTreeAPITestCase):
|
|||
|
||||
line = models.ReturnOrderLineItem.objects.get(pk=1)
|
||||
self.assertEqual(float(line.price.amount), 15.75)
|
||||
|
||||
|
||||
class TransferOrderTest(OrderTest):
|
||||
"""Tests for the TransferOrder API."""
|
||||
|
||||
LIST_URL = reverse('api-transfer-order-list')
|
||||
|
||||
def test_transfer_order_list(self):
|
||||
"""Test the TransferOrder list API endpoint."""
|
||||
# all orders
|
||||
self.filter({}, 5)
|
||||
|
||||
# filter by outstanding
|
||||
self.filter({'outstanding': True}, 3)
|
||||
self.filter({'outstanding': False}, 2)
|
||||
|
||||
# Filter by status
|
||||
self.filter({'status': TransferOrderStatus.PENDING.value}, 1)
|
||||
self.filter({'status': SalesOrderStatus.COMPLETE.value}, 1)
|
||||
self.filter({'status': 99}, 0) # Invalid
|
||||
|
||||
# Filter by "reference"
|
||||
self.filter({'reference': 'TO-123'}, 1)
|
||||
self.filter({'reference': 'TO-999'}, 0)
|
||||
|
||||
# Filter by "assigned_to_me"
|
||||
self.filter({'assigned_to_me': 1}, 0)
|
||||
self.filter({'assigned_to_me': 0}, 5)
|
||||
|
||||
def test_overdue(self):
|
||||
"""Test "overdue" status."""
|
||||
self.filter({'overdue': True}, 0)
|
||||
self.filter({'overdue': False}, 5)
|
||||
|
||||
# pick two orders that are still open (not cancelled or complete)
|
||||
for pk in [1, 4]:
|
||||
order = models.TransferOrder.objects.get(pk=pk)
|
||||
order.target_date = datetime.now().date() - timedelta(days=10)
|
||||
order.save()
|
||||
|
||||
self.filter({'overdue': True}, 2)
|
||||
self.filter({'overdue': False}, 3)
|
||||
|
||||
def test_transfer_order_detail(self):
|
||||
"""Test the TransferOrder detail endpoint."""
|
||||
url = '/api/order/transfer-order/1/'
|
||||
|
||||
response = self.get(url)
|
||||
|
||||
data = response.data
|
||||
|
||||
self.assertEqual(data['pk'], 1)
|
||||
|
||||
def test_transfer_order_attachments(self):
|
||||
"""Test the list endpoint for the Transfer Order Attachments."""
|
||||
url = reverse('api-attachment-list')
|
||||
|
||||
# Filter by 'transferorder'
|
||||
self.get(
|
||||
url, data={'model_type': 'transferorder', 'model_id': 1}, expected_code=200
|
||||
)
|
||||
|
||||
def test_transfer_order_operations(self):
|
||||
"""Test that we can create / edit and delete a TransferOrder via the API."""
|
||||
n = models.TransferOrder.objects.count()
|
||||
|
||||
url = reverse('api-transfer-order-list')
|
||||
|
||||
# Initially we do not have "add" permission for the TransferOrder model,
|
||||
# so this POST request should return 403 (denied)
|
||||
response = self.post(
|
||||
url,
|
||||
{'reference': 'TO-43245', 'description': 'Transfer order'},
|
||||
expected_code=403,
|
||||
)
|
||||
|
||||
self.assignRole('transfer_order.add')
|
||||
|
||||
# Now we should be able to create a TransferOrder via the API
|
||||
response = self.post(
|
||||
url,
|
||||
{'reference': 'TO-12345', 'description': 'Transfer order'},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
# Check that the new order has been created
|
||||
self.assertEqual(models.TransferOrder.objects.count(), n + 1)
|
||||
|
||||
# Grab the PK for the newly created TransferOrder
|
||||
pk = response.data['pk']
|
||||
|
||||
# Basic checks against the newly created TransferOrder
|
||||
so = models.TransferOrder.objects.get(pk=pk)
|
||||
self.assertEqual(so.reference, 'TO-12345')
|
||||
self.assertEqual(so.created_by.username, 'testuser')
|
||||
|
||||
# Try to create a TO with identical reference (should fail)
|
||||
response = self.post(
|
||||
url,
|
||||
{
|
||||
'customer': 4,
|
||||
'reference': 'TO-12345',
|
||||
'description': 'Another transfer order',
|
||||
},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
url = reverse('api-transfer-order-detail', kwargs={'pk': pk})
|
||||
|
||||
# Extract detail info for the TransferOrder
|
||||
response = self.get(url)
|
||||
self.assertEqual(response.data['reference'], 'TO-12345')
|
||||
|
||||
# Try to alter (edit) the TransferOrder
|
||||
# Initially try with an invalid reference field value
|
||||
response = self.patch(url, {'reference': 'TO-12345-a'}, expected_code=400)
|
||||
|
||||
response = self.patch(url, {'reference': 'TO-12346'}, expected_code=200)
|
||||
|
||||
# Reference should have changed
|
||||
self.assertEqual(response.data['reference'], 'TO-12346')
|
||||
|
||||
# Now, let's try to delete this TransferOrder
|
||||
# Initially, we do not have the required permission
|
||||
response = self.delete(url, expected_code=403)
|
||||
|
||||
self.assignRole('transfer_order.delete')
|
||||
|
||||
response = self.delete(url, expected_code=204)
|
||||
|
||||
# Check that the number of transfer orders has decreased
|
||||
self.assertEqual(models.TransferOrder.objects.count(), n)
|
||||
|
||||
# And the resource should no longer be available
|
||||
response = self.get(url, expected_code=404)
|
||||
|
||||
def test_transfer_order_create(self):
|
||||
"""Test that we can create a new TransferOrder via the API."""
|
||||
self.assignRole('transfer_order.add')
|
||||
|
||||
url = reverse('api-transfer-order-list')
|
||||
|
||||
# Will fail due to invalid reference field
|
||||
response = self.post(
|
||||
url,
|
||||
{'reference': '1234566778', 'description': 'A test transfer order'},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
'Reference must match required pattern', str(response.data['reference'])
|
||||
)
|
||||
|
||||
self.post(
|
||||
url,
|
||||
{'reference': 'TO-12345', 'description': 'A better test transfer order'},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
def test_transfer_order_cancel(self):
|
||||
"""Test API endpoint for cancelling a TransferOrder."""
|
||||
to = models.TransferOrder.objects.get(pk=1)
|
||||
|
||||
self.assertEqual(to.status, TransferOrderStatus.PENDING)
|
||||
|
||||
url = reverse('api-transfer-order-cancel', kwargs={'pk': to.pk})
|
||||
|
||||
# Try to cancel, without permission
|
||||
self.post(url, {}, expected_code=403)
|
||||
|
||||
self.assignRole('transfer_order.add')
|
||||
|
||||
self.post(url, {}, expected_code=201)
|
||||
|
||||
to.refresh_from_db()
|
||||
|
||||
self.assertEqual(to.status, TransferOrderStatus.CANCELLED)
|
||||
|
||||
def test_transfer_order_hold(self):
|
||||
"""Test API endpoint for holdling a TransferOrder."""
|
||||
to = models.TransferOrder.objects.get(pk=1)
|
||||
|
||||
self.assertEqual(to.status, TransferOrderStatus.PENDING)
|
||||
|
||||
url = reverse('api-transfer-order-hold', kwargs={'pk': to.pk})
|
||||
|
||||
# Try to hold, without permission
|
||||
self.post(url, {}, expected_code=403)
|
||||
|
||||
self.assignRole('transfer_order.add')
|
||||
|
||||
self.post(url, {}, expected_code=201)
|
||||
|
||||
to.refresh_from_db()
|
||||
|
||||
self.assertEqual(to.status, TransferOrderStatus.ON_HOLD)
|
||||
|
||||
def test_transfer_order_calendar(self):
|
||||
"""Test the calendar export endpoint."""
|
||||
# Create required transfer orders
|
||||
self.assignRole('transfer_order.add')
|
||||
|
||||
for i in range(1, 9):
|
||||
self.post(
|
||||
reverse('api-transfer-order-list'),
|
||||
{
|
||||
'reference': f'TO-1100000{i}',
|
||||
'description': f'Calendar SO {i}',
|
||||
'target_date': f'2024-12-{i:02d}',
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
# Cancel a few orders - these will not show in incomplete view below
|
||||
for to in models.TransferOrder.objects.filter(target_date__isnull=False):
|
||||
if to.reference in [
|
||||
'TO-11000006',
|
||||
'TO-11000007',
|
||||
'TO-11000008',
|
||||
'TO-11000009',
|
||||
]:
|
||||
self.post(
|
||||
reverse('api-transfer-order-cancel', kwargs={'pk': to.pk}),
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
url = reverse('api-po-so-calendar', kwargs={'ordertype': 'transfer-order'})
|
||||
|
||||
# Test without completed orders
|
||||
response = self.get(url, expected_code=200, format=None)
|
||||
|
||||
number_orders = len(
|
||||
models.TransferOrder.objects.filter(target_date__isnull=False).filter(
|
||||
status__lt=TransferOrderStatus.COMPLETE.value
|
||||
)
|
||||
)
|
||||
|
||||
# Transform content to a Calendar object
|
||||
calendar = Calendar.from_ical(response.content)
|
||||
n_events = 0
|
||||
# Count number of events in calendar
|
||||
for component in calendar.walk():
|
||||
if component.name == 'VEVENT':
|
||||
n_events += 1
|
||||
|
||||
self.assertGreaterEqual(n_events, 1)
|
||||
self.assertEqual(number_orders, n_events)
|
||||
|
||||
# Test with completed orders
|
||||
response = self.get(
|
||||
url, data={'include_completed': 'True'}, expected_code=200, format=None
|
||||
)
|
||||
|
||||
number_orders_incl_complete = len(
|
||||
models.TransferOrder.objects.filter(target_date__isnull=False)
|
||||
)
|
||||
self.assertGreater(number_orders_incl_complete, number_orders)
|
||||
|
||||
# Transform content to a Calendar object
|
||||
calendar = Calendar.from_ical(response.content)
|
||||
n_events = 0
|
||||
# Count number of events in calendar
|
||||
for component in calendar.walk():
|
||||
if component.name == 'VEVENT':
|
||||
n_events += 1
|
||||
|
||||
self.assertGreaterEqual(n_events, 1)
|
||||
self.assertEqual(number_orders_incl_complete, n_events)
|
||||
|
||||
def test_export(self):
|
||||
"""Test we can export the TransferOrder list."""
|
||||
n = models.TransferOrder.objects.count()
|
||||
|
||||
# Check there are some sales orders
|
||||
self.assertGreater(n, 0)
|
||||
|
||||
# Download file, check we get a 200 response
|
||||
for fmt in ['csv', 'xlsx', 'tsv']:
|
||||
self.export_data(
|
||||
reverse('api-transfer-order-list'),
|
||||
export_format=fmt,
|
||||
decode=fmt == 'csv',
|
||||
expected_code=200,
|
||||
expected_fn=r'InvenTree_TransferOrder_.+',
|
||||
)
|
||||
|
||||
def test_transfer_order_complete(self):
|
||||
"""Tests for marking a TransferOrder as complete."""
|
||||
self.assignRole('transfer_order.add')
|
||||
destination = StockLocation.objects.first()
|
||||
# Let's create a TransferOrder
|
||||
to = models.TransferOrder.objects.create(
|
||||
reference='TO-12345', description='Test TO'
|
||||
)
|
||||
|
||||
self.assertEqual(to.status, TransferOrderStatus.PENDING.value)
|
||||
|
||||
# Create a line item
|
||||
part = Part.objects.exclude(virtual=True).first()
|
||||
|
||||
line = models.TransferOrderLineItem.objects.create(
|
||||
order=to, part=part, quantity=10
|
||||
)
|
||||
|
||||
# issue the order
|
||||
url = reverse('api-transfer-order-issue', kwargs={'pk': to.pk})
|
||||
self.post(url, {}, expected_code=201)
|
||||
to.refresh_from_db()
|
||||
self.assertEqual(to.status, TransferOrderStatus.ISSUED.value)
|
||||
|
||||
# Allocate some stock
|
||||
item = StockItem.objects.create(
|
||||
part=part, quantity=100, location=None, batch='transfer-order-test'
|
||||
)
|
||||
short_allocation = models.TransferOrderAllocation.objects.create(
|
||||
quantity=5, line=line, item=item
|
||||
)
|
||||
|
||||
# attempt to complete the order, but fail because there are incomplete allocations
|
||||
url = reverse('api-transfer-order-complete', kwargs={'pk': to.pk})
|
||||
response = self.post(url, {}, expected_code=400)
|
||||
self.assertIn('has incomplete allocations', str(response.data))
|
||||
# allocate more stock
|
||||
short_allocation.delete()
|
||||
models.TransferOrderAllocation.objects.create(quantity=10, line=line, item=item)
|
||||
|
||||
# attempt to complete the order, but fail because there is no destination yet
|
||||
url = reverse('api-transfer-order-complete', kwargs={'pk': to.pk})
|
||||
response = self.post(url, {}, expected_code=400)
|
||||
self.assertIn('until a destination location is set', str(response.data))
|
||||
# add destination
|
||||
to.destination = destination
|
||||
to.save()
|
||||
|
||||
# Ok, now we should be able to "complete" the transfer via the API
|
||||
url = reverse('api-transfer-order-complete', kwargs={'pk': to.pk})
|
||||
self.post(url, {}, expected_code=201)
|
||||
|
||||
to.refresh_from_db()
|
||||
self.assertEqual(to.status, TransferOrderStatus.COMPLETE.value)
|
||||
self.assertIsNotNone(to.complete_date)
|
||||
|
||||
# Now, let's try *again* (it should fail as the order is already complete)
|
||||
response = self.post(url, {}, expected_code=400)
|
||||
self.assertIn('Order is already complete', str(response.data))
|
||||
|
||||
# Now, we make sure the affected stock was transferred to the correct location
|
||||
StockItem.objects.get(
|
||||
part=part, quantity=10, batch='transfer-order-test', location=destination
|
||||
)
|
||||
|
||||
def test_transfer_order_consume(self):
|
||||
"""Tests for marking a TransferOrder consume the stock it 'transfers'."""
|
||||
self.assignRole('transfer_order.add')
|
||||
destination = StockLocation.objects.first()
|
||||
# Let's create a TransferOrder
|
||||
to = models.TransferOrder.objects.create(
|
||||
reference='TO-12345',
|
||||
description='Test TO',
|
||||
consume=True,
|
||||
destination=destination,
|
||||
)
|
||||
|
||||
self.assertEqual(to.status, TransferOrderStatus.PENDING.value)
|
||||
|
||||
# Create a line item
|
||||
part = Part.objects.exclude(virtual=True).first()
|
||||
|
||||
line = models.TransferOrderLineItem.objects.create(
|
||||
order=to, part=part, quantity=10
|
||||
)
|
||||
|
||||
# issue the order
|
||||
url = reverse('api-transfer-order-issue', kwargs={'pk': to.pk})
|
||||
self.post(url, {}, expected_code=201)
|
||||
to.refresh_from_db()
|
||||
self.assertEqual(to.status, TransferOrderStatus.ISSUED.value)
|
||||
|
||||
# Allocate some stock
|
||||
item = StockItem.objects.create(
|
||||
part=part, quantity=100, location=None, batch='transfer-order-test'
|
||||
)
|
||||
models.TransferOrderAllocation.objects.create(quantity=10, line=line, item=item)
|
||||
|
||||
# Ok, now we should be able to "complete" the transfer via the API
|
||||
url = reverse('api-transfer-order-complete', kwargs={'pk': to.pk})
|
||||
self.post(url, {}, expected_code=201)
|
||||
|
||||
to.refresh_from_db()
|
||||
self.assertEqual(to.status, TransferOrderStatus.COMPLETE.value)
|
||||
self.assertIsNotNone(to.complete_date)
|
||||
|
||||
# Now, we make sure the affected stock was 'consumed', reducing available quantity
|
||||
item.refresh_from_db()
|
||||
self.assertEqual(item.quantity, 90)
|
||||
|
||||
# and that it wasn't transferred to the destination
|
||||
with self.assertRaises(StockItem.DoesNotExist):
|
||||
StockItem.objects.get(
|
||||
part=part,
|
||||
quantity=10,
|
||||
batch='transfer-order-test',
|
||||
location=destination,
|
||||
)
|
||||
|
||||
def test_output_options(self):
|
||||
"""Test the output options for the TransferOrder detail endpoint."""
|
||||
self.run_output_test(
|
||||
reverse('api-transfer-order-detail', kwargs={'pk': 1}),
|
||||
['take_from_detail', 'destination_detail'],
|
||||
)
|
||||
|
||||
|
||||
class TransferOrderLineItemTest(OrderTest):
|
||||
"""Tests for the TransferOrderLineItem API."""
|
||||
|
||||
LIST_URL = reverse('api-transfer-order-line-list')
|
||||
|
||||
# adjust counts in asserts based on those created in setUpTestData
|
||||
# plus those in fixtures
|
||||
NUM_LINE_ITEMS_IN_FIXTURES = 2
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""Init routine for this unit test class."""
|
||||
super().setUpTestData()
|
||||
|
||||
# List of 'transferrable' parts
|
||||
parts = Part.objects.exclude(virtual=True)
|
||||
|
||||
lines = []
|
||||
|
||||
# Create a bunch of TransferOrderLineItems for each order
|
||||
for idx, to in enumerate(models.TransferOrder.objects.all()):
|
||||
for part in parts:
|
||||
lines.append(
|
||||
models.TransferOrderLineItem(
|
||||
order=to,
|
||||
part=part,
|
||||
quantity=(idx + 1) * 5,
|
||||
reference=f'Order {to.reference} - line {idx}',
|
||||
)
|
||||
)
|
||||
|
||||
# Bulk create
|
||||
models.TransferOrderLineItem.objects.bulk_create(lines)
|
||||
|
||||
cls.url = reverse('api-transfer-order-line-list')
|
||||
|
||||
def test_transfer_order_line_list(self):
|
||||
"""Test list endpoint."""
|
||||
response = self.get(self.url, {}, expected_code=200)
|
||||
|
||||
n = models.TransferOrderLineItem.objects.count()
|
||||
|
||||
# We should have received *all* lines
|
||||
self.assertEqual(len(response.data), n)
|
||||
|
||||
# List *all* lines, but paginate
|
||||
response = self.get(self.url, {'limit': 5}, expected_code=200)
|
||||
|
||||
self.assertEqual(response.data['count'], n)
|
||||
self.assertEqual(len(response.data['results']), 5)
|
||||
|
||||
n_orders = models.TransferOrder.objects.count()
|
||||
n_parts = Part.objects.exclude(virtual=True).count()
|
||||
|
||||
# List by part
|
||||
# fixures add line items, avoid those here with [:3] for predictable counts
|
||||
for part in Part.objects.exclude(virtual=True)[:3]:
|
||||
response = self.get(self.url, {'part': part.pk, 'limit': 10})
|
||||
self.assertEqual(response.data['count'], n_orders)
|
||||
|
||||
# List by order
|
||||
# fixures add line items, avoid those here with [:3] for predictable counts
|
||||
for order in models.TransferOrder.objects.all()[:3]:
|
||||
response = self.get(self.url, {'order': order.pk, 'limit': 10})
|
||||
# count of line items equal to number of parts because
|
||||
# we created a line item per part on each order in setUpTestData
|
||||
self.assertEqual(response.data['count'], n_parts)
|
||||
|
||||
# Filter by 'completed' status
|
||||
self.filter({'completed': 1}, 1)
|
||||
self.filter({'completed': 0}, n - 1)
|
||||
|
||||
# Filter by 'allocated' status
|
||||
self.filter({'allocated': 'true'}, 2)
|
||||
self.filter({'allocated': 'false'}, n - 2)
|
||||
|
||||
def test_transfer_order_line_allocated_filters(self):
|
||||
"""Test filtering by allocation status for a TransferOrderLineItem."""
|
||||
self.assignRole('transfer_order.add')
|
||||
|
||||
destination = StockLocation.objects.first()
|
||||
assert destination
|
||||
|
||||
response = self.post(
|
||||
reverse('api-transfer-order-list'),
|
||||
{
|
||||
'reference': 'TO-12345',
|
||||
'description': 'Test Transfer Order',
|
||||
'destination': destination.pk,
|
||||
},
|
||||
)
|
||||
|
||||
order_id = response.data['pk']
|
||||
order = models.TransferOrder.objects.get(pk=order_id)
|
||||
|
||||
transfer_order_line_url = reverse('api-transfer-order-line-list')
|
||||
|
||||
# Initially, there should be no line items against this order
|
||||
response = self.get(transfer_order_line_url, {'order': order_id})
|
||||
|
||||
self.assertEqual(len(response.data), 0)
|
||||
|
||||
parts = [25, 50, 100]
|
||||
|
||||
# Let's create some new line items
|
||||
for part_id in parts:
|
||||
self.post(
|
||||
transfer_order_line_url,
|
||||
{'order': order_id, 'part': part_id, 'quantity': 10},
|
||||
)
|
||||
|
||||
# Should be three items now
|
||||
response = self.get(transfer_order_line_url, {'order': order_id})
|
||||
|
||||
self.assertEqual(len(response.data), 3)
|
||||
|
||||
for item in response.data:
|
||||
# Check that the line item has been created
|
||||
self.assertEqual(item['order'], order_id)
|
||||
|
||||
# Check that the line quantities are correct
|
||||
self.assertEqual(item['quantity'], 10)
|
||||
self.assertEqual(item['allocated'], 0)
|
||||
self.assertEqual(item['transferred'], 0)
|
||||
|
||||
# Initial API filters should return no results
|
||||
self.filter({'order': order_id, 'allocated': 1}, 0)
|
||||
self.filter({'order': order_id, 'completed': 1}, 0)
|
||||
|
||||
# issue the order
|
||||
order_issue_url = reverse('api-transfer-order-issue', kwargs={'pk': order.pk})
|
||||
self.post(order_issue_url, {}, expected_code=201)
|
||||
|
||||
# Next, allocate stock against 2 line items
|
||||
for item in parts[:2]:
|
||||
p = Part.objects.get(pk=item)
|
||||
s = StockItem.objects.create(part=p, quantity=100)
|
||||
l = models.TransferOrderLineItem.objects.filter(order=order, part=p).first()
|
||||
assert l
|
||||
|
||||
# Allocate against the API
|
||||
self.post(
|
||||
reverse('api-transfer-order-allocate', kwargs={'pk': order.pk}),
|
||||
{'items': [{'line_item': l.pk, 'stock_item': s.pk, 'quantity': 10}]},
|
||||
)
|
||||
|
||||
# Filter by 'fully allocated' status
|
||||
self.filter({'order': order_id, 'allocated': 1}, 2)
|
||||
self.filter({'order': order_id, 'allocated': 0}, 1)
|
||||
|
||||
self.filter({'order': order_id, 'completed': 1}, 0)
|
||||
self.filter({'order': order_id, 'completed': 0}, 3)
|
||||
|
||||
# Finally, attempt to transfer this line item
|
||||
# we have incomplete allocations, so must specify arg
|
||||
self.post(
|
||||
reverse('api-transfer-order-complete', kwargs={'pk': order.pk}),
|
||||
{'accept_incomplete_allocation': 'true'},
|
||||
)
|
||||
|
||||
# Filter by 'completed' status
|
||||
self.filter({'order': order_id, 'completed': 1}, 2)
|
||||
self.filter({'order': order_id, 'completed': 0}, 1)
|
||||
|
||||
def test_output_options(self):
|
||||
"""Test the various output options for the TransferOrderLineItem detail endpoint."""
|
||||
self.run_output_test(
|
||||
reverse('api-transfer-order-line-detail', kwargs={'pk': 1}),
|
||||
['part_detail', 'order_detail'],
|
||||
)
|
||||
|
||||
|
||||
class TransferOrderDownloadTest(OrderTest):
|
||||
"""Unit tests for downloading TransferOrder data via the API endpoint."""
|
||||
|
||||
def test_download_fail(self):
|
||||
"""Test that downloading without the 'export' option fails."""
|
||||
url = reverse('api-transfer-order-list')
|
||||
|
||||
response = self.export_data(url, export_plugin='no-plugin', expected_code=400)
|
||||
self.assertIn('is not a valid choice', str(response['export_plugin']))
|
||||
|
||||
def test_download_xlsx(self):
|
||||
"""Test xlsx file download."""
|
||||
url = reverse('api-transfer-order-list')
|
||||
|
||||
# Download .xls file
|
||||
with self.export_data(
|
||||
url, export_format='xlsx', expected_code=200, decode=False
|
||||
) as file:
|
||||
self.assertIsInstance(file, io.BytesIO)
|
||||
|
||||
def test_download_csv(self):
|
||||
"""Test that the list of transfer orders can be downloaded as a .csv file."""
|
||||
url = reverse('api-transfer-order-list')
|
||||
|
||||
required_cols = [
|
||||
'Line Items',
|
||||
'Completed Lines',
|
||||
'ID',
|
||||
'Reference',
|
||||
'Order Status',
|
||||
'Description',
|
||||
'Project Code',
|
||||
'Responsible',
|
||||
'Consume Stock',
|
||||
]
|
||||
|
||||
excluded_cols = ['metadata']
|
||||
|
||||
# Download .xls file
|
||||
with self.export_data(url, export_format='csv') as file:
|
||||
data = self.process_csv(
|
||||
file,
|
||||
required_cols=required_cols,
|
||||
excluded_cols=excluded_cols,
|
||||
required_rows=models.TransferOrder.objects.count(),
|
||||
)
|
||||
|
||||
for line in data:
|
||||
order = models.TransferOrder.objects.get(pk=line['ID'])
|
||||
|
||||
self.assertEqual(line['Description'], order.description)
|
||||
self.assertEqual(line['Order Status'], str(order.status))
|
||||
|
||||
# Download only outstanding transfer orders
|
||||
with self.export_data(url, {'outstanding': True}, export_format='tsv') as file:
|
||||
self.process_csv(
|
||||
file,
|
||||
required_cols=required_cols,
|
||||
excluded_cols=excluded_cols,
|
||||
required_rows=models.TransferOrder.objects.filter(
|
||||
status__in=TransferOrderStatusGroups.OPEN
|
||||
).count(),
|
||||
delimiter='\t',
|
||||
)
|
||||
|
||||
|
||||
class TransferOrderAllocateTest(OrderTest):
|
||||
"""Unit tests for allocating stock items against a TransferOrder."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""Init routine for this unit test class."""
|
||||
super().setUpTestData()
|
||||
|
||||
def setUp(self):
|
||||
"""Init routines for this unit testing class."""
|
||||
super().setUp()
|
||||
|
||||
self.assignRole('transfer_order.add')
|
||||
|
||||
self.url = reverse('api-transfer-order-allocate', kwargs={'pk': 1})
|
||||
self.url_serialized = reverse(
|
||||
'api-transfer-order-allocate-serials', kwargs={'pk': 1}
|
||||
)
|
||||
|
||||
self.order = models.TransferOrder.objects.get(pk=1)
|
||||
|
||||
# Create some line items for this transfer order
|
||||
parts = Part.objects.exclude(virtual=True)
|
||||
|
||||
for part in parts:
|
||||
# Create a new line item
|
||||
models.TransferOrderLineItem.objects.create(
|
||||
order=self.order, part=part, quantity=5
|
||||
)
|
||||
|
||||
# Ensure we have stock!
|
||||
StockItem.objects.create(part=part, quantity=100)
|
||||
|
||||
# Create a new shipment against this TransferOrder
|
||||
# self.shipment = models.TransferOrderShipment.objects.create(order=self.order)
|
||||
|
||||
def test_invalid(self):
|
||||
"""Test POST with invalid data."""
|
||||
# No data
|
||||
response = self.post(self.url, {}, expected_code=400)
|
||||
|
||||
self.assertIn('This field is required', str(response.data['items']))
|
||||
|
||||
# Test with a single line items
|
||||
line = self.order.lines.first()
|
||||
part = line.part
|
||||
|
||||
# Valid stock_item, but quantity is invalid
|
||||
data = {
|
||||
'items': [
|
||||
{
|
||||
'line_item': line.pk,
|
||||
'stock_item': part.stock_items.last().pk,
|
||||
'quantity': 0,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
response = self.post(self.url, data, expected_code=400)
|
||||
|
||||
self.assertIn('Quantity must be positive', str(response.data['items']))
|
||||
|
||||
# Valid stock item, too much quantity
|
||||
data['items'][0]['quantity'] = 250
|
||||
|
||||
response = self.post(self.url, data, expected_code=400)
|
||||
|
||||
self.assertIn('Available quantity (100) exceeded', str(response.data['items']))
|
||||
|
||||
def test_allocate(self):
|
||||
"""Test that the allocation endpoint acts as expected, when provided with valid data!"""
|
||||
# First, check that there are no line items allocated against this TransferOrder
|
||||
self.assertEqual(self.order.stock_allocations.count(), 0)
|
||||
|
||||
data = {'items': []}
|
||||
|
||||
for line in self.order.lines.all():
|
||||
for stock_item in line.part.stock_items.filter(quantity__gt=5):
|
||||
# Find a non-serialized stock item to allocate
|
||||
if not stock_item.serialized:
|
||||
break
|
||||
|
||||
# Fully-allocate each line
|
||||
data['items'].append({
|
||||
'line_item': line.pk,
|
||||
'stock_item': stock_item.pk,
|
||||
'quantity': 5,
|
||||
})
|
||||
|
||||
self.post(self.url, data, expected_code=201)
|
||||
|
||||
# There should have been 1 stock item allocated against each line item
|
||||
n_lines = self.order.lines.count()
|
||||
|
||||
self.assertEqual(self.order.stock_allocations.count(), n_lines)
|
||||
|
||||
for line in self.order.lines.all():
|
||||
self.assertEqual(line.allocations.count(), 1)
|
||||
|
||||
def test_allocate_serials(self):
|
||||
"""Test that the allocation endpoint acts as expected, when provided with serials."""
|
||||
self.assertEqual(self.order.stock_allocations.count(), 0)
|
||||
|
||||
trackable_lines = self.order.lines.filter(part__trackable=True)
|
||||
for line in trackable_lines:
|
||||
stock_item = (
|
||||
line.part.stock_items
|
||||
.exclude(serial=None)
|
||||
.filter(StockItem.IN_STOCK_FILTER)
|
||||
.first()
|
||||
)
|
||||
|
||||
# Allocate this serialized item to the transfer order
|
||||
data = {
|
||||
'line_item': line.pk,
|
||||
'quantity': 1,
|
||||
'serial_numbers': stock_item.serial,
|
||||
}
|
||||
|
||||
self.post(self.url_serialized, data, expected_code=201)
|
||||
|
||||
# There should have been 1 stock item allocated against each line item
|
||||
n_lines = trackable_lines.count()
|
||||
self.assertEqual(self.order.stock_allocations.count(), n_lines)
|
||||
|
||||
for line in trackable_lines.all():
|
||||
self.assertEqual(line.allocations.count(), 1)
|
||||
|
||||
def test_allocate_variant(self):
|
||||
"""Test that the allocation endpoint acts as expected, when provided with variant."""
|
||||
# First, check that there are no line items allocated against this TransferOrder
|
||||
self.assertEqual(self.order.stock_allocations.count(), 0)
|
||||
|
||||
data = {'items': []}
|
||||
|
||||
def check_template(line_item):
|
||||
return line_item.part.is_template
|
||||
|
||||
for line in filter(check_template, self.order.lines.all()):
|
||||
stock_item: Optional[StockItem] = None
|
||||
|
||||
stock_item = None
|
||||
|
||||
# Allocate a matching variant
|
||||
parts: list[Part] = (
|
||||
Part.objects
|
||||
.exclude(virtual=True)
|
||||
.exclude(is_template=True)
|
||||
.filter(variant_of=line.part.pk)
|
||||
)
|
||||
# if we don't have a matching variant, continue
|
||||
if not parts.exists():
|
||||
continue
|
||||
for part in parts:
|
||||
# ensure we have the quantity necessary to allocate
|
||||
if not part.stock_items.filter(quantity__gt=5).exists():
|
||||
continue
|
||||
|
||||
stock_item = part.stock_items.last()
|
||||
|
||||
for item in part.stock_items.filter(quantity__gt=5):
|
||||
if item.serialized:
|
||||
continue
|
||||
|
||||
stock_item = item
|
||||
break
|
||||
|
||||
if stock_item is not None:
|
||||
break
|
||||
|
||||
if stock_item is None:
|
||||
raise self.fail('No stock item found for part') # pragma: no cover
|
||||
|
||||
# Fully-allocate each line
|
||||
data['items'].append({
|
||||
'line_item': line.pk,
|
||||
'stock_item': stock_item.pk,
|
||||
'quantity': 5,
|
||||
})
|
||||
|
||||
self.post(self.url, data, expected_code=201)
|
||||
|
||||
# At least one item should be allocated, and all should be variants
|
||||
self.assertGreater(self.order.stock_allocations.count(), 0)
|
||||
for allocation in self.order.stock_allocations.all():
|
||||
self.assertNotEqual(allocation.item.part.pk, allocation.line.part.pk)
|
||||
|
||||
def test_output_options(self):
|
||||
"""Test the various output options for the SalesOrderAllocation detail endpoint."""
|
||||
self.run_output_test(
|
||||
reverse('api-transfer-order-allocation-list'),
|
||||
['part_detail', 'item_detail', 'order_detail', 'location_detail'],
|
||||
assert_subset=True,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,13 @@ def generate_next_return_order_reference():
|
|||
return ReturnOrder.generate_reference()
|
||||
|
||||
|
||||
def generate_next_transfer_order_reference():
|
||||
"""Generate the next available TransferOrder reference."""
|
||||
from order.models import TransferOrder
|
||||
|
||||
return TransferOrder.generate_reference()
|
||||
|
||||
|
||||
def validate_sales_order_reference_pattern(pattern):
|
||||
"""Validate the SalesOrder reference 'pattern' setting."""
|
||||
from order.models import SalesOrder
|
||||
|
|
@ -62,3 +69,17 @@ def validate_return_order_reference(value):
|
|||
from order.models import ReturnOrder
|
||||
|
||||
ReturnOrder.validate_reference_field(value)
|
||||
|
||||
|
||||
def validate_transfer_order_reference_pattern(pattern):
|
||||
"""Validate the TransferOrder reference 'pattern' setting."""
|
||||
from order.models import TransferOrder
|
||||
|
||||
TransferOrder.validate_reference_pattern(pattern)
|
||||
|
||||
|
||||
def validate_transfer_order_reference(value):
|
||||
"""Validate that the ReturnOrder reference field matches the required pattern."""
|
||||
from order.models import TransferOrder
|
||||
|
||||
TransferOrder.validate_reference_field(value)
|
||||
|
|
|
|||
|
|
@ -264,9 +264,12 @@ class CategoryDetail(CategoryMixin, OutputOptionsMixin, CustomRetrieveUpdateDest
|
|||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""Delete a Part category instance via the API."""
|
||||
delete_parts = str2bool(request.data.get('delete_parts', False))
|
||||
serializer = part_serializers.CategoryDeleteSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
delete_parts = str2bool(serializer.validated_data.get('delete_parts', False))
|
||||
delete_child_categories = str2bool(
|
||||
request.data.get('delete_child_categories', False)
|
||||
serializer.validated_data.get('delete_child_categories', False)
|
||||
)
|
||||
|
||||
return super().destroy(
|
||||
|
|
|
|||
|
|
@ -37,7 +37,11 @@ from sql_util.utils import SubquerySum
|
|||
import part.models
|
||||
import stock.models
|
||||
from build.status_codes import BuildStatusGroups
|
||||
from order.status_codes import PurchaseOrderStatusGroups, SalesOrderStatusGroups
|
||||
from order.status_codes import (
|
||||
PurchaseOrderStatusGroups,
|
||||
SalesOrderStatusGroups,
|
||||
TransferOrderStatusGroups,
|
||||
)
|
||||
|
||||
|
||||
def annotate_in_production_quantity(reference: str = '') -> QuerySet:
|
||||
|
|
@ -274,6 +278,40 @@ def annotate_sales_order_allocations(reference: str = '', location=None) -> Quer
|
|||
)
|
||||
|
||||
|
||||
def annotate_transfer_order_allocations(reference: str = '', location=None) -> QuerySet:
|
||||
"""Annotate the total quantity of each part allocated to transfer orders.
|
||||
|
||||
- This function calculates the total part quantity allocated to open transfer orders"
|
||||
- Finds all transfer order allocations for each part (using the provided filter)
|
||||
- Aggregates the 'allocated quantity' for each relevant transfer order allocation item
|
||||
|
||||
Arguments:
|
||||
reference: The relationship reference of the part from the current model
|
||||
location: If provided, only allocated stock items from this location are considered
|
||||
"""
|
||||
# Order filter only returns open orders
|
||||
order_filter = Q(line__order__status__in=TransferOrderStatusGroups.OPEN)
|
||||
|
||||
if location is not None:
|
||||
# Filter by location (including any child locations)
|
||||
|
||||
order_filter &= Q(
|
||||
item__location__tree_id=location.tree_id,
|
||||
item__location__lft__gte=location.lft,
|
||||
item__location__rght__lte=location.rght,
|
||||
item__location__level__gte=location.level,
|
||||
)
|
||||
|
||||
return Coalesce(
|
||||
SubquerySum(
|
||||
f'{reference}stock_items__transfer_order_allocations__quantity',
|
||||
filter=order_filter,
|
||||
),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
|
||||
|
||||
def variant_stock_query(reference: str = '', filter: Optional[Q] = None) -> QuerySet:
|
||||
"""Create a queryset to retrieve all stock items for variant parts under the specified part.
|
||||
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ from order.status_codes import (
|
|||
PurchaseOrderStatus,
|
||||
PurchaseOrderStatusGroups,
|
||||
SalesOrderStatusGroups,
|
||||
TransferOrderStatusGroups,
|
||||
)
|
||||
from stock import models as StockModels
|
||||
|
||||
|
|
@ -1769,8 +1770,50 @@ class Part(
|
|||
|
||||
return query['total']
|
||||
|
||||
def transfer_order_allocations(self, **kwargs):
|
||||
"""Return all transfer-order-allocation objects which allocate this part to a TransferOrder."""
|
||||
include_variants = kwargs.get('include_variants', True)
|
||||
|
||||
queryset = OrderModels.TransferOrderAllocation.objects.all()
|
||||
|
||||
if include_variants:
|
||||
# Include allocations for all variants
|
||||
variants = self.get_descendants(include_self=True)
|
||||
queryset = queryset.filter(item__part__in=variants)
|
||||
else:
|
||||
# Only look at this part
|
||||
queryset = queryset.filter(item__part=self)
|
||||
|
||||
# Default behaviour is to only return *pending* allocations
|
||||
pending = kwargs.get('pending', True)
|
||||
|
||||
if pending is True:
|
||||
# Look only for 'open' orders
|
||||
queryset = queryset.filter(
|
||||
line__order__status__in=TransferOrderStatusGroups.OPEN
|
||||
)
|
||||
elif pending is False:
|
||||
# Look only for 'closed' orders
|
||||
queryset = queryset.exclude(
|
||||
line__order__status__in=TransferOrderStatusGroups.OPEN
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
def transfer_order_allocation_count(self, **kwargs):
|
||||
"""Return the total quantity of this part allocated to transfer orders."""
|
||||
query = self.transfer_order_allocations(**kwargs).aggregate(
|
||||
total=Coalesce(
|
||||
Sum('quantity', output_field=models.DecimalField()),
|
||||
0,
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
return query['total']
|
||||
|
||||
def allocation_count(self, **kwargs):
|
||||
"""Return the total quantity of stock allocated for this part, against both build orders and sales orders."""
|
||||
"""Return the total quantity of stock allocated for this part, against build orders, sales orders, and transfer orders."""
|
||||
if self.id is None:
|
||||
# If this instance has not been saved, foreign-key lookups will fail
|
||||
return 0
|
||||
|
|
@ -1778,6 +1821,8 @@ class Part(
|
|||
return sum([
|
||||
self.build_order_allocation_count(**kwargs),
|
||||
self.sales_order_allocation_count(**kwargs),
|
||||
# For now, stock allocated to a transfer order will not impact its availability
|
||||
# self.transfer_order_allocation_count(**kwargs),
|
||||
])
|
||||
|
||||
def stock_entries(
|
||||
|
|
|
|||
|
|
@ -54,6 +54,27 @@ from .models import (
|
|||
logger = structlog.get_logger('inventree')
|
||||
|
||||
|
||||
class CategoryDeleteSerializer(serializers.Serializer):
|
||||
"""Serializer for deleting a PartCategory instance."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
fields = ['delete_child_categories', 'delete_parts']
|
||||
|
||||
delete_child_categories = serializers.BooleanField(
|
||||
label=_('Delete Subcategories'),
|
||||
help_text=_('Delete all sub-categories contained within this category'),
|
||||
required=True,
|
||||
)
|
||||
|
||||
delete_parts = serializers.BooleanField(
|
||||
label=_('Delete Parts'),
|
||||
help_text=_('Delete all parts contained within this category'),
|
||||
required=True,
|
||||
)
|
||||
|
||||
|
||||
@register_importer()
|
||||
class CategorySerializer(
|
||||
InvenTree.serializers.FilterableSerializerMixin,
|
||||
|
|
@ -714,6 +735,8 @@ class PartSerializer(
|
|||
ordering=part_filters.annotate_on_order_quantity(),
|
||||
in_stock=part_filters.annotate_total_stock(),
|
||||
allocated_to_sales_orders=part_filters.annotate_sales_order_allocations(),
|
||||
# NOTE: for now, decided that allocations to Transfer Orders don't reduce available stock
|
||||
# allocated_to_transfer_orders=part_filters.annotate_transfer_order_allocations(),
|
||||
allocated_to_build_orders=part_filters.annotate_build_order_allocations(),
|
||||
)
|
||||
|
||||
|
|
@ -738,6 +761,8 @@ class PartSerializer(
|
|||
ExpressionWrapper(
|
||||
F('total_in_stock')
|
||||
- F('allocated_to_sales_orders')
|
||||
# NOTE: for now, decided that allocations to Transfer Orders don't reduce available stock
|
||||
# - F('allocated_to_transfer_orders'),
|
||||
- F('allocated_to_build_orders'),
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
|
|
@ -747,6 +772,8 @@ class PartSerializer(
|
|||
)
|
||||
|
||||
# Annotate with the total 'required for builds' quantity
|
||||
# NOTE: for now, we don't consider transfer orders for required quantities
|
||||
# and they are assumed to operate on stock that already exists.
|
||||
queryset = queryset.annotate(
|
||||
required_for_build_orders=part_filters.annotate_build_order_requirements(),
|
||||
required_for_sales_orders=part_filters.annotate_sales_order_requirements(),
|
||||
|
|
@ -1886,6 +1913,7 @@ class BomItemSerializer(
|
|||
'sub_part__stock_items',
|
||||
'sub_part__stock_items__allocations',
|
||||
'sub_part__stock_items__sales_order_allocations',
|
||||
'sub_part__stock_items__transfer_order_allocations',
|
||||
)
|
||||
|
||||
# Annotate with the 'total pricing' information based on unit pricing and quantity
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import os
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from enum import IntEnum
|
||||
from random import randint
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
|
@ -331,116 +330,94 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
|
|||
|
||||
def test_category_delete(self):
|
||||
"""Test category deletion with different parameters."""
|
||||
|
||||
class Target(IntEnum):
|
||||
move_subcategories_to_parent_move_parts_to_parent = (0,)
|
||||
move_subcategories_to_parent_delete_parts = (1,)
|
||||
delete_subcategories_move_parts_to_parent = (2,)
|
||||
delete_subcategories_delete_parts = (3,)
|
||||
|
||||
for i in range(4):
|
||||
delete_child_categories: bool = False
|
||||
delete_parts: bool = False
|
||||
|
||||
if i in (
|
||||
Target.move_subcategories_to_parent_delete_parts,
|
||||
Target.delete_subcategories_delete_parts,
|
||||
):
|
||||
delete_parts = True
|
||||
if i in (
|
||||
Target.delete_subcategories_move_parts_to_parent,
|
||||
Target.delete_subcategories_delete_parts,
|
||||
):
|
||||
delete_child_categories = True
|
||||
|
||||
# Create a parent category
|
||||
parent_category = PartCategory.objects.create(
|
||||
name='Parent category',
|
||||
description='This is the parent category where the child categories and parts are moved to',
|
||||
parent=None,
|
||||
)
|
||||
|
||||
category_count_before = PartCategory.objects.count()
|
||||
part_count_before = Part.objects.count()
|
||||
|
||||
# Create a category to delete
|
||||
cat_to_delete = PartCategory.objects.create(
|
||||
name='Category to delete',
|
||||
description='This is the category to be deleted',
|
||||
parent=parent_category,
|
||||
)
|
||||
|
||||
url = reverse('api-part-category-detail', kwargs={'pk': cat_to_delete.id})
|
||||
|
||||
parts = []
|
||||
# Create parts in the category to be deleted
|
||||
for jj in range(3):
|
||||
parts.append(
|
||||
Part.objects.create(
|
||||
name=f'Part xyz {i}_{jj}',
|
||||
description='Child part of the deleted category',
|
||||
category=cat_to_delete,
|
||||
)
|
||||
for delete_child_categories in [False, True]:
|
||||
for delete_parts in [False, True]:
|
||||
# Create a parent category
|
||||
parent_category = PartCategory.objects.create(
|
||||
name='Parent category',
|
||||
description='This is the parent category where the child categories and parts are moved to',
|
||||
parent=None,
|
||||
)
|
||||
|
||||
child_categories = []
|
||||
child_categories_parts = []
|
||||
# Create child categories under the category to be deleted
|
||||
for ii in range(3):
|
||||
child = PartCategory.objects.create(
|
||||
name=f'Child parent_cat {i}_{ii}',
|
||||
description='A child category of the deleted category',
|
||||
parent=cat_to_delete,
|
||||
)
|
||||
child_categories.append(child)
|
||||
category_count_before = PartCategory.objects.count()
|
||||
|
||||
# Create parts in the child categories
|
||||
# Create a category to delete
|
||||
cat_to_delete = PartCategory.objects.create(
|
||||
name='Category to delete',
|
||||
description='This is the category to be deleted',
|
||||
parent=parent_category,
|
||||
)
|
||||
|
||||
url = reverse(
|
||||
'api-part-category-detail', kwargs={'pk': cat_to_delete.id}
|
||||
)
|
||||
|
||||
parts = []
|
||||
# Create parts in the category to be deleted
|
||||
for jj in range(3):
|
||||
child_categories_parts.append(
|
||||
parts.append(
|
||||
Part.objects.create(
|
||||
name=f'Part xyz {i}_{jj}_{ii}',
|
||||
description='Child part in the child category of the deleted category',
|
||||
category=child,
|
||||
name=f'Part {"A" if delete_child_categories else "B"}{"C" if delete_parts else "D"}-{jj}',
|
||||
description='Child part of the deleted category',
|
||||
category=cat_to_delete,
|
||||
)
|
||||
)
|
||||
|
||||
# Delete the created category (sub categories and their parts will be moved under the parent)
|
||||
params = {}
|
||||
if delete_parts:
|
||||
params['delete_parts'] = '1'
|
||||
if delete_child_categories:
|
||||
params['delete_child_categories'] = '1'
|
||||
self.delete(url, params, expected_code=204)
|
||||
|
||||
if delete_parts:
|
||||
if i == Target.delete_subcategories_delete_parts:
|
||||
# Check if all parts deleted
|
||||
self.assertEqual(Part.objects.count(), part_count_before)
|
||||
elif i == Target.move_subcategories_to_parent_delete_parts:
|
||||
# Check if all parts deleted
|
||||
self.assertEqual(
|
||||
Part.objects.count(),
|
||||
part_count_before + len(child_categories_parts),
|
||||
child_categories = []
|
||||
child_categories_parts = []
|
||||
# Create child categories under the category to be deleted
|
||||
for ii in range(3):
|
||||
child = PartCategory.objects.create(
|
||||
name=f'Child parent_cat {ii}',
|
||||
description='A child category of the deleted category',
|
||||
parent=cat_to_delete,
|
||||
)
|
||||
else:
|
||||
# parts moved to the parent category
|
||||
for part in parts:
|
||||
part.refresh_from_db()
|
||||
self.assertEqual(part.category, parent_category)
|
||||
child_categories.append(child)
|
||||
|
||||
if delete_child_categories:
|
||||
for part in child_categories_parts:
|
||||
# Create parts in the child categories
|
||||
for jj in range(3):
|
||||
child_categories_parts.append(
|
||||
Part.objects.create(
|
||||
name=f'Part xyz {jj}_{ii}-{"E" if delete_child_categories else "F"}{"G" if delete_parts else "H"}',
|
||||
description='Child part in the child category of the deleted category',
|
||||
category=child,
|
||||
)
|
||||
)
|
||||
|
||||
# Delete the created category (sub categories and their parts will be moved under the parent)
|
||||
params = {
|
||||
'delete_parts': delete_parts,
|
||||
'delete_child_categories': delete_child_categories,
|
||||
}
|
||||
|
||||
self.delete(url, params, expected_code=204)
|
||||
|
||||
if delete_parts:
|
||||
# Check if all parts deleted
|
||||
for p in parts:
|
||||
with self.assertRaises(Part.DoesNotExist):
|
||||
p.refresh_from_db()
|
||||
else:
|
||||
# parts moved to the parent category
|
||||
for part in parts:
|
||||
part.refresh_from_db()
|
||||
self.assertEqual(part.category, parent_category)
|
||||
|
||||
if delete_child_categories:
|
||||
# Check if all categories are deleted
|
||||
self.assertEqual(PartCategory.objects.count(), category_count_before)
|
||||
else:
|
||||
# Check if all subcategories to parent moved to parent and all parts deleted
|
||||
for child in child_categories:
|
||||
child.refresh_from_db()
|
||||
self.assertEqual(child.parent, parent_category)
|
||||
if delete_child_categories:
|
||||
for part in child_categories_parts:
|
||||
part.refresh_from_db()
|
||||
self.assertEqual(part.category, parent_category)
|
||||
|
||||
if delete_child_categories:
|
||||
# Check if all categories are deleted
|
||||
self.assertEqual(
|
||||
PartCategory.objects.count(), category_count_before
|
||||
)
|
||||
else:
|
||||
# Check if all subcategories to parent moved to parent and all parts deleted
|
||||
for child in child_categories:
|
||||
child.refresh_from_db()
|
||||
self.assertEqual(child.parent, parent_category)
|
||||
|
||||
def test_structural(self):
|
||||
"""Test the effectiveness of structural categories.
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ FeatureType = Literal[
|
|||
'template_editor', # Custom template editor
|
||||
'template_preview', # Custom template preview
|
||||
'navigation', # Custom navigation items
|
||||
'primary_action', # Custom primary action buttons
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -106,6 +107,7 @@ class UserInterfaceMixin:
|
|||
'panel': self.get_ui_panels,
|
||||
'template_editor': self.get_ui_template_editors,
|
||||
'template_preview': self.get_ui_template_previews,
|
||||
'primary_action': self.get_ui_primary_actions,
|
||||
}
|
||||
|
||||
if feature_type in feature_map:
|
||||
|
|
@ -203,3 +205,29 @@ class UserInterfaceMixin:
|
|||
"""
|
||||
# Default implementation returns an empty list
|
||||
return []
|
||||
|
||||
def get_ui_primary_actions(
|
||||
self, request: Request, context: dict, **kwargs
|
||||
) -> list[UIFeature]:
|
||||
"""Return a list of custom primary action buttons to be injected into the UI.
|
||||
|
||||
Args:
|
||||
request: HTTPRequest object (including user information)
|
||||
context: Additional context data provided by the UI (query parameters)
|
||||
|
||||
Returns:
|
||||
list: A list of custom primary action buttons to be injected into the UI
|
||||
"""
|
||||
# Sample code to render conditional primary action button based on context
|
||||
# items = []
|
||||
# if self.my_assert_function(context):
|
||||
# items.append({
|
||||
# 'key': 'sample-primary-action',
|
||||
# 'title': 'Sample Primary Action',
|
||||
# 'icon': 'ti:plus:outline',
|
||||
# 'options': {'url': '/core/sample-primary-action/', 'color': 'orange'},
|
||||
# })
|
||||
# return items
|
||||
|
||||
# Default implementation returns an empty list
|
||||
return []
|
||||
|
|
|
|||
|
|
@ -233,3 +233,13 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
|
|||
self.assertEqual(response.data[0]['plugin_name'], 'sampleui')
|
||||
self.assertEqual(response.data[0]['key'], 'sample-nav-item')
|
||||
self.assertEqual(response.data[0]['title'], 'Sample Nav Item')
|
||||
|
||||
def test_ui_primary_actions(self):
|
||||
"""Test that the sample UI plugin provides custom primary actions."""
|
||||
response = self.get(
|
||||
reverse('api-plugin-ui-feature-list', kwargs={'feature': 'primary_action'})
|
||||
)
|
||||
self.assertEqual(1, len(response.data))
|
||||
self.assertEqual(response.data[0]['plugin_name'], 'sampleui')
|
||||
self.assertEqual(response.data[0]['key'], 'sample-primary-action')
|
||||
self.assertEqual(response.data[0]['title'], 'Sample Primary Action')
|
||||
|
|
|
|||
|
|
@ -226,6 +226,17 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
|
|||
}
|
||||
]
|
||||
|
||||
def get_ui_primary_actions(self, request, context, **kwargs):
|
||||
"""Return a list of custom primary action buttons."""
|
||||
return [
|
||||
{
|
||||
'key': 'sample-primary-action',
|
||||
'title': 'Sample Primary Action',
|
||||
'icon': 'ti:plus:outline',
|
||||
'options': {'url': '/core/sample-primary-action/', 'color': 'orange'},
|
||||
}
|
||||
]
|
||||
|
||||
def get_admin_context(self) -> dict:
|
||||
"""Return custom context data which can be rendered in the admin panel."""
|
||||
return {'apple': 'banana', 'foo': 'bar', 'hello': 'world'}
|
||||
|
|
|
|||
|
|
@ -229,6 +229,13 @@ class ReportConfig(AppConfig):
|
|||
'model_type': 'returnorder',
|
||||
'filename_pattern': 'ReturnOrder-{{ reference }}.pdf',
|
||||
},
|
||||
{
|
||||
'file': 'inventree_transfer_order_report.html',
|
||||
'name': 'InvenTree Transfer Order',
|
||||
'description': 'Sample transfer order report',
|
||||
'model_type': 'transferorder',
|
||||
'filename_pattern': 'TransferOrder-{{ reference }}.pdf',
|
||||
},
|
||||
{
|
||||
'file': 'inventree_test_report.html',
|
||||
'name': 'InvenTree Test Report',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
{% extends "report/inventree_order_report_base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load report %}
|
||||
{% load barcode %}
|
||||
{% load inventree_extras %}
|
||||
{% load markdownify %}
|
||||
|
||||
{% block header_content %}
|
||||
|
||||
<div class='header-right'>
|
||||
<h3>{% trans "Transfer Order" %} {{ prefix }}{{ reference }}</h3>
|
||||
{{ order.take_from.pathstring }} → {{ order.destination.pathstring }}
|
||||
</div>
|
||||
|
||||
{% endblock header_content %}
|
||||
|
||||
{% block page_content %}
|
||||
|
||||
<h3>{% trans "Line Items" %}</h3>
|
||||
|
||||
<table class='table table-striped table-condensed'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Part" %}</th>
|
||||
<th>{% trans "Reference" %}</th>
|
||||
<th>{% trans "Quantity" %}</th>
|
||||
<th>{% trans "Transferred" %}</th>
|
||||
<th>{% trans "Note" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for line in lines.all %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class='thumb-container'>
|
||||
<img src='{% part_image line.part height=240 %}' alt='{% trans "Part image" %}' class='part-thumb'>
|
||||
</div>
|
||||
<div class='part-text'>
|
||||
{{ line.part.full_name }}
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ line.reference }}</td>
|
||||
<td>{% decimal line.quantity %}</td>
|
||||
<td>{% decimal line.transferred %}</td>
|
||||
<td>{{ line.notes }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% endblock page_content %}
|
||||
|
|
@ -53,11 +53,12 @@ from InvenTree.mixins import (
|
|||
RetrieveUpdateDestroyAPI,
|
||||
SerializerContextMixin,
|
||||
)
|
||||
from order.models import PurchaseOrder, ReturnOrder, SalesOrder
|
||||
from order.models import PurchaseOrder, ReturnOrder, SalesOrder, TransferOrder
|
||||
from order.serializers import (
|
||||
PurchaseOrderSerializer,
|
||||
ReturnOrderSerializer,
|
||||
SalesOrderSerializer,
|
||||
TransferOrderSerializer,
|
||||
)
|
||||
from part.models import BomItem, Part, PartCategory
|
||||
from part.serializers import PartBriefSerializer
|
||||
|
|
@ -430,11 +431,15 @@ class StockLocationDetail(
|
|||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""Delete a Stock location instance via the API."""
|
||||
serializer = StockSerializers.LocationDeleteSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
delete_stock_items = InvenTree.helpers.str2bool(
|
||||
request.data.get('delete_stock_items', False)
|
||||
serializer.validated_data.get('delete_stock_items', False)
|
||||
)
|
||||
|
||||
delete_sub_locations = InvenTree.helpers.str2bool(
|
||||
request.data.get('delete_sub_locations', False)
|
||||
serializer.validated_data.get('delete_sub_locations', False)
|
||||
)
|
||||
|
||||
return super().destroy(
|
||||
|
|
@ -668,13 +673,17 @@ class StockFilter(FilterSet):
|
|||
def filter_allocated(self, queryset, name, value):
|
||||
"""Filter by whether or not the stock item is 'allocated'."""
|
||||
if str2bool(value):
|
||||
# Filter StockItem with either build allocations or sales order allocations
|
||||
# Filter StockItem with either build allocations or transfer order allocations or sales order allocations
|
||||
return queryset.filter(
|
||||
Q(sales_order_allocations__isnull=False) | Q(allocations__isnull=False)
|
||||
Q(sales_order_allocations__isnull=False)
|
||||
| Q(transfer_order_allocations__isnull=False)
|
||||
| Q(allocations__isnull=False)
|
||||
).distinct()
|
||||
# Filter StockItem without build allocations or sales order allocations
|
||||
# Filter StockItem without build allocations or transfer order allocations or sales order allocations
|
||||
return queryset.filter(
|
||||
Q(sales_order_allocations__isnull=True) & Q(allocations__isnull=True)
|
||||
Q(sales_order_allocations__isnull=True)
|
||||
& Q(transfer_order_allocations__isnull=True)
|
||||
& Q(allocations__isnull=True)
|
||||
)
|
||||
|
||||
expired = rest_filters.BooleanFilter(label='Expired', method='filter_expired')
|
||||
|
|
@ -1580,6 +1589,7 @@ class StockTrackingList(
|
|||
'purchaseorder': (PurchaseOrder, PurchaseOrderSerializer),
|
||||
'salesorder': (SalesOrder, SalesOrderSerializer),
|
||||
'returnorder': (ReturnOrder, ReturnOrderSerializer),
|
||||
'transferorder': (TransferOrder, TransferOrderSerializer),
|
||||
'buildorder': (Build, BuildSerializer),
|
||||
'item': (StockItem, StockSerializers.StockItemSerializer),
|
||||
'stockitem': (StockItem, StockSerializers.StockItemSerializer),
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ from InvenTree.status_codes import (
|
|||
StockStatus,
|
||||
StockStatusGroups,
|
||||
)
|
||||
from order.status_codes import TransferOrderStatusGroups
|
||||
from part import models as PartModels
|
||||
from plugin.events import trigger_event
|
||||
from stock.events import StockEvents
|
||||
|
|
@ -1536,7 +1537,7 @@ class StockItem(
|
|||
item.save(add_note=False)
|
||||
|
||||
def is_allocated(self):
|
||||
"""Return True if this StockItem is allocated to a SalesOrder or a Build."""
|
||||
"""Return True if this StockItem is allocated to a SalesOrder, TransferOrder, or a Build."""
|
||||
return self.allocation_count() > 0
|
||||
|
||||
def build_allocation_count(self, **kwargs):
|
||||
|
|
@ -1596,12 +1597,48 @@ class StockItem(
|
|||
|
||||
return total
|
||||
|
||||
def get_transfer_order_allocations(self, active=True, **kwargs):
|
||||
"""Return a queryset for TransferOrderAllocations against this StockItem, with optional filters.
|
||||
|
||||
Arguments:
|
||||
active: Filter by 'active' status of the allocation
|
||||
"""
|
||||
query = self.transfer_order_allocations.all()
|
||||
|
||||
if filter_allocations := kwargs.get('filter_allocations'):
|
||||
query = query.filter(**filter_allocations)
|
||||
|
||||
if exclude_allocations := kwargs.get('exclude_allocations'):
|
||||
query = query.exclude(**exclude_allocations)
|
||||
|
||||
if active is True:
|
||||
query = query.filter(line__order__status__in=TransferOrderStatusGroups.OPEN)
|
||||
elif active is False:
|
||||
query = query.exclude(
|
||||
line__order__status__in=TransferOrderStatusGroups.OPEN
|
||||
)
|
||||
|
||||
return query
|
||||
|
||||
def transfer_order_allocation_count(self, active=True, **kwargs):
|
||||
"""Return the total quantity allocated to TransferOrders."""
|
||||
query = self.get_transfer_order_allocations(active=active, **kwargs)
|
||||
query = query.aggregate(q=Coalesce(Sum('quantity'), Decimal(0)))
|
||||
|
||||
total = query['q']
|
||||
|
||||
if total is None:
|
||||
total = Decimal(0)
|
||||
|
||||
return total
|
||||
|
||||
def allocation_count(self):
|
||||
"""Return the total quantity allocated to builds or orders."""
|
||||
bo = self.build_allocation_count()
|
||||
so = self.sales_order_allocation_count()
|
||||
to = self.transfer_order_allocation_count()
|
||||
|
||||
return bo + so
|
||||
return bo + so + to
|
||||
|
||||
def unallocated_quantity(self):
|
||||
"""Return the quantity of this StockItem which is *not* allocated."""
|
||||
|
|
@ -2331,6 +2368,10 @@ class StockItem(
|
|||
|
||||
deltas = {'stockitem': self.pk}
|
||||
|
||||
transferorder = kwargs.pop('transferorder', None)
|
||||
if transferorder:
|
||||
deltas['transferorder'] = transferorder.pk
|
||||
|
||||
# Optional fields which can be supplied in a 'move' call
|
||||
for field in StockItem.optional_transfer_fields():
|
||||
if field in kwargs:
|
||||
|
|
@ -2479,6 +2520,10 @@ class StockItem(
|
|||
)
|
||||
tracking_info['old_status_logical'] = old_status_logical
|
||||
|
||||
transferorder = kwargs.pop('transferorder', None)
|
||||
if transferorder:
|
||||
tracking_info['transferorder'] = transferorder.pk
|
||||
|
||||
# Optional fields which can be supplied in a 'move' call
|
||||
for field in StockItem.optional_transfer_fields():
|
||||
if field in kwargs:
|
||||
|
|
@ -2718,6 +2763,10 @@ class StockItem(
|
|||
setattr(self, field, kwargs[field])
|
||||
deltas[field] = kwargs[field]
|
||||
|
||||
transferorder = kwargs.pop('transferorder', None)
|
||||
if transferorder:
|
||||
deltas['transferorder'] = transferorder.pk
|
||||
|
||||
self.save(add_note=False)
|
||||
|
||||
self.add_tracking_entry(
|
||||
|
|
|
|||
|
|
@ -520,6 +520,8 @@ class StockItemSerializer(
|
|||
allocated=Coalesce(
|
||||
SubquerySum('sales_order_allocations__quantity'), Decimal(0)
|
||||
)
|
||||
# For now, stock allocated to a transfer order will not impact its availability
|
||||
# + Coalesce(SubquerySum('transfer_order_allocations__quantity'), Decimal(0))
|
||||
+ Coalesce(SubquerySum('allocations__quantity'), Decimal(0))
|
||||
)
|
||||
|
||||
|
|
@ -1167,6 +1169,27 @@ class LocationTreeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
|||
return queryset.annotate(sublocations=stock.filters.annotate_sub_locations())
|
||||
|
||||
|
||||
class LocationDeleteSerializer(serializers.Serializer):
|
||||
"""Serializer for deleting a stock location."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
fields = ['delete_stock_items', 'delete_sub_locations']
|
||||
|
||||
delete_stock_items = serializers.BooleanField(
|
||||
required=True,
|
||||
label=_('Delete Stock Items'),
|
||||
help_text=_('Delete all stock items contained within this location'),
|
||||
)
|
||||
|
||||
delete_sub_locations = serializers.BooleanField(
|
||||
required=True,
|
||||
label=_('Delete Sublocations'),
|
||||
help_text=_('Delete all sub-locations contained within this location'),
|
||||
)
|
||||
|
||||
|
||||
@register_importer()
|
||||
class LocationSerializer(
|
||||
InvenTree.serializers.FilterableSerializerMixin,
|
||||
|
|
@ -1369,6 +1392,10 @@ class StockAssignmentItemSerializer(serializers.Serializer):
|
|||
if item.sales_order_allocations.count() > 0:
|
||||
raise ValidationError(_('Item is allocated to a sales order'))
|
||||
|
||||
# The item must not be allocated to a transfer order
|
||||
if item.transfer_order_allocations.count() > 0:
|
||||
raise ValidationError(_('Item is allocated to a transfer order'))
|
||||
|
||||
# The item must not be allocated to a build order
|
||||
if item.allocations.count() > 0:
|
||||
raise ValidationError(_('Item is allocated to a build order'))
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import os
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from enum import IntEnum
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
|
|
@ -142,13 +141,6 @@ class StockLocationTest(StockAPITestCase):
|
|||
|
||||
def test_stock_location_delete(self):
|
||||
"""Test stock location deletion with different parameters."""
|
||||
|
||||
class Target(IntEnum):
|
||||
move_sub_locations_to_parent_move_stockitems_to_parent = (0,)
|
||||
move_sub_locations_to_parent_delete_stockitems = (1,)
|
||||
delete_sub_locations_move_stockitems_to_parent = (2,)
|
||||
delete_sub_locations_delete_stockitems = (3,)
|
||||
|
||||
# First, construct a set of template / variant parts
|
||||
part = Part.objects.create(
|
||||
name='Part for stock item creation',
|
||||
|
|
@ -157,117 +149,95 @@ class StockLocationTest(StockAPITestCase):
|
|||
is_template=False,
|
||||
)
|
||||
|
||||
for i in range(4):
|
||||
delete_sub_locations: bool = False
|
||||
delete_stock_items: bool = False
|
||||
|
||||
if i in (
|
||||
Target.move_sub_locations_to_parent_delete_stockitems,
|
||||
Target.delete_sub_locations_delete_stockitems,
|
||||
):
|
||||
delete_stock_items = True
|
||||
if i in (
|
||||
Target.delete_sub_locations_move_stockitems_to_parent,
|
||||
Target.delete_sub_locations_delete_stockitems,
|
||||
):
|
||||
delete_sub_locations = True
|
||||
|
||||
# Create a parent stock location
|
||||
parent_stock_location = StockLocation.objects.create(
|
||||
name='Parent stock location',
|
||||
description='This is the parent stock location where the sub categories and stock items are moved to',
|
||||
parent=None,
|
||||
)
|
||||
|
||||
stocklocation_count_before = StockLocation.objects.count()
|
||||
stock_location_count_before = StockItem.objects.count()
|
||||
|
||||
# Create a stock location to be deleted
|
||||
stock_location_to_delete = StockLocation.objects.create(
|
||||
name='Stock location to delete',
|
||||
description='This is the stock location to be deleted',
|
||||
parent=parent_stock_location,
|
||||
)
|
||||
|
||||
url = reverse(
|
||||
'api-location-detail', kwargs={'pk': stock_location_to_delete.id}
|
||||
)
|
||||
|
||||
stock_items = []
|
||||
# Create stock items in the location to be deleted
|
||||
for jj in range(3):
|
||||
stock_items.append(
|
||||
StockItem.objects.create(
|
||||
batch=f'Batch xyz {jj}',
|
||||
location=stock_location_to_delete,
|
||||
part=part,
|
||||
)
|
||||
for delete_sub_locations in [False, True]:
|
||||
for delete_stock_items in [False, True]:
|
||||
# Create a parent stock location
|
||||
parent_stock_location = StockLocation.objects.create(
|
||||
name='Parent stock location',
|
||||
description='This is the parent stock location where the sub categories and stock items are moved to',
|
||||
parent=None,
|
||||
)
|
||||
|
||||
child_stock_locations = []
|
||||
child_stock_locations_items = []
|
||||
# Create sub location under the stock location to be deleted
|
||||
for ii in range(3):
|
||||
child = StockLocation.objects.create(
|
||||
name=f'Sub-location {ii}',
|
||||
description='A sub-location of the deleted stock location',
|
||||
parent=stock_location_to_delete,
|
||||
)
|
||||
child_stock_locations.append(child)
|
||||
location_count_before = StockLocation.objects.count()
|
||||
item_count_before = StockItem.objects.count()
|
||||
|
||||
# Create stock items in the sub locations
|
||||
# Create a stock location to be deleted
|
||||
location_to_delete = StockLocation.objects.create(
|
||||
name='Stock location to delete',
|
||||
description='This is the stock location to be deleted',
|
||||
parent=parent_stock_location,
|
||||
)
|
||||
|
||||
url = reverse(
|
||||
'api-location-detail', kwargs={'pk': location_to_delete.id}
|
||||
)
|
||||
|
||||
stock_items = []
|
||||
|
||||
# Create stock items in the location to be deleted
|
||||
for jj in range(3):
|
||||
child_stock_locations_items.append(
|
||||
stock_items.append(
|
||||
StockItem.objects.create(
|
||||
batch=f'B xyz {jj}', part=part, location=child
|
||||
batch=f'Batch xyz {jj}',
|
||||
location=location_to_delete,
|
||||
part=part,
|
||||
)
|
||||
)
|
||||
|
||||
# Delete the created stock location
|
||||
params = {}
|
||||
if delete_stock_items:
|
||||
params['delete_stock_items'] = '1'
|
||||
if delete_sub_locations:
|
||||
params['delete_sub_locations'] = '1'
|
||||
response = self.delete(url, params, expected_code=204)
|
||||
child_locations = []
|
||||
child_locations_items = []
|
||||
|
||||
self.assertEqual(response.status_code, 204)
|
||||
# Create sub location under the stock location to be deleted
|
||||
for ii in range(3):
|
||||
child = StockLocation.objects.create(
|
||||
name=f'Sub-location {ii}',
|
||||
description='A sub-location of the deleted stock location',
|
||||
parent=location_to_delete,
|
||||
)
|
||||
child_locations.append(child)
|
||||
|
||||
if delete_stock_items:
|
||||
if i == Target.delete_sub_locations_delete_stockitems:
|
||||
# Check if all sub-categories deleted
|
||||
# Create stock items in the sub locations
|
||||
for jj in range(3):
|
||||
child_locations_items.append(
|
||||
StockItem.objects.create(
|
||||
batch=f'B xyz {jj}', part=part, location=child
|
||||
)
|
||||
)
|
||||
|
||||
# Delete the created stock location
|
||||
params = {
|
||||
'delete_stock_items': delete_stock_items,
|
||||
'delete_sub_locations': delete_sub_locations,
|
||||
}
|
||||
|
||||
response = self.delete(url, data=params, expected_code=204)
|
||||
|
||||
self.assertEqual(response.status_code, 204)
|
||||
|
||||
# If we were deleting stock items, the count must not have changed
|
||||
if delete_stock_items:
|
||||
extra_items = 0 if delete_sub_locations else 9
|
||||
self.assertEqual(
|
||||
StockItem.objects.count(), stock_location_count_before
|
||||
StockItem.objects.count(), item_count_before + extra_items
|
||||
)
|
||||
elif i == Target.move_sub_locations_to_parent_delete_stockitems:
|
||||
# Check if all stock locations deleted
|
||||
self.assertEqual(
|
||||
StockItem.objects.count(),
|
||||
stock_location_count_before + len(child_stock_locations_items),
|
||||
)
|
||||
else:
|
||||
# Stock locations moved to the parent location
|
||||
for stock_item in stock_items:
|
||||
stock_item.refresh_from_db()
|
||||
self.assertEqual(stock_item.location, parent_stock_location)
|
||||
else:
|
||||
# Stock items moved to the parent location
|
||||
self.assertGreater(StockItem.objects.count(), item_count_before)
|
||||
|
||||
for stock_item in stock_items:
|
||||
stock_item.refresh_from_db()
|
||||
self.assertEqual(stock_item.location, parent_stock_location)
|
||||
|
||||
if delete_sub_locations:
|
||||
for child_stock_location_item in child_stock_locations_items:
|
||||
child_stock_location_item.refresh_from_db()
|
||||
self.assertEqual(
|
||||
child_stock_location_item.location, parent_stock_location
|
||||
)
|
||||
|
||||
if delete_sub_locations:
|
||||
# Check if all sub-locations are deleted
|
||||
self.assertEqual(
|
||||
StockLocation.objects.count(), stocklocation_count_before
|
||||
)
|
||||
else:
|
||||
# Check if all sub-locations moved to the parent
|
||||
for child in child_stock_locations:
|
||||
child.refresh_from_db()
|
||||
self.assertEqual(child.parent, parent_stock_location)
|
||||
# Check if all sub-categories deleted
|
||||
self.assertEqual(
|
||||
StockLocation.objects.count(), location_count_before
|
||||
)
|
||||
else:
|
||||
# Check if all sub-categories moved to the parent category
|
||||
for location in child_locations:
|
||||
location.refresh_from_db()
|
||||
self.assertEqual(location.parent, parent_stock_location)
|
||||
|
||||
def test_output_options(self):
|
||||
"""Test output options."""
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ from InvenTree.mixins import (
|
|||
SerializerContextMixin,
|
||||
UpdateAPI,
|
||||
)
|
||||
from InvenTree.schema import exclude_from_schema
|
||||
from InvenTree.settings import FRONTEND_URL_BASE
|
||||
from users.models import ApiToken, Owner, RuleSet, UserProfile
|
||||
from users.serializers import (
|
||||
|
|
@ -501,8 +502,38 @@ class UserProfileDetail(RetrieveUpdateAPI):
|
|||
|
||||
|
||||
user_urls = [
|
||||
path('roles/', RoleDetails.as_view(), name='api-user-roles'),
|
||||
path('token/', ensure_csrf_cookie(GetAuthToken.as_view()), name='api-token'),
|
||||
# Legacy endpoints (to avoid breaking existing API clients)
|
||||
# TODO @matmair - remove these legacy endpoints in the next breaking release
|
||||
path(
|
||||
'roles/',
|
||||
exclude_from_schema(RoleDetails, '/api/user/me/roles/').as_view(),
|
||||
name='api-user-roles_legacy',
|
||||
),
|
||||
path(
|
||||
'token/',
|
||||
ensure_csrf_cookie(
|
||||
exclude_from_schema(GetAuthToken, '/api/user/me/token/').as_view()
|
||||
),
|
||||
name='api-token_legacy',
|
||||
),
|
||||
path(
|
||||
'profile/',
|
||||
exclude_from_schema(UserProfileDetail, '/api/user/me/profile/').as_view(),
|
||||
name='api-user-profile_legacy',
|
||||
),
|
||||
# Individual user endpoints
|
||||
path(
|
||||
'me/',
|
||||
include([
|
||||
path('profile/', UserProfileDetail.as_view(), name='api-user-profile'),
|
||||
path('roles/', RoleDetails.as_view(), name='api-user-roles'),
|
||||
path(
|
||||
'token/', ensure_csrf_cookie(GetAuthToken.as_view()), name='api-token'
|
||||
),
|
||||
path('', MeUserDetail.as_view(), name='api-user-me'),
|
||||
]),
|
||||
),
|
||||
# User related endpoints
|
||||
path(
|
||||
'tokens/',
|
||||
include([
|
||||
|
|
@ -510,8 +541,6 @@ user_urls = [
|
|||
path('', TokenListView.as_view(), name='api-token-list'),
|
||||
]),
|
||||
),
|
||||
path('me/', MeUserDetail.as_view(), name='api-user-me'),
|
||||
path('profile/', UserProfileDetail.as_view(), name='api-user-profile'),
|
||||
path(
|
||||
'owner/',
|
||||
include([
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ _roles = {
|
|||
'purchase_order': 'Role Purchase Orders',
|
||||
'sales_order': 'Role Sales Orders',
|
||||
'return_order': 'Role Return Orders',
|
||||
'transfer_order': 'Role Transfer Orders',
|
||||
}
|
||||
_methods = {'view': 'GET', 'add': 'POST', 'change': 'PUT / PATCH', 'delete': 'DELETE'}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ class RuleSetEnum(StringEnum):
|
|||
PURCHASE_ORDER = 'purchase_order'
|
||||
SALES_ORDER = 'sales_order'
|
||||
RETURN_ORDER = 'return_order'
|
||||
TRANSFER_ORDER = 'transfer_order'
|
||||
|
||||
|
||||
# This is a list of all the ruleset choices available in the system.
|
||||
|
|
@ -34,6 +35,7 @@ RULESET_CHOICES = [
|
|||
(RuleSetEnum.PURCHASE_ORDER, _('Purchase Orders')),
|
||||
(RuleSetEnum.SALES_ORDER, _('Sales Orders')),
|
||||
(RuleSetEnum.RETURN_ORDER, _('Return Orders')),
|
||||
(RuleSetEnum.TRANSFER_ORDER, _('Transfer Orders')),
|
||||
]
|
||||
|
||||
# Ruleset names available in the system.
|
||||
|
|
@ -161,6 +163,11 @@ def get_ruleset_models() -> dict:
|
|||
'order_returnorderlineitem',
|
||||
'order_returnorderextraline',
|
||||
],
|
||||
RuleSetEnum.TRANSFER_ORDER: [
|
||||
'order_transferorder',
|
||||
'order_transferorderallocation',
|
||||
'order_transferorderlineitem',
|
||||
],
|
||||
}
|
||||
|
||||
if settings.SITE_MULTI:
|
||||
|
|
|
|||
|
|
@ -977,9 +977,9 @@ icalendar==7.0.3 \
|
|||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# django-ical
|
||||
idna==3.13 \
|
||||
--hash=sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242 \
|
||||
--hash=sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3
|
||||
idna==3.15 \
|
||||
--hash=sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8 \
|
||||
--hash=sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# django-anymail
|
||||
|
|
@ -2152,6 +2152,12 @@ tinyhtml5==2.1.0 \
|
|||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# weasyprint
|
||||
tqdm==4.67.3 \
|
||||
--hash=sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb \
|
||||
--hash=sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# -r src/backend/requirements.in
|
||||
typing-extensions==4.15.0 \
|
||||
--hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \
|
||||
--hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
|
||||
|
|
|
|||
|
|
@ -449,9 +449,9 @@ gprof2dot==2025.4.14 \
|
|||
--hash=sha256:0742e4c0b4409a5e8777e739388a11e1ed3750be86895655312ea7c20bd0090e \
|
||||
--hash=sha256:35743e2d2ca027bf48fa7cba37021aaf4a27beeae1ae8e05a50b55f1f921a6ce
|
||||
# via django-silk
|
||||
idna==3.13 \
|
||||
--hash=sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242 \
|
||||
--hash=sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3
|
||||
idna==3.15 \
|
||||
--hash=sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8 \
|
||||
--hash=sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc
|
||||
# via
|
||||
# -c src/backend/requirements-3.14.txt
|
||||
# -c src/backend/requirements.txt
|
||||
|
|
|
|||
|
|
@ -443,9 +443,9 @@ gprof2dot==2025.4.14 \
|
|||
--hash=sha256:0742e4c0b4409a5e8777e739388a11e1ed3750be86895655312ea7c20bd0090e \
|
||||
--hash=sha256:35743e2d2ca027bf48fa7cba37021aaf4a27beeae1ae8e05a50b55f1f921a6ce
|
||||
# via django-silk
|
||||
idna==3.13 \
|
||||
--hash=sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242 \
|
||||
--hash=sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3
|
||||
idna==3.15 \
|
||||
--hash=sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8 \
|
||||
--hash=sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# requests
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ rapidfuzz # Fuzzy string matching
|
|||
sentry-sdk # Error reporting (optional)
|
||||
setuptools # Standard dependency
|
||||
tablib[xls,xlsx,yaml] # Support for XLS and XLSX formats
|
||||
tqdm # Progress bars for CLI
|
||||
weasyprint # PDF generation
|
||||
whitenoise # Enhanced static file serving
|
||||
|
||||
|
|
|
|||
|
|
@ -868,9 +868,9 @@ icalendar==7.0.3 \
|
|||
--hash=sha256:8c9fea6d3a89671bba8b6938d8565b4d0ec465c6a2796ef0f92790dcb9e627cd \
|
||||
--hash=sha256:95027ece087ab87184d765f03761f25875821f74cdd18d3b57e9c868216d8fde
|
||||
# via django-ical
|
||||
idna==3.13 \
|
||||
--hash=sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242 \
|
||||
--hash=sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3
|
||||
idna==3.15 \
|
||||
--hash=sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8 \
|
||||
--hash=sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc
|
||||
# via
|
||||
# django-anymail
|
||||
# requests
|
||||
|
|
@ -1916,6 +1916,10 @@ tinyhtml5==2.1.0 \
|
|||
--hash=sha256:60a50ec3d938a37e491efa01af895853060943dcebb5627de5b10d188b338a67 \
|
||||
--hash=sha256:6e11cfff38515834268daf89d5f85bbde0b6dd02e8d9e212d1385c2289b89f0a
|
||||
# via weasyprint
|
||||
tqdm==4.67.3 \
|
||||
--hash=sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb \
|
||||
--hash=sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf
|
||||
# via -r src/backend/requirements.in
|
||||
typing-extensions==4.15.0 \
|
||||
--hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \
|
||||
--hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
|
||||
|
|
|
|||
|
|
@ -12,12 +12,13 @@ export enum ApiEndpoints {
|
|||
// User API endpoints
|
||||
user_list = 'user/',
|
||||
user_set_password = 'user/:id/set-password/',
|
||||
user_me = 'user/me/',
|
||||
user_profile = 'user/profile/',
|
||||
user_roles = 'user/roles/',
|
||||
user_token = 'user/token/',
|
||||
user_tokens = 'user/tokens/',
|
||||
user_simple_login = 'email/generate/',
|
||||
// Individual user endpoints
|
||||
user_me_profile = 'user/me/profile/',
|
||||
user_me_roles = 'user/me/roles/',
|
||||
user_me_token = 'user/me/token/',
|
||||
user_me = 'user/me/',
|
||||
|
||||
// User auth endpoints
|
||||
auth_base = '/auth/',
|
||||
|
|
@ -198,6 +199,17 @@ export enum ApiEndpoints {
|
|||
return_order_line_list = 'order/ro-line/',
|
||||
return_order_extra_line_list = 'order/ro-extra-line/',
|
||||
|
||||
transfer_order_list = 'order/transfer-order/',
|
||||
transfer_order_issue = 'order/transfer-order/:id/issue/',
|
||||
transfer_order_hold = 'order/transfer-order/:id/hold/',
|
||||
transfer_order_cancel = 'order/transfer-order/:id/cancel/',
|
||||
transfer_order_complete = 'order/transfer-order/:id/complete/',
|
||||
transfer_order_allocate = 'order/transfer-order/:id/allocate/',
|
||||
transfer_order_allocate_serials = 'order/transfer-order/:id/allocate-serials/',
|
||||
|
||||
transfer_order_line_list = 'order/transfer-order-line/',
|
||||
transfer_order_allocation_list = 'order/transfer-order-allocation/',
|
||||
|
||||
// Template API endpoints
|
||||
label_list = 'label/template/',
|
||||
label_print = 'label/print/',
|
||||
|
|
|
|||
|
|
@ -207,6 +207,22 @@ export const ModelInformationDict: ModelDict = {
|
|||
api_endpoint: ApiEndpoints.return_order_line_list,
|
||||
icon: 'return_orders'
|
||||
},
|
||||
transferorder: {
|
||||
label: () => t`Transfer Order`,
|
||||
label_multiple: () => t`Transfer Orders`,
|
||||
url_overview: '/stock/location/index/transfer-orders',
|
||||
url_detail: '/stock/transfer-order/:pk/',
|
||||
api_endpoint: ApiEndpoints.transfer_order_list,
|
||||
admin_url: '/order/transferorder/',
|
||||
supports_barcode: true,
|
||||
icon: 'transfer_orders'
|
||||
},
|
||||
transferorderlineitem: {
|
||||
label: () => t`Transfer Order Line Item`,
|
||||
label_multiple: () => t`Transfer Order Line Items`,
|
||||
api_endpoint: ApiEndpoints.transfer_order_line_list,
|
||||
icon: 'transfer-orders'
|
||||
},
|
||||
address: {
|
||||
label: () => t`Address`,
|
||||
label_multiple: () => t`Addresses`,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ export enum ModelType {
|
|||
salesordershipment = 'salesordershipment',
|
||||
returnorder = 'returnorder',
|
||||
returnorderlineitem = 'returnorderlineitem',
|
||||
transferorder = 'transferorder',
|
||||
transferorderlineitem = 'transferorderlineitem',
|
||||
importsession = 'importsession',
|
||||
address = 'address',
|
||||
contact = 'contact',
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export enum UserRoles {
|
|||
part_category = 'part_category',
|
||||
purchase_order = 'purchase_order',
|
||||
return_order = 'return_order',
|
||||
transfer_order = 'transfer_order',
|
||||
sales_order = 'sales_order',
|
||||
stock = 'stock',
|
||||
stock_location = 'stock_location'
|
||||
|
|
@ -40,6 +41,8 @@ export function userRoleLabel(role: UserRoles): string {
|
|||
return t`Purchase Orders`;
|
||||
case UserRoles.return_order:
|
||||
return t`Return Orders`;
|
||||
case UserRoles.transfer_order:
|
||||
return t`Transfer Orders`;
|
||||
case UserRoles.sales_order:
|
||||
return t`Sales Orders`;
|
||||
case UserRoles.stock:
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ export default function PrimaryActionButton({
|
|||
icon,
|
||||
color,
|
||||
hidden,
|
||||
onClick
|
||||
onClick,
|
||||
leftSection
|
||||
}: Readonly<{
|
||||
title: string;
|
||||
tooltip?: string;
|
||||
|
|
@ -20,6 +21,7 @@ export default function PrimaryActionButton({
|
|||
color?: string;
|
||||
hidden?: boolean;
|
||||
onClick: () => void;
|
||||
leftSection?: React.ReactNode;
|
||||
}>) {
|
||||
if (hidden) {
|
||||
return null;
|
||||
|
|
@ -28,7 +30,7 @@ export default function PrimaryActionButton({
|
|||
return (
|
||||
<Tooltip label={tooltip ?? title} position='bottom' hidden={!tooltip}>
|
||||
<Button
|
||||
leftSection={icon && <InvenTreeIcon icon={icon} />}
|
||||
leftSection={leftSection ?? (icon && <InvenTreeIcon icon={icon} />)}
|
||||
color={color}
|
||||
radius='sm'
|
||||
p='xs'
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import type { CalendarOptions, DatesSetArg } from '@fullcalendar/core';
|
||||
import type {
|
||||
CalendarOptions,
|
||||
DatesSetArg,
|
||||
EventContentArg
|
||||
} from '@fullcalendar/core';
|
||||
import allLocales from '@fullcalendar/core/locales-all';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
|
|
@ -15,6 +19,7 @@ import {
|
|||
Box,
|
||||
Button,
|
||||
Group,
|
||||
HoverCard,
|
||||
Indicator,
|
||||
LoadingOverlay,
|
||||
Popover,
|
||||
|
|
@ -29,7 +34,13 @@ import {
|
|||
IconDownload,
|
||||
IconFilter
|
||||
} from '@tabler/icons-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState
|
||||
} from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import {
|
||||
defaultLocale,
|
||||
|
|
@ -44,6 +55,7 @@ export interface InvenTreeCalendarProps extends CalendarOptions {
|
|||
enableDownload?: boolean;
|
||||
enableFilters?: boolean;
|
||||
enableSearch?: boolean;
|
||||
eventTooltipContent?: (event: EventContentArg) => ReactNode;
|
||||
filters?: TableFilter[];
|
||||
isLoading?: boolean;
|
||||
state: CalendarState;
|
||||
|
|
@ -53,6 +65,7 @@ export default function Calendar({
|
|||
enableDownload,
|
||||
enableFilters = false,
|
||||
enableSearch,
|
||||
eventTooltipContent,
|
||||
isLoading,
|
||||
filters,
|
||||
state,
|
||||
|
|
@ -112,6 +125,36 @@ export default function Calendar({
|
|||
[calendarProps.datesSet, state.ref, state.setMonthName]
|
||||
);
|
||||
|
||||
const wrappedEventContent = useCallback(
|
||||
(arg: EventContentArg) => {
|
||||
const inner =
|
||||
typeof calendarProps.eventContent === 'function'
|
||||
? calendarProps.eventContent(arg, null)
|
||||
: (calendarProps.eventContent ?? null);
|
||||
|
||||
if (!eventTooltipContent) return inner;
|
||||
|
||||
const tooltip = eventTooltipContent(arg);
|
||||
|
||||
if (!tooltip) return inner;
|
||||
|
||||
return (
|
||||
<HoverCard
|
||||
openDelay={300}
|
||||
closeDelay={100}
|
||||
shadow='md'
|
||||
position='top-start'
|
||||
>
|
||||
<HoverCard.Target>
|
||||
<div style={{ width: '100%', overflow: 'hidden' }}>{inner}</div>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown>{tooltip}</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
);
|
||||
},
|
||||
[calendarProps.eventContent, eventTooltipContent]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{state.exportModal.modal}
|
||||
|
|
@ -217,6 +260,7 @@ export default function Calendar({
|
|||
footerToolbar={false}
|
||||
{...calendarProps}
|
||||
datesSet={datesSet}
|
||||
eventContent={wrappedEventContent}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -46,12 +46,14 @@ export default function OrderCalendar({
|
|||
model,
|
||||
role,
|
||||
params,
|
||||
filters
|
||||
filters,
|
||||
tooltip
|
||||
}: {
|
||||
model: ModelType;
|
||||
role: UserRoles;
|
||||
params: Record<string, any>;
|
||||
filters?: TableFilter[];
|
||||
tooltip?: (event: EventContentArg) => React.ReactNode;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const user = useUserState();
|
||||
|
|
@ -106,6 +108,7 @@ export default function OrderCalendar({
|
|||
const end: string = order.target_date || start;
|
||||
|
||||
return {
|
||||
order: order,
|
||||
id: order.pk,
|
||||
title: order.reference,
|
||||
description: order.description,
|
||||
|
|
@ -209,6 +212,7 @@ export default function OrderCalendar({
|
|||
state={calendarState}
|
||||
filters={calendarFilters}
|
||||
editable={true}
|
||||
eventTooltipContent={tooltip}
|
||||
eventContent={renderOrder}
|
||||
eventClick={onClickOrder}
|
||||
eventChange={onEditOrder}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
import type { EventContentArg } from '@fullcalendar/core';
|
||||
import type { ModelType } from '@lib/enums/ModelType';
|
||||
import { resolveItem } from '@lib/functions/Conversion';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Badge, Divider, Group, Stack, Text } from '@mantine/core';
|
||||
import { formatDate } from '../../defaults/formatters';
|
||||
import { RenderInstance } from '../render/Instance';
|
||||
import { RenderOwner } from '../render/User';
|
||||
|
||||
export default function OrderCalendarToolTip({
|
||||
event,
|
||||
instanceLookup,
|
||||
modelType
|
||||
}: {
|
||||
event: EventContentArg;
|
||||
instanceLookup: string;
|
||||
modelType: ModelType;
|
||||
}) {
|
||||
// Extract the order instance from the event
|
||||
const order = event?.event?._def?.extendedProps?.order;
|
||||
|
||||
const instance = resolveItem(order, instanceLookup);
|
||||
|
||||
if (!order) return null;
|
||||
|
||||
return (
|
||||
<Stack gap='xs'>
|
||||
<RenderInstance model={modelType} instance={instance} />
|
||||
<Divider />
|
||||
<Group gap='xs'>
|
||||
<Text size='sm' fw='bold'>
|
||||
{order.reference}
|
||||
</Text>
|
||||
<Text size='xs'>{order.description || order.title}</Text>
|
||||
</Group>
|
||||
{order.start_date && (
|
||||
<Group gap='xs'>
|
||||
<Text size='sm' fw='bold'>{t`Start Date`}</Text>
|
||||
<Text size='xs'>{formatDate(order.start_date)}</Text>
|
||||
</Group>
|
||||
)}
|
||||
{order.target_date && (
|
||||
<Group gap='xs'>
|
||||
<Text size='sm' fw='bold'>{t`Target Date`}</Text>
|
||||
<Text size='xs'>{formatDate(order.target_date)}</Text>
|
||||
{order.overdue && (
|
||||
<Badge size='sm' color='red'>
|
||||
{t`Overdue`}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
{order.responsible && (
|
||||
<Group gap='xs'>
|
||||
<Text size='sm' fw='bold'>{t`Responsible`}</Text>
|
||||
<RenderOwner instance={order.responsible_detail} />
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from '@tabler/icons-react';
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
import { generateUrl } from '../../functions/urls';
|
||||
import { Thumbnail } from '../images/Thumbnail';
|
||||
|
||||
/**
|
||||
* Return an icon based on the provided filename
|
||||
|
|
@ -59,9 +60,11 @@ export function attachmentIcon(attachment: string): ReactNode {
|
|||
*/
|
||||
export function AttachmentLink({
|
||||
attachment,
|
||||
thumbnail,
|
||||
external
|
||||
}: Readonly<{
|
||||
attachment: string;
|
||||
thumbnail?: string;
|
||||
external?: boolean;
|
||||
}>): ReactNode {
|
||||
const url = useMemo(() => {
|
||||
|
|
@ -82,7 +85,13 @@ export function AttachmentLink({
|
|||
|
||||
return (
|
||||
<Group justify='left' gap='sm' wrap='nowrap'>
|
||||
{external ? <IconLink /> : attachmentIcon(attachment)}
|
||||
{thumbnail ? (
|
||||
<Thumbnail src={thumbnail} hover size={16} />
|
||||
) : external ? (
|
||||
<IconLink />
|
||||
) : (
|
||||
attachmentIcon(attachment)
|
||||
)}
|
||||
{!!attachment ? (
|
||||
<Anchor href={url} target='_blank' rel='noopener noreferrer'>
|
||||
{text}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,13 @@ import { useHotkeys } from '@mantine/hooks';
|
|||
import { StylishText } from '@lib/components/StylishText';
|
||||
import { shortenString } from '@lib/functions/String';
|
||||
import { Fragment, type ReactNode, useMemo } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { usePluginUIFeature } from '../../hooks/UsePluginUIFeature';
|
||||
import { useUserSettingsState } from '../../states/SettingsStates';
|
||||
import PrimaryActionButton from '../buttons/PrimaryActionButton';
|
||||
import { ApiImage } from '../images/ApiImage';
|
||||
import { ApiIcon } from '../items/ApiIcon';
|
||||
import type { PrimaryActionUIFeature } from '../plugins/PluginUIFeatureTypes';
|
||||
import { type Breadcrumb, BreadcrumbList } from './BreadcrumbList';
|
||||
import PageTitle from './PageTitle';
|
||||
|
||||
|
|
@ -45,6 +50,8 @@ export function PageDetail({
|
|||
editEnabled
|
||||
}: Readonly<PageDetailInterface>) {
|
||||
const userSettings = useUserSettingsState();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
useHotkeys([
|
||||
[
|
||||
'mod+E',
|
||||
|
|
@ -86,6 +93,39 @@ export function PageDetail({
|
|||
}
|
||||
}, [breadcrumbs, last_crumb, userSettings]);
|
||||
|
||||
const extraActions = usePluginUIFeature<PrimaryActionUIFeature>({
|
||||
featureType: 'primary_action',
|
||||
context: { location: location.pathname }
|
||||
});
|
||||
|
||||
// action caching
|
||||
const computedActions = useMemo(() => {
|
||||
const extraActionArray: ReactNode[] = extraActions.map((action) => {
|
||||
const { options: opts, func } = action;
|
||||
const { title, icon, context, options } = opts;
|
||||
|
||||
const click = () => {
|
||||
const url = options?.url;
|
||||
if (url) {
|
||||
navigate(url);
|
||||
} else if (func) {
|
||||
func(context);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PrimaryActionButton
|
||||
title={title}
|
||||
leftSection={<ApiIcon name={icon as string} />}
|
||||
color={options?.color}
|
||||
onClick={click}
|
||||
key={title}
|
||||
/>
|
||||
);
|
||||
});
|
||||
return [...(extraActionArray ?? []), ...(actions ?? [])];
|
||||
}, [extraActions, actions]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={pageTitleString} />
|
||||
|
|
@ -140,9 +180,9 @@ export function PageDetail({
|
|||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
{actions && (
|
||||
{computedActions && (
|
||||
<Group gap={5} justify='right' wrap='nowrap' align='flex-start'>
|
||||
{actions.map((action, idx) => (
|
||||
{computedActions.map((action, idx) => (
|
||||
<Fragment key={idx}>{action}</Fragment>
|
||||
))}
|
||||
</Group>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ export enum PluginUIFeatureType {
|
|||
panel = 'panel',
|
||||
template_editor = 'template_editor',
|
||||
template_preview = 'template_preview',
|
||||
navigation = 'navigation'
|
||||
navigation = 'navigation',
|
||||
primary_action = 'primary_action'
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -84,3 +84,11 @@ export type NavigationUIFeature = {
|
|||
featureContext: {};
|
||||
featureReturnType: undefined;
|
||||
};
|
||||
|
||||
export type PrimaryActionUIFeature = {
|
||||
featureType: 'primary_action';
|
||||
requestContext: {};
|
||||
responseOptions: PluginUIFeature;
|
||||
featureContext: {};
|
||||
featureReturnType: undefined;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -50,11 +50,11 @@ export function RenderCompany(
|
|||
return (
|
||||
<RenderInlineModel
|
||||
{...props}
|
||||
image={instance.thumbnail || instance.image}
|
||||
primary={instance.name}
|
||||
suffix={instance.description}
|
||||
image={instance?.thumbnail || instance?.image}
|
||||
primary={instance?.name}
|
||||
suffix={instance?.description}
|
||||
url={
|
||||
props.link ? getDetailUrl(ModelType.company, instance.pk) : undefined
|
||||
props.link ? getDetailUrl(ModelType.company, instance?.pk) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -51,7 +51,9 @@ import {
|
|||
RenderReturnOrder,
|
||||
RenderReturnOrderLineItem,
|
||||
RenderSalesOrder,
|
||||
RenderSalesOrderShipment
|
||||
RenderSalesOrderShipment,
|
||||
RenderTransferOrder,
|
||||
RenderTransferOrderLineItem
|
||||
} from './Order';
|
||||
import { RenderPart, RenderPartCategory, RenderPartTestTemplate } from './Part';
|
||||
import { RenderPlugin } from './Plugin';
|
||||
|
|
@ -87,6 +89,8 @@ export const RendererLookup: ModelRendererDict = {
|
|||
[ModelType.returnorderlineitem]: RenderReturnOrderLineItem,
|
||||
[ModelType.salesorder]: RenderSalesOrder,
|
||||
[ModelType.salesordershipment]: RenderSalesOrderShipment,
|
||||
[ModelType.transferorder]: RenderTransferOrder,
|
||||
[ModelType.transferorderlineitem]: RenderTransferOrderLineItem,
|
||||
[ModelType.stocklocation]: RenderStockLocation,
|
||||
[ModelType.stocklocationtype]: RenderStockLocationType,
|
||||
[ModelType.stockitem]: RenderStockItem,
|
||||
|
|
|
|||
|
|
@ -123,3 +123,46 @@ export function RenderSalesOrderShipment({
|
|||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline rendering of a single TransferOrder instance
|
||||
*/
|
||||
export function RenderTransferOrder(
|
||||
props: Readonly<InstanceRenderInterface>
|
||||
): ReactNode {
|
||||
const { instance } = props;
|
||||
|
||||
return (
|
||||
<RenderInlineModel
|
||||
{...props}
|
||||
primary={instance.reference}
|
||||
secondary={instance.description}
|
||||
suffix={StatusRenderer({
|
||||
status: instance.status_custom_key,
|
||||
type: ModelType.transferorder
|
||||
})}
|
||||
url={
|
||||
props.link
|
||||
? getDetailUrl(ModelType.transferorder, instance.pk)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function RenderTransferOrderLineItem(
|
||||
props: Readonly<InstanceRenderInterface>
|
||||
): ReactNode {
|
||||
const { instance } = props;
|
||||
|
||||
return (
|
||||
<RenderInlineModel
|
||||
{...props}
|
||||
primary={instance.reference}
|
||||
suffix={StatusRenderer({
|
||||
status: instance.outcome,
|
||||
type: ModelType.transferorderlineitem
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ export const statusCodeList: Record<string, ModelType> = {
|
|||
PurchaseOrderStatus: ModelType.purchaseorder,
|
||||
ReturnOrderStatus: ModelType.returnorder,
|
||||
ReturnOrderLineStatus: ModelType.returnorderlineitem,
|
||||
TransferOrderStatus: ModelType.transferorder,
|
||||
TransferOrderLineStatus: ModelType.transferorderlineitem,
|
||||
SalesOrderStatus: ModelType.salesorder,
|
||||
StockHistoryCode: ModelType.stockhistory,
|
||||
StockStatus: ModelType.stockitem,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,308 @@
|
|||
import { ApiEndpoints, ModelType, ProgressBar, apiUrl } from '@lib/index';
|
||||
import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Table } from '@mantine/core';
|
||||
import { IconCalendar, IconUsers } from '@tabler/icons-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import RemoveRowButton from '../components/buttons/RemoveRowButton';
|
||||
import { StandaloneField } from '../components/forms/StandaloneField';
|
||||
import type { TableFieldRowProps } from '../components/forms/fields/TableField';
|
||||
import { useCreateApiFormModal } from '../hooks/UseForm';
|
||||
import { useGlobalSettingsState } from '../states/SettingsStates';
|
||||
import { RenderPartColumn } from '../tables/ColumnRenderers';
|
||||
|
||||
export function useTransferOrderFields({
|
||||
duplicateOrderId
|
||||
}: {
|
||||
duplicateOrderId?: number;
|
||||
}): ApiFormFieldSet {
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
|
||||
return useMemo(() => {
|
||||
const fields: ApiFormFieldSet = {
|
||||
reference: {},
|
||||
description: {},
|
||||
project_code: {},
|
||||
start_date: {
|
||||
icon: <IconCalendar />
|
||||
},
|
||||
target_date: {
|
||||
icon: <IconCalendar />
|
||||
},
|
||||
take_from: {},
|
||||
destination: {
|
||||
filters: {
|
||||
structural: false
|
||||
}
|
||||
},
|
||||
consume: {},
|
||||
link: {},
|
||||
responsible: {
|
||||
filters: {
|
||||
is_active: true
|
||||
},
|
||||
icon: <IconUsers />
|
||||
}
|
||||
};
|
||||
|
||||
// Order duplication fields
|
||||
if (!!duplicateOrderId) {
|
||||
fields.duplicate = {
|
||||
children: {
|
||||
order_id: {
|
||||
hidden: true,
|
||||
value: duplicateOrderId
|
||||
},
|
||||
copy_lines: {},
|
||||
// Transfer Orders don't have extra lines for now...
|
||||
copy_extra_lines: { hidden: true, value: false }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!globalSettings.isSet('PROJECT_CODES_ENABLED', true)) {
|
||||
delete fields.project_code;
|
||||
}
|
||||
|
||||
return fields;
|
||||
}, [duplicateOrderId, globalSettings]);
|
||||
}
|
||||
|
||||
export function useTransferOrderLineItemFields({
|
||||
orderId,
|
||||
create
|
||||
}: {
|
||||
orderId?: number;
|
||||
create?: boolean;
|
||||
}): ApiFormFieldSet {
|
||||
return useMemo(() => {
|
||||
const fields: ApiFormFieldSet = {
|
||||
order: {
|
||||
filters: {},
|
||||
disabled: true
|
||||
},
|
||||
part: {
|
||||
filters: {
|
||||
active: true,
|
||||
virtual: false
|
||||
}
|
||||
},
|
||||
reference: {},
|
||||
quantity: {},
|
||||
project_code: {
|
||||
description: t`Select project code for this line item`
|
||||
},
|
||||
target_date: {},
|
||||
notes: {},
|
||||
link: {}
|
||||
};
|
||||
|
||||
return fields;
|
||||
}, [orderId, create]);
|
||||
}
|
||||
|
||||
function TransferOrderAllocateLineRow({
|
||||
props,
|
||||
record,
|
||||
sourceLocation
|
||||
}: Readonly<{
|
||||
props: TableFieldRowProps;
|
||||
record: any;
|
||||
sourceLocation?: number | null;
|
||||
}>) {
|
||||
// Statically defined field for selecting the stock item
|
||||
const stockItemField: ApiFormFieldType = useMemo(() => {
|
||||
return {
|
||||
field_type: 'related field',
|
||||
api_url: apiUrl(ApiEndpoints.stock_item_list),
|
||||
model: ModelType.stockitem,
|
||||
autoFill: true,
|
||||
filters: {
|
||||
available: true,
|
||||
part_detail: true,
|
||||
location_detail: true,
|
||||
location: sourceLocation,
|
||||
cascade: sourceLocation ? true : undefined,
|
||||
part: record.part
|
||||
},
|
||||
value: props.item.stock_item,
|
||||
name: 'stock_item',
|
||||
onValueChange: (value: any, instance: any) => {
|
||||
props.changeFn(props.idx, 'stock_item', value);
|
||||
|
||||
// Update the allocated quantity based on the selected stock item
|
||||
if (instance) {
|
||||
const available = instance.quantity - instance.allocated;
|
||||
const required = record.quantity - record.allocated;
|
||||
|
||||
let quantity = props.item?.quantity ?? 0;
|
||||
|
||||
quantity = Math.max(quantity, required);
|
||||
quantity = Math.min(quantity, available);
|
||||
|
||||
if (quantity != props.item.quantity) {
|
||||
props.changeFn(props.idx, 'quantity', quantity);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [sourceLocation, record, props]);
|
||||
|
||||
// Statically defined field for selecting the allocation quantity
|
||||
const quantityField: ApiFormFieldType = useMemo(() => {
|
||||
return {
|
||||
field_type: 'number',
|
||||
name: 'quantity',
|
||||
required: true,
|
||||
value: props.item.quantity,
|
||||
onValueChange: (value: any) => {
|
||||
props.changeFn(props.idx, 'quantity', value);
|
||||
}
|
||||
};
|
||||
}, [props]);
|
||||
|
||||
return (
|
||||
<Table.Tr key={`table-row-${props.idx}-${record.pk}`}>
|
||||
<Table.Td>
|
||||
<RenderPartColumn part={record.part_detail} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ProgressBar
|
||||
value={record.allocated}
|
||||
maximum={record.quantity}
|
||||
progressLabel
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<StandaloneField
|
||||
fieldName='stock_item'
|
||||
fieldDefinition={stockItemField}
|
||||
error={props.rowErrors?.stock_item?.message}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<StandaloneField
|
||||
fieldName='quantity'
|
||||
fieldDefinition={quantityField}
|
||||
error={props.rowErrors?.quantity?.message}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAllocateToTransferOrderForm({
|
||||
orderId,
|
||||
sourceLocationId,
|
||||
lineItems,
|
||||
onFormSuccess
|
||||
}: {
|
||||
orderId: number;
|
||||
sourceLocationId?: number;
|
||||
lineItems: any[];
|
||||
onFormSuccess: (response: any) => void;
|
||||
}) {
|
||||
const [sourceLocation, setSourceLocation] = useState<number | null>(
|
||||
sourceLocationId || null
|
||||
);
|
||||
|
||||
const fields: ApiFormFieldSet = useMemo(() => {
|
||||
return {
|
||||
// Non-submitted field to select the source location
|
||||
source_location: {
|
||||
exclude: true,
|
||||
required: false,
|
||||
value: sourceLocationId,
|
||||
field_type: 'related field',
|
||||
api_url: apiUrl(ApiEndpoints.stock_location_list),
|
||||
model: ModelType.stocklocation,
|
||||
label: t`Source Location`,
|
||||
description: t`Select the source location for the stock allocation`,
|
||||
onValueChange: (value: any) => {
|
||||
setSourceLocation(value);
|
||||
}
|
||||
},
|
||||
items: {
|
||||
field_type: 'table',
|
||||
value: [],
|
||||
headers: [
|
||||
{ title: t`Part`, style: { minWidth: '200px' } },
|
||||
{ title: t`Allocated`, style: { minWidth: '200px' } },
|
||||
{ title: t`Stock Item`, style: { width: '100%' } },
|
||||
{ title: t`Quantity`, style: { minWidth: '200px' } },
|
||||
{ title: '', style: { width: '50px' } }
|
||||
],
|
||||
modelRenderer: (row: TableFieldRowProps) => {
|
||||
const record =
|
||||
lineItems.find((item) => item.pk == row.item.line_item) ?? {};
|
||||
|
||||
return (
|
||||
<TransferOrderAllocateLineRow
|
||||
key={`table-row-${row.idx}-${record.pk}`}
|
||||
props={row}
|
||||
record={record}
|
||||
sourceLocation={sourceLocation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [orderId, lineItems, sourceLocation]);
|
||||
|
||||
return useCreateApiFormModal({
|
||||
title: t`Allocate Stock`,
|
||||
url: ApiEndpoints.transfer_order_allocate,
|
||||
pk: orderId,
|
||||
fields: fields,
|
||||
onFormSuccess: onFormSuccess,
|
||||
successMessage: t`Stock items allocated`,
|
||||
size: '80%',
|
||||
initialData: {
|
||||
items: lineItems.map((item) => {
|
||||
return {
|
||||
line_item: item.pk,
|
||||
quantity: 0,
|
||||
stock_item: null
|
||||
};
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useTransferOrderAllocationFields({
|
||||
orderId
|
||||
}: {
|
||||
orderId?: number;
|
||||
}): ApiFormFieldSet {
|
||||
return useMemo(() => {
|
||||
return {
|
||||
item: {
|
||||
// Cannot change item, but display for reference
|
||||
disabled: true
|
||||
},
|
||||
quantity: {}
|
||||
};
|
||||
}, [orderId]);
|
||||
}
|
||||
|
||||
export function useTransferOrderAllocateSerialsFields({
|
||||
itemId,
|
||||
orderId
|
||||
}: {
|
||||
itemId: number;
|
||||
orderId: number;
|
||||
}): ApiFormFieldSet {
|
||||
return useMemo(() => {
|
||||
return {
|
||||
line_item: {
|
||||
value: itemId,
|
||||
hidden: true
|
||||
},
|
||||
quantity: {},
|
||||
serial_numbers: {}
|
||||
};
|
||||
}, [itemId, orderId]);
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ import {
|
|||
IconCancel,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconCircleDashedCheck,
|
||||
IconCircleMinus,
|
||||
IconCirclePlus,
|
||||
IconCircleX,
|
||||
|
|
@ -148,11 +149,13 @@ const icons: InvenTreeIconType = {
|
|||
build_order: IconTools,
|
||||
builds: IconTools,
|
||||
used_in: IconStack2,
|
||||
consume: IconCircleDashedCheck,
|
||||
manufacturers: IconBuildingFactory2,
|
||||
suppliers: IconBuilding,
|
||||
customers: IconBuildingStore,
|
||||
purchase_orders: IconShoppingCart,
|
||||
return_orders: IconTruckReturn,
|
||||
transfer_orders: IconTransfer,
|
||||
sales_orders: IconTruckDelivery,
|
||||
scheduling: IconCalendarStats,
|
||||
scrap: IconCircleX,
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export function AccountDetailPanel() {
|
|||
|
||||
const editProfile = useEditApiFormModal({
|
||||
title: t`Edit Profile Information`,
|
||||
url: ApiEndpoints.user_profile,
|
||||
url: ApiEndpoints.user_me_profile,
|
||||
onFormSuccess: fetchUserState,
|
||||
fields: profileFields,
|
||||
successMessage: t`Profile details updated`
|
||||
|
|
|
|||
|
|
@ -70,10 +70,12 @@ function PluginSettingGroupItem({
|
|||
export default function PluginSettingsGroup({
|
||||
mixin,
|
||||
message,
|
||||
includeBaseSettings,
|
||||
global
|
||||
}: {
|
||||
global: boolean;
|
||||
message?: string;
|
||||
includeBaseSettings?: boolean;
|
||||
mixin?: string;
|
||||
}) {
|
||||
const mixins: string = useMemo(() => {
|
||||
|
|
@ -99,16 +101,18 @@ export default function PluginSettingsGroup({
|
|||
|
||||
return (
|
||||
<Stack gap='xs'>
|
||||
<Accordion defaultValue='plugin-settings'>
|
||||
<Accordion.Item value='plugin-settings'>
|
||||
<Accordion.Control>
|
||||
<StylishText size='lg'>{t`Plugin Settings`}</StylishText>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<GlobalSettingList keys={PLUGIN_SETTINGS_KEYS} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
{includeBaseSettings == true && (
|
||||
<Accordion defaultValue='plugin-settings'>
|
||||
<Accordion.Item value='plugin-settings'>
|
||||
<Accordion.Control>
|
||||
<StylishText size='lg'>{t`Plugin Settings`}</StylishText>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<GlobalSettingList keys={PLUGIN_SETTINGS_KEYS} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
)}
|
||||
<Alert color='blue' icon={<IconInfoCircle />}>
|
||||
<Text>
|
||||
{message ??
|
||||
|
|
|
|||
|
|
@ -13,9 +13,10 @@ import {
|
|||
IconQrcode,
|
||||
IconServerCog,
|
||||
IconShoppingCart,
|
||||
IconTransfer,
|
||||
IconTruckDelivery
|
||||
} from '@tabler/icons-react';
|
||||
import { lazy, useMemo } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { PluginPanelKey } from '@lib/enums/ModelType';
|
||||
import type { PanelType } from '@lib/types/Panel';
|
||||
|
|
@ -25,13 +26,9 @@ import PageTitle from '../../../components/nav/PageTitle';
|
|||
import { SettingsHeader } from '../../../components/nav/SettingsHeader';
|
||||
import { PanelGroup } from '../../../components/panels/PanelGroup';
|
||||
import { GlobalSettingList } from '../../../components/settings/SettingList';
|
||||
import { Loadable } from '../../../functions/loading';
|
||||
import { useServerApiState } from '../../../states/ServerApiState';
|
||||
import { useUserState } from '../../../states/UserState';
|
||||
|
||||
const PluginSettingsGroup = Loadable(
|
||||
lazy(() => import('./PluginSettingsGroup'))
|
||||
);
|
||||
import PluginSettingsGroup from './PluginSettingsGroup';
|
||||
|
||||
/**
|
||||
* System settings page
|
||||
|
|
@ -60,6 +57,7 @@ export default function SystemSettings() {
|
|||
'DISPLAY_FULL_NAMES',
|
||||
'DISPLAY_PROFILE_INFO',
|
||||
'WEEK_STARTS_ON',
|
||||
'INVENTREE_UPLOAD_MAX_SIZE',
|
||||
'INVENTREE_STRICT_URLS'
|
||||
]}
|
||||
/>
|
||||
|
|
@ -367,11 +365,27 @@ export default function SystemSettings() {
|
|||
</Stack>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'transferorders',
|
||||
label: t`Transfer Orders`,
|
||||
icon: <IconTransfer />,
|
||||
content: (
|
||||
<GlobalSettingList
|
||||
keys={[
|
||||
'TRANSFERORDER_ENABLED',
|
||||
'TRANSFERORDER_REFERENCE_PATTERN',
|
||||
'TRANSFERORDER_REQUIRE_RESPONSIBLE'
|
||||
]}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'plugins',
|
||||
label: t`Plugins`,
|
||||
icon: <IconPlugConnected />,
|
||||
content: <PluginSettingsGroup global={true} />
|
||||
content: (
|
||||
<PluginSettingsGroup global={true} includeBaseSettings={true} />
|
||||
)
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
IconSearch,
|
||||
IconUserCircle
|
||||
} from '@tabler/icons-react';
|
||||
import { lazy, useMemo } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { PluginPanelKey } from '@lib/enums/ModelType';
|
||||
import type { PanelType } from '@lib/types/Panel';
|
||||
|
|
@ -18,14 +18,10 @@ import PageTitle from '../../../components/nav/PageTitle';
|
|||
import { SettingsHeader } from '../../../components/nav/SettingsHeader';
|
||||
import { PanelGroup } from '../../../components/panels/PanelGroup';
|
||||
import { UserSettingList } from '../../../components/settings/SettingList';
|
||||
import { Loadable } from '../../../functions/loading';
|
||||
import { useUserState } from '../../../states/UserState';
|
||||
import { SecurityContent } from './AccountSettings/SecurityContent';
|
||||
import { AccountContent } from './AccountSettings/UserPanel';
|
||||
|
||||
const PluginSettingsGroup = Loadable(
|
||||
lazy(() => import('./PluginSettingsGroup'))
|
||||
);
|
||||
import PluginSettingsGroup from './PluginSettingsGroup';
|
||||
|
||||
/**
|
||||
* User settings page
|
||||
|
|
@ -130,7 +126,9 @@ export default function UserSettings() {
|
|||
name: 'plugins',
|
||||
label: t`Plugin Settings`,
|
||||
icon: <IconPlugConnected />,
|
||||
content: <PluginSettingsGroup global={false} />
|
||||
content: (
|
||||
<PluginSettingsGroup global={false} includeBaseSettings={true} />
|
||||
)
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -6,14 +6,16 @@ import {
|
|||
IconTable,
|
||||
IconTools
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import type { EventContentArg } from '@fullcalendar/core';
|
||||
import { ModelType, PluginPanelKey } from '@lib/enums/ModelType';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
import type { TableFilter } from '@lib/types/Filters';
|
||||
import type { PanelType } from '@lib/types/Panel';
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
import OrderCalendar from '../../components/calendar/OrderCalendar';
|
||||
import OrderCalendarToolTip from '../../components/calendar/OrderCalendarToolTip';
|
||||
import PermissionDenied from '../../components/errors/PermissionDenied';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||
|
|
@ -34,12 +36,21 @@ function BuildOrderCalendar() {
|
|||
});
|
||||
}, [globalSettings]);
|
||||
|
||||
const renderTooltip = useCallback((event: EventContentArg) => {
|
||||
return OrderCalendarToolTip({
|
||||
event: event,
|
||||
modelType: ModelType.part,
|
||||
instanceLookup: 'part_detail'
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<OrderCalendar
|
||||
model={ModelType.build}
|
||||
role={UserRoles.build}
|
||||
params={{ outstanding: true }}
|
||||
params={{ outstanding: true, part_detail: true }}
|
||||
filters={calendarFilters}
|
||||
tooltip={renderTooltip}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,12 +23,14 @@ import {
|
|||
IconListDetails,
|
||||
IconListTree,
|
||||
IconLock,
|
||||
IconLockOpen,
|
||||
IconPackages,
|
||||
IconSearch,
|
||||
IconShoppingCart,
|
||||
IconStack2,
|
||||
IconTestPipe,
|
||||
IconTools,
|
||||
IconTransfer,
|
||||
IconTruckDelivery,
|
||||
IconTruckReturn,
|
||||
IconVersions
|
||||
|
|
@ -100,6 +102,7 @@ import { RelatedPartTable } from '../../tables/part/RelatedPartTable';
|
|||
import { ReturnOrderTable } from '../../tables/sales/ReturnOrderTable';
|
||||
import { SalesOrderTable } from '../../tables/sales/SalesOrderTable';
|
||||
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
||||
import { TransferOrderTable } from '../../tables/stock/TransferOrderTable';
|
||||
import PartAllocationPanel from './PartAllocationPanel';
|
||||
import PartPricingPanel from './PartPricingPanel';
|
||||
import PartStockHistoryDetail from './PartStockHistoryDetail';
|
||||
|
|
@ -770,6 +773,20 @@ export default function PartDetail() {
|
|||
hidden: !part.assembly || !user.hasViewRole(UserRoles.build),
|
||||
content: part.pk ? <BuildOrderTable partId={part.pk} /> : <Skeleton />
|
||||
},
|
||||
{
|
||||
name: 'transfer_orders',
|
||||
label: t`Transfer Orders`,
|
||||
icon: <IconTransfer />,
|
||||
hidden:
|
||||
part.virtual ||
|
||||
!globalSettings.isSet('TRANSFERORDER_ENABLED') ||
|
||||
!user.hasViewRole(UserRoles.transfer_order),
|
||||
content: part.pk ? (
|
||||
<TransferOrderTable partId={part.pk} />
|
||||
) : (
|
||||
<Skeleton />
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'stocktake',
|
||||
label: t`Stock History`,
|
||||
|
|
@ -1132,9 +1149,20 @@ export default function PartDetail() {
|
|||
<PageDetail
|
||||
title={`${t`Part`}: ${part.full_name}`}
|
||||
icon={
|
||||
part?.locked ? (
|
||||
<IconLock aria-label='part-lock-icon' />
|
||||
) : undefined
|
||||
<ActionIcon
|
||||
aria-label='part-lock-icon'
|
||||
variant='transparent'
|
||||
disabled={!user.hasChangeRole(UserRoles.part)}
|
||||
onClick={() => {
|
||||
api
|
||||
.patch(apiUrl(ApiEndpoints.part_list, part.pk), {
|
||||
locked: !part.locked
|
||||
})
|
||||
.then(refreshInstance);
|
||||
}}
|
||||
>
|
||||
{part?.locked ? <IconLock /> : <IconLockOpen />}
|
||||
</ActionIcon>
|
||||
}
|
||||
subtitle={part.description}
|
||||
imageUrl={part.image}
|
||||
|
|
|
|||
|
|
@ -10,13 +10,15 @@ import {
|
|||
IconShoppingCart,
|
||||
IconTable
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import type { EventContentArg } from '@fullcalendar/core';
|
||||
import { ModelType, PluginPanelKey } from '@lib/enums/ModelType';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
import type { TableFilter } from '@lib/index';
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
import OrderCalendar from '../../components/calendar/OrderCalendar';
|
||||
import OrderCalendarToolTip from '../../components/calendar/OrderCalendarToolTip';
|
||||
import PermissionDenied from '../../components/errors/PermissionDenied';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||
|
|
@ -37,12 +39,21 @@ function PurchaseOrderCalendar() {
|
|||
return PurchaseOrderFilters({ includeDateFilters: false });
|
||||
}, []);
|
||||
|
||||
const renderTooltip = useCallback((event: EventContentArg) => {
|
||||
return OrderCalendarToolTip({
|
||||
event: event,
|
||||
modelType: ModelType.company,
|
||||
instanceLookup: 'supplier_detail'
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<OrderCalendar
|
||||
model={ModelType.purchaseorder}
|
||||
role={UserRoles.purchase_order}
|
||||
params={{ outstanding: true }}
|
||||
params={{ outstanding: true, supplier_detail: true }}
|
||||
filters={calendarFilters}
|
||||
tooltip={renderTooltip}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,13 +9,15 @@ import {
|
|||
IconTruckDelivery,
|
||||
IconTruckReturn
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import type { EventContentArg } from '@fullcalendar/core';
|
||||
import { ModelType, PluginPanelKey } from '@lib/enums/ModelType';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
import type { TableFilter } from '@lib/index';
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
import OrderCalendar from '../../components/calendar/OrderCalendar';
|
||||
import OrderCalendarToolTip from '../../components/calendar/OrderCalendarToolTip';
|
||||
import PermissionDenied from '../../components/errors/PermissionDenied';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||
|
|
@ -35,12 +37,21 @@ function SalesOrderCalendar() {
|
|||
return SalesOrderFilters({ includeDateFilters: false });
|
||||
}, []);
|
||||
|
||||
const renderTooltip = useCallback((event: EventContentArg) => {
|
||||
return OrderCalendarToolTip({
|
||||
event: event,
|
||||
modelType: ModelType.company,
|
||||
instanceLookup: 'customer_detail'
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<OrderCalendar
|
||||
model={ModelType.salesorder}
|
||||
role={UserRoles.sales_order}
|
||||
params={{ outstanding: true }}
|
||||
params={{ outstanding: true, customer_detail: true }}
|
||||
filters={calendarFilters}
|
||||
tooltip={renderTooltip}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -50,12 +61,21 @@ const ReturnOrderCalendar = () => {
|
|||
return SalesOrderFilters({ includeDateFilters: false });
|
||||
}, []);
|
||||
|
||||
const renderTooltip = useCallback((event: EventContentArg) => {
|
||||
return OrderCalendarToolTip({
|
||||
event: event,
|
||||
modelType: ModelType.company,
|
||||
instanceLookup: 'customer_detail'
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<OrderCalendar
|
||||
model={ModelType.returnorder}
|
||||
role={UserRoles.return_order}
|
||||
params={{ outstanding: true }}
|
||||
params={{ outstanding: true, customer_detail: true }}
|
||||
filters={calendarFilters}
|
||||
tooltip={renderTooltip}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,11 +8,13 @@ import type { PanelType } from '@lib/types/Panel';
|
|||
import { t } from '@lingui/core/macro';
|
||||
import { Group, Skeleton, Stack } from '@mantine/core';
|
||||
import {
|
||||
IconCalendar,
|
||||
IconInfoCircle,
|
||||
IconListDetails,
|
||||
IconPackages,
|
||||
IconSitemap,
|
||||
IconTable
|
||||
IconTable,
|
||||
IconTransfer
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
|
@ -20,6 +22,7 @@ import { api } from '../../App';
|
|||
import { useBarcodeScanDialog } from '../../components/barcodes/BarcodeScanDialog';
|
||||
import AdminButton from '../../components/buttons/AdminButton';
|
||||
import { PrintingActions } from '../../components/buttons/PrintingActions';
|
||||
import OrderCalendar from '../../components/calendar/OrderCalendar';
|
||||
import {
|
||||
type DetailsField,
|
||||
DetailsTable
|
||||
|
|
@ -48,11 +51,14 @@ import {
|
|||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
|
||||
import { useUserSettingsState } from '../../states/SettingsStates';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsStates';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { PartListTable } from '../../tables/part/PartTable';
|
||||
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
||||
import StockLocationParametricTable from '../../tables/stock/StockLocationParametricTable';
|
||||
import { StockLocationTable } from '../../tables/stock/StockLocationTable';
|
||||
import TransferOrderParametricTable from '../../tables/stock/TransferOrderParametricTable';
|
||||
import { TransferOrderTable } from '../../tables/stock/TransferOrderTable';
|
||||
|
||||
export default function Stock() {
|
||||
const { id: _id } = useParams();
|
||||
|
|
@ -65,6 +71,7 @@ export default function Stock() {
|
|||
const navigate = useNavigate();
|
||||
const user = useUserState();
|
||||
const settings = useUserSettingsState();
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
|
||||
const [treeOpen, setTreeOpen] = useState(false);
|
||||
|
||||
|
|
@ -169,6 +176,7 @@ export default function Stock() {
|
|||
}, [location, instanceQuery]);
|
||||
|
||||
const [sublocationView, setSublocationView] = useState<string>('table');
|
||||
const [transferOrderView, setTransferOrderView] = useState<string>('table');
|
||||
|
||||
const locationPanels: PanelType[] = useMemo(() => {
|
||||
return [
|
||||
|
|
@ -219,6 +227,42 @@ export default function Stock() {
|
|||
/>
|
||||
)
|
||||
},
|
||||
SegmentedControlPanel({
|
||||
name: 'transfer-orders',
|
||||
label: t`Transfer Orders`,
|
||||
icon: <IconTransfer />,
|
||||
hidden:
|
||||
!user.hasViewRole(UserRoles.transfer_order) ||
|
||||
!globalSettings.isSet('TRANSFERORDER_ENABLED'),
|
||||
selection: transferOrderView,
|
||||
onChange: setTransferOrderView,
|
||||
options: [
|
||||
{
|
||||
value: 'table',
|
||||
label: t`Table View`,
|
||||
icon: <IconTable />,
|
||||
content: <TransferOrderTable />
|
||||
},
|
||||
{
|
||||
value: 'calendar',
|
||||
label: t`Calendar View`,
|
||||
icon: <IconCalendar />,
|
||||
content: (
|
||||
<OrderCalendar
|
||||
model={ModelType.transferorder}
|
||||
role={UserRoles.transfer_order}
|
||||
params={{ outstanding: true }}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'parametric',
|
||||
label: t`Parametric View`,
|
||||
icon: <IconListDetails />,
|
||||
content: <TransferOrderParametricTable />
|
||||
}
|
||||
]
|
||||
}),
|
||||
{
|
||||
name: 'default_parts',
|
||||
label: t`Default Parts`,
|
||||
|
|
@ -240,7 +284,7 @@ export default function Stock() {
|
|||
hidden: !location.pk
|
||||
})
|
||||
];
|
||||
}, [sublocationView, location, id]);
|
||||
}, [sublocationView, transferOrderView, location, id]);
|
||||
|
||||
const editLocation = useEditApiFormModal({
|
||||
url: ApiEndpoints.stock_location_list,
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ import InstalledItemsTable from '../../tables/stock/InstalledItemsTable';
|
|||
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
||||
import StockItemTestResultTable from '../../tables/stock/StockItemTestResultTable';
|
||||
import { StockTrackingTable } from '../../tables/stock/StockTrackingTable';
|
||||
import TransferOrderAllocationTable from '../../tables/stock/TransferOrderAllocationTable';
|
||||
|
||||
export default function StockDetail() {
|
||||
const { id } = useParams();
|
||||
|
|
@ -476,6 +477,13 @@ export default function StockDetail() {
|
|||
return stockitem?.part_detail?.salable;
|
||||
}, [stockitem]);
|
||||
|
||||
const showTransferAllocations: boolean = useMemo(() => {
|
||||
return (
|
||||
!stockitem?.part_detail?.virtual &&
|
||||
globalSettings.isSet('TRANSFERORDER_ENABLED')
|
||||
);
|
||||
}, [stockitem]);
|
||||
|
||||
// API query to determine if this stock item has trackable BOM items
|
||||
const trackedBomItemQuery = useQuery({
|
||||
queryKey: ['tracked-bom-item', stockitem.pk, stockitem.part],
|
||||
|
|
@ -544,11 +552,17 @@ export default function StockDetail() {
|
|||
icon: <IconBookmark />,
|
||||
hidden:
|
||||
!stockitem.in_stock ||
|
||||
(!showSalesAllocations && !showBuildAllocations),
|
||||
(!showSalesAllocations &&
|
||||
!showBuildAllocations &&
|
||||
!showTransferAllocations),
|
||||
content: (
|
||||
<Accordion
|
||||
multiple={true}
|
||||
defaultValue={['buildAllocations', 'salesAllocations']}
|
||||
defaultValue={[
|
||||
'buildAllocations',
|
||||
'salesAllocations',
|
||||
'transferAllocations'
|
||||
]}
|
||||
>
|
||||
{showBuildAllocations && (
|
||||
<Accordion.Item value='buildAllocations' key='buildAllocations'>
|
||||
|
|
@ -580,6 +594,24 @@ export default function StockDetail() {
|
|||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
)}
|
||||
{showTransferAllocations && (
|
||||
<Accordion.Item
|
||||
value='transferAllocations'
|
||||
key='transferAllocations'
|
||||
>
|
||||
<Accordion.Control>
|
||||
<StylishText size='lg'>{t`Transfer Order Allocations`}</StylishText>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<TransferOrderAllocationTable
|
||||
stockId={stockitem.pk}
|
||||
modelField='order'
|
||||
modelTarget={ModelType.transferorder}
|
||||
showOrderInfo
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
)}
|
||||
</Accordion>
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,552 @@
|
|||
import { t } from '@lingui/core/macro';
|
||||
import { Grid, Skeleton, Stack } from '@mantine/core';
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
import { type PanelType, apiUrl } from '@lib/index';
|
||||
import {
|
||||
IconBookmark,
|
||||
IconInfoCircle,
|
||||
IconList,
|
||||
IconListCheck
|
||||
} from '@tabler/icons-react';
|
||||
import AdminButton from '../../components/buttons/AdminButton';
|
||||
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
|
||||
import { PrintingActions } from '../../components/buttons/PrintingActions';
|
||||
import {
|
||||
type DetailsField,
|
||||
DetailsTable
|
||||
} from '../../components/details/Details';
|
||||
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
|
||||
import {
|
||||
BarcodeActionDropdown,
|
||||
CancelItemAction,
|
||||
DuplicateItemAction,
|
||||
EditItemAction,
|
||||
HoldItemAction,
|
||||
OptionsActionDropdown
|
||||
} from '../../components/items/ActionDropdown';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import AttachmentPanel from '../../components/panels/AttachmentPanel';
|
||||
import NotesPanel from '../../components/panels/NotesPanel';
|
||||
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||
import ParametersPanel from '../../components/panels/ParametersPanel';
|
||||
import { StatusRenderer } from '../../components/render/StatusRenderer';
|
||||
import { useTransferOrderFields } from '../../forms/TransferOrderForms';
|
||||
import {
|
||||
useCreateApiFormModal,
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import useStatusCodes from '../../hooks/UseStatusCodes';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsStates';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import TransferOrderAllocationTable from '../../tables/stock/TransferOrderAllocationTable';
|
||||
import TransferOrderLineItemTable from '../../tables/stock/TransferOrderLineItemTable';
|
||||
|
||||
export default function TransferOrderDetail() {
|
||||
const { id } = useParams();
|
||||
|
||||
const user = useUserState();
|
||||
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
|
||||
const {
|
||||
instance: order,
|
||||
instanceQuery,
|
||||
refreshInstance
|
||||
} = useInstance({
|
||||
endpoint: ApiEndpoints.transfer_order_list,
|
||||
pk: id,
|
||||
params: {}
|
||||
});
|
||||
|
||||
const toStatus = useStatusCodes({ modelType: ModelType.transferorder });
|
||||
|
||||
const lineItemsEditable: boolean = useMemo(() => {
|
||||
const orderOpen: boolean =
|
||||
order.status != toStatus.COMPLETE && order.status != toStatus.CANCELLED;
|
||||
|
||||
return orderOpen;
|
||||
// TODO: does this setting make any sense for Transfer Orders???
|
||||
// if (orderOpen) {
|
||||
// return true;
|
||||
// } else {
|
||||
// return globalSettings.isSet('TRANSFERORDER_EDIT_COMPLETED_ORDERS');
|
||||
// }
|
||||
}, [globalSettings, order.status, toStatus]);
|
||||
|
||||
// for now, only permit editing allocations when line items can be edited
|
||||
const allocationsEditable = lineItemsEditable;
|
||||
|
||||
const orderOpen = useMemo(() => {
|
||||
return (
|
||||
order.status == toStatus.PENDING ||
|
||||
order.status == toStatus.ISSUED ||
|
||||
order.status == toStatus.ON_HOLD
|
||||
);
|
||||
}, [order, toStatus]);
|
||||
|
||||
const detailsPanel = useMemo(() => {
|
||||
if (instanceQuery.isFetching) {
|
||||
return <Skeleton />;
|
||||
}
|
||||
|
||||
const tl: DetailsField[] = [
|
||||
{
|
||||
type: 'text',
|
||||
name: 'reference',
|
||||
label: t`Reference`,
|
||||
copy: true
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
name: 'take_from',
|
||||
icon: 'location',
|
||||
label: t`Source Location`,
|
||||
model: ModelType.stocklocation
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
name: 'destination',
|
||||
icon: 'location',
|
||||
label: t`Destination Location`,
|
||||
model: ModelType.stocklocation
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'description',
|
||||
label: t`Description`,
|
||||
copy: true
|
||||
},
|
||||
{
|
||||
type: 'status',
|
||||
name: 'status',
|
||||
label: t`Status`,
|
||||
model: ModelType.transferorder
|
||||
},
|
||||
{
|
||||
type: 'status',
|
||||
name: 'status_custom_key',
|
||||
label: t`Custom Status`,
|
||||
model: ModelType.transferorder,
|
||||
icon: 'status',
|
||||
hidden:
|
||||
!order.status_custom_key || order.status_custom_key == order.status
|
||||
}
|
||||
];
|
||||
|
||||
const tr: DetailsField[] = [
|
||||
{
|
||||
type: 'boolean',
|
||||
name: 'consume',
|
||||
icon: 'consume',
|
||||
label: t`Consume Stock`
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'line_items',
|
||||
label: t`Line Items`,
|
||||
icon: 'list'
|
||||
},
|
||||
{
|
||||
type: 'progressbar',
|
||||
name: 'completed',
|
||||
icon: 'progress',
|
||||
label: t`Completed Line Items`,
|
||||
total: order.line_items,
|
||||
progress: order.completed_lines
|
||||
}
|
||||
];
|
||||
|
||||
const bl: DetailsField[] = [
|
||||
{
|
||||
type: 'link',
|
||||
external: true,
|
||||
name: 'link',
|
||||
label: t`Link`,
|
||||
copy: true,
|
||||
hidden: !order.link
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'project_code_label',
|
||||
label: t`Project Code`,
|
||||
icon: 'reference',
|
||||
copy: true,
|
||||
hidden: !order.project_code
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'responsible',
|
||||
label: t`Responsible`,
|
||||
badge: 'owner',
|
||||
hidden: !order.responsible
|
||||
}
|
||||
];
|
||||
|
||||
const br: DetailsField[] = [
|
||||
{
|
||||
type: 'date',
|
||||
name: 'creation_date',
|
||||
label: t`Creation Date`,
|
||||
icon: 'calendar',
|
||||
copy: true,
|
||||
hidden: !order.creation_date
|
||||
},
|
||||
{
|
||||
type: 'date',
|
||||
name: 'issue_date',
|
||||
label: t`Issue Date`,
|
||||
icon: 'calendar',
|
||||
copy: true,
|
||||
hidden: !order.issue_date
|
||||
},
|
||||
{
|
||||
type: 'date',
|
||||
name: 'start_date',
|
||||
label: t`Start Date`,
|
||||
icon: 'calendar',
|
||||
copy: true,
|
||||
hidden: !order.start_date
|
||||
},
|
||||
{
|
||||
type: 'date',
|
||||
name: 'target_date',
|
||||
label: t`Target Date`,
|
||||
copy: true,
|
||||
hidden: !order.target_date
|
||||
},
|
||||
{
|
||||
type: 'date',
|
||||
name: 'complete_date',
|
||||
icon: 'calendar_check',
|
||||
label: t`Completion Date`,
|
||||
copy: true,
|
||||
hidden: !order.complete_date
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<ItemDetailsGrid>
|
||||
<Grid grow>
|
||||
{/* TODO: what image do we show for a Transfer Order? */}
|
||||
{/* <DetailsImage
|
||||
appRole={UserRoles.transfer_order}
|
||||
apiPath={ApiEndpoints.transfer_order_list}
|
||||
src="/static/img/blank_image.png"
|
||||
pk={order.pk}
|
||||
/> */}
|
||||
<Grid.Col span={{ base: 12, sm: 8 }}>
|
||||
<DetailsTable fields={tl} item={order} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<DetailsTable fields={tr} item={order} />
|
||||
<DetailsTable fields={bl} item={order} />
|
||||
<DetailsTable fields={br} item={order} />
|
||||
</ItemDetailsGrid>
|
||||
);
|
||||
}, [order, instanceQuery]);
|
||||
|
||||
const orderPanels: PanelType[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
name: 'detail',
|
||||
label: t`Order Details`,
|
||||
icon: <IconInfoCircle />,
|
||||
content: detailsPanel
|
||||
},
|
||||
{
|
||||
name: 'line-items',
|
||||
label: t`Line Items`,
|
||||
icon: <IconList />,
|
||||
content: (
|
||||
<TransferOrderLineItemTable
|
||||
orderId={order.pk}
|
||||
sourceLocationId={order.take_from}
|
||||
orderDetailRefresh={refreshInstance}
|
||||
editable={lineItemsEditable}
|
||||
/>
|
||||
// TODO: add back the accordion if we need extra lines
|
||||
// <Accordion
|
||||
// multiple={true}
|
||||
// defaultValue={[
|
||||
// 'line-items',
|
||||
// // 'extra-items'
|
||||
// ]}
|
||||
// >
|
||||
// <Accordion.Item value='line-items' key='lineitems'>
|
||||
// <Accordion.Control>
|
||||
// <StylishText size='lg'>{t`Line Items`}</StylishText>
|
||||
// </Accordion.Control>
|
||||
// <Accordion.Panel>
|
||||
// <TransferOrderLineItemTable
|
||||
// orderId={order.pk}
|
||||
// orderDetailRefresh={refreshInstance}
|
||||
// editable={lineItemsEditable}
|
||||
// />
|
||||
// </Accordion.Panel>
|
||||
// </Accordion.Item>
|
||||
// {/* <Accordion.Item value='extra-items' key='extraitems'>
|
||||
// <Accordion.Control>
|
||||
// <StylishText size='lg'>{t`Extra Line Items`}</StylishText>
|
||||
// </Accordion.Control>
|
||||
// <Accordion.Panel>
|
||||
// <ExtraLineItemTable
|
||||
// endpoint={ApiEndpoints.sales_order_extra_line_list}
|
||||
// orderId={order.pk}
|
||||
// editable={lineItemsEditable}
|
||||
// orderDetailRefresh={refreshInstance}
|
||||
// currency={orderCurrency}
|
||||
// role={UserRoles.sales_order}
|
||||
// />
|
||||
// </Accordion.Panel>
|
||||
// </Accordion.Item> */}
|
||||
// </Accordion>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'allocations',
|
||||
label:
|
||||
order.status != toStatus.COMPLETE
|
||||
? t`Allocated Stock`
|
||||
: t`Transferred Stock`,
|
||||
icon:
|
||||
order.status != toStatus.COMPLETE ? (
|
||||
<IconBookmark />
|
||||
) : (
|
||||
<IconListCheck />
|
||||
),
|
||||
content: (
|
||||
<TransferOrderAllocationTable
|
||||
orderId={order.pk}
|
||||
showPartInfo
|
||||
allowEdit={allocationsEditable}
|
||||
modelField='item'
|
||||
modelTarget={ModelType.stockitem}
|
||||
/>
|
||||
)
|
||||
},
|
||||
ParametersPanel({
|
||||
model_type: ModelType.transferorder,
|
||||
model_id: order.pk
|
||||
}),
|
||||
AttachmentPanel({
|
||||
model_type: ModelType.transferorder,
|
||||
model_id: order.pk
|
||||
}),
|
||||
NotesPanel({
|
||||
model_type: ModelType.transferorder,
|
||||
model_id: order.pk
|
||||
})
|
||||
];
|
||||
}, [order, id, user]);
|
||||
|
||||
const orderBadges: ReactNode[] = useMemo(() => {
|
||||
return instanceQuery.isLoading
|
||||
? []
|
||||
: [
|
||||
<StatusRenderer
|
||||
status={order.status_custom_key}
|
||||
type={ModelType.transferorder}
|
||||
options={{ size: 'lg' }}
|
||||
/>
|
||||
];
|
||||
}, [order, instanceQuery]);
|
||||
|
||||
const transferOrderFields = useTransferOrderFields({});
|
||||
|
||||
const duplicateTransferOrderFields = useTransferOrderFields({
|
||||
duplicateOrderId: order.pk
|
||||
});
|
||||
|
||||
const editTransferOrder = useEditApiFormModal({
|
||||
url: ApiEndpoints.transfer_order_list,
|
||||
pk: order.pk,
|
||||
title: t`Edit Transfer Order`,
|
||||
fields: transferOrderFields,
|
||||
onFormSuccess: () => {
|
||||
refreshInstance();
|
||||
}
|
||||
});
|
||||
|
||||
const duplicateTransferOrderInitialData = useMemo(() => {
|
||||
const data = { ...order };
|
||||
// if we set the reference to null/undefined, it will be left blank in the form
|
||||
// if we omit the reference altogether, it will be auto-generated via reference pattern
|
||||
// from the OPTIONS response
|
||||
delete data.reference;
|
||||
return data;
|
||||
}, [order]);
|
||||
|
||||
const duplicateTransferOrder = useCreateApiFormModal({
|
||||
url: ApiEndpoints.transfer_order_list,
|
||||
title: t`Add Transfer Order`,
|
||||
fields: duplicateTransferOrderFields,
|
||||
initialData: duplicateTransferOrderInitialData,
|
||||
modelType: ModelType.transferorder,
|
||||
follow: true
|
||||
});
|
||||
|
||||
const issueOrder = useCreateApiFormModal({
|
||||
url: apiUrl(ApiEndpoints.transfer_order_issue, order.pk),
|
||||
title: t`Issue Transfer Order`,
|
||||
onFormSuccess: refreshInstance,
|
||||
preFormWarning: t`Issue this order`,
|
||||
successMessage: t`Order issued`
|
||||
});
|
||||
|
||||
const cancelOrder = useCreateApiFormModal({
|
||||
url: apiUrl(ApiEndpoints.transfer_order_cancel, order.pk),
|
||||
title: t`Cancel Transfer Order`,
|
||||
onFormSuccess: refreshInstance,
|
||||
preFormWarning: t`Cancel this order`,
|
||||
successMessage: t`Order cancelled`
|
||||
});
|
||||
|
||||
const holdOrder = useCreateApiFormModal({
|
||||
url: apiUrl(ApiEndpoints.transfer_order_hold, order.pk),
|
||||
title: t`Hold Transfer Order`,
|
||||
onFormSuccess: refreshInstance,
|
||||
preFormWarning: t`Place this order on hold`,
|
||||
successMessage: t`Order placed on hold`
|
||||
});
|
||||
|
||||
const completeOrder = useCreateApiFormModal({
|
||||
url: apiUrl(ApiEndpoints.transfer_order_complete, order.pk),
|
||||
title: t`Complete Transfer Order`,
|
||||
onFormSuccess: refreshInstance,
|
||||
preFormWarning: t`Mark this order as complete`,
|
||||
successMessage: t`Order completed`,
|
||||
fields: {
|
||||
accept_incomplete_allocation: {}
|
||||
}
|
||||
});
|
||||
|
||||
const orderActions = useMemo(() => {
|
||||
const canEdit: boolean = user.hasChangeRole(UserRoles.transfer_order);
|
||||
|
||||
const canIssue: boolean =
|
||||
canEdit &&
|
||||
(order.status == toStatus.PENDING || order.status == toStatus.ON_HOLD);
|
||||
|
||||
const canHold: boolean =
|
||||
canEdit &&
|
||||
(order.status == toStatus.PENDING || order.status == toStatus.ISSUED);
|
||||
|
||||
const canCancel: boolean =
|
||||
canEdit &&
|
||||
(order.status == toStatus.PENDING || order.status == toStatus.ON_HOLD);
|
||||
|
||||
const canComplete: boolean = canEdit && order.status == toStatus.ISSUED;
|
||||
|
||||
return [
|
||||
<PrimaryActionButton
|
||||
title={t`Issue Order`}
|
||||
icon='issue'
|
||||
hidden={!canIssue}
|
||||
color='blue'
|
||||
onClick={() => issueOrder.open()}
|
||||
/>,
|
||||
<PrimaryActionButton
|
||||
title={t`Complete Order`}
|
||||
icon='complete'
|
||||
hidden={!canComplete}
|
||||
color='green'
|
||||
onClick={() => completeOrder.open()}
|
||||
/>,
|
||||
<AdminButton model={ModelType.transferorder} id={order.pk} />,
|
||||
<BarcodeActionDropdown
|
||||
model={ModelType.transferorder}
|
||||
pk={order.pk}
|
||||
hash={order?.barcode_hash}
|
||||
/>,
|
||||
<PrintingActions
|
||||
modelType={ModelType.transferorder}
|
||||
items={[order.pk]}
|
||||
enableReports
|
||||
enableLabels
|
||||
/>,
|
||||
<OptionsActionDropdown
|
||||
tooltip={t`Order Actions`}
|
||||
actions={[
|
||||
EditItemAction({
|
||||
hidden: !user.hasChangeRole(UserRoles.transfer_order),
|
||||
tooltip: t`Edit order`,
|
||||
onClick: () => {
|
||||
editTransferOrder.open();
|
||||
}
|
||||
}),
|
||||
DuplicateItemAction({
|
||||
tooltip: t`Duplicate order`,
|
||||
hidden: !user.hasChangeRole(UserRoles.transfer_order),
|
||||
onClick: () => duplicateTransferOrder.open()
|
||||
}),
|
||||
HoldItemAction({
|
||||
tooltip: t`Hold order`,
|
||||
hidden: !canHold,
|
||||
onClick: () => holdOrder.open()
|
||||
}),
|
||||
CancelItemAction({
|
||||
tooltip: t`Cancel order`,
|
||||
hidden: !canCancel,
|
||||
onClick: () => cancelOrder.open()
|
||||
})
|
||||
]}
|
||||
/>
|
||||
];
|
||||
}, [user, order, orderOpen, toStatus]);
|
||||
|
||||
const subtitle: string = useMemo(() => {
|
||||
const t = order.take_from_detail?.pathstring || '';
|
||||
const d = order.destination_detail?.pathstring || '';
|
||||
return `${t} → ${d}`;
|
||||
}, [order]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{editTransferOrder.modal}
|
||||
{issueOrder.modal}
|
||||
{cancelOrder.modal}
|
||||
{holdOrder.modal}
|
||||
{completeOrder.modal}
|
||||
{duplicateTransferOrder.modal}
|
||||
<InstanceDetail
|
||||
query={instanceQuery}
|
||||
requiredRole={UserRoles.transfer_order}
|
||||
>
|
||||
<Stack gap='xs'>
|
||||
<PageDetail
|
||||
title={`${t`Transfer Order`}: ${order.reference}`}
|
||||
subtitle={subtitle}
|
||||
// What should be the Transfer Order image?
|
||||
// imageUrl={order.customer_detail?.image}
|
||||
badges={orderBadges}
|
||||
actions={orderActions}
|
||||
breadcrumbs={[{ name: t`Stock`, url: '/stock/' }]}
|
||||
lastCrumb={[
|
||||
{
|
||||
name: order.reference,
|
||||
url: `/stock/transfer-order/${order.pk}`
|
||||
}
|
||||
]}
|
||||
editAction={editTransferOrder.open}
|
||||
editEnabled={user.hasChangePermission(ModelType.transferorder)}
|
||||
/>
|
||||
<PanelGroup
|
||||
pageKey='transferorder'
|
||||
panels={orderPanels}
|
||||
model={ModelType.transferorder}
|
||||
reloadInstance={instanceQuery.refetch}
|
||||
instance={order}
|
||||
id={order.pk}
|
||||
/>
|
||||
</Stack>
|
||||
</InstanceDetail>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -88,6 +88,10 @@ export const ReturnOrderDetail = Loadable(
|
|||
lazy(() => import('./pages/sales/ReturnOrderDetail'))
|
||||
);
|
||||
|
||||
export const TransferOrderDetail = Loadable(
|
||||
lazy(() => import('./pages/stock/TransferOrderDetail'))
|
||||
);
|
||||
|
||||
export const Scan = Loadable(lazy(() => import('./pages/Index/Scan')));
|
||||
|
||||
export const ErrorPage = Loadable(lazy(() => import('./pages/ErrorPage')));
|
||||
|
|
@ -169,6 +173,7 @@ export const routes = (
|
|||
<Route index element={<Navigate to='location/index/' />} />
|
||||
<Route path='location/:id?/*' element={<LocationDetail />} />
|
||||
<Route path='item/:id/*' element={<StockDetail />} />
|
||||
<Route path='transfer-order/:id/*' element={<TransferOrderDetail />} />
|
||||
</Route>
|
||||
<Route path='manufacturing/'>
|
||||
<Route index element={<Navigate to='index/' />} />
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ pushes changes in user profile to backend
|
|||
export function patchUser(key: 'language' | 'theme' | 'widgets', val: any) {
|
||||
const uid = useUserState.getState().userId();
|
||||
if (uid) {
|
||||
api.patch(apiUrl(ApiEndpoints.user_profile), { [key]: val });
|
||||
api.patch(apiUrl(ApiEndpoints.user_me_profile), { [key]: val });
|
||||
} else {
|
||||
console.log('user not logged in, not patching');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ export const useUserState = create<UserStateProps>((set, get) => ({
|
|||
|
||||
// Fetch role data
|
||||
await api
|
||||
.get(apiUrl(ApiEndpoints.user_roles))
|
||||
.get(apiUrl(ApiEndpoints.user_me_roles))
|
||||
.then((response) => {
|
||||
if (response.status == 200) {
|
||||
const user: UserProps = get().user as UserProps;
|
||||
|
|
|
|||
|
|
@ -276,6 +276,7 @@ export default function InvenTreeTableHeader({
|
|||
<Divider />
|
||||
{tableState.filterSet.activeFilters?.map((filter) => (
|
||||
<FilterPreview
|
||||
key={filter.name}
|
||||
filter={filter}
|
||||
filters={tableProps.tableFilters}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -536,7 +536,10 @@ export default function BuildLineTable({
|
|||
minWidth: 125,
|
||||
render: (record: any) => {
|
||||
return record?.bom_item_detail?.consumable ? (
|
||||
<Text style={{ fontStyle: 'italic' }}>{t`Consumable item`}</Text>
|
||||
<Text
|
||||
size='sm'
|
||||
style={{ fontStyle: 'italic' }}
|
||||
>{t`Consumable item`}</Text>
|
||||
) : (
|
||||
<ProgressBar
|
||||
progressLabel={true}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,12 @@ function attachmentTableColumns(): TableColumn[] {
|
|||
noWrap: true,
|
||||
render: (record: any) => {
|
||||
if (record.attachment) {
|
||||
return <AttachmentLink attachment={record.attachment} />;
|
||||
return (
|
||||
<AttachmentLink
|
||||
thumbnail={record.thumbnail}
|
||||
attachment={record.attachment}
|
||||
/>
|
||||
);
|
||||
} else if (record.link) {
|
||||
return <AttachmentLink attachment={record.link} external />;
|
||||
} else {
|
||||
|
|
@ -300,6 +305,11 @@ export function AttachmentTable({
|
|||
name: 'is_file',
|
||||
label: t`Is File`,
|
||||
description: t`Show file attachments`
|
||||
},
|
||||
{
|
||||
name: 'is_image',
|
||||
label: t`Is Image`,
|
||||
description: t`Show image attachments`
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export function ApiTokenTable({
|
|||
const [opened, { open, close }] = useDisclosure(false);
|
||||
|
||||
const generateToken = useCreateApiFormModal({
|
||||
url: ApiEndpoints.user_token,
|
||||
url: ApiEndpoints.user_me_token,
|
||||
method: 'GET',
|
||||
title: t`Generate Token`,
|
||||
fields: { name: {} },
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ import { RenderCompany } from '../../components/render/Company';
|
|||
import {
|
||||
RenderPurchaseOrder,
|
||||
RenderReturnOrder,
|
||||
RenderSalesOrder
|
||||
RenderSalesOrder,
|
||||
RenderTransferOrder
|
||||
} from '../../components/render/Order';
|
||||
import { RenderPart } from '../../components/render/Part';
|
||||
import { StatusRenderer } from '../../components/render/StatusRenderer';
|
||||
|
|
@ -181,6 +182,17 @@ export function StockTrackingTable({
|
|||
navigate: navigate
|
||||
})
|
||||
},
|
||||
{
|
||||
label: t`Transfer Order`,
|
||||
key: 'transferorder',
|
||||
details:
|
||||
deltas.transferorder_detail &&
|
||||
RenderTransferOrder({
|
||||
instance: deltas.transferorder_detail,
|
||||
link: true,
|
||||
navigate: navigate
|
||||
})
|
||||
},
|
||||
{
|
||||
label: t`Customer`,
|
||||
key: 'customer',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,285 @@
|
|||
import { type RowAction, RowEditAction } from '@lib/components/RowActions';
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import useTable from '@lib/hooks/UseTable';
|
||||
import type { TableFilter } from '@lib/types/Filters';
|
||||
import type { StockOperationProps } from '@lib/types/Forms';
|
||||
import type { TableColumn } from '@lib/types/Tables';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Alert } from '@mantine/core';
|
||||
import { IconCircleX } from '@tabler/icons-react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useTransferOrderAllocationFields } from '../../forms/TransferOrderForms';
|
||||
import {
|
||||
useDeleteApiFormModal,
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import {
|
||||
DescriptionColumn,
|
||||
LocationColumn,
|
||||
PartColumn,
|
||||
ReferenceColumn,
|
||||
StatusColumn
|
||||
} from '../ColumnRenderers';
|
||||
import { IncludeVariantsFilter, StockLocationFilter } from '../Filter';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
|
||||
export default function TransferOrderAllocationTable({
|
||||
partId,
|
||||
stockId,
|
||||
orderId,
|
||||
lineItemId,
|
||||
showPartInfo,
|
||||
showOrderInfo,
|
||||
allowEdit,
|
||||
isSubTable,
|
||||
modelTarget,
|
||||
modelField
|
||||
}: Readonly<{
|
||||
partId?: number;
|
||||
stockId?: number;
|
||||
orderId?: number;
|
||||
lineItemId?: number;
|
||||
showPartInfo?: boolean;
|
||||
showOrderInfo?: boolean;
|
||||
allowEdit?: boolean;
|
||||
isSubTable?: boolean;
|
||||
modelTarget?: ModelType;
|
||||
modelField?: string;
|
||||
}>) {
|
||||
const user = useUserState();
|
||||
|
||||
const tableId = useMemo(() => {
|
||||
let id = 'transferorderallocations';
|
||||
|
||||
if (!!partId) {
|
||||
id += '-part';
|
||||
}
|
||||
|
||||
if (isSubTable) {
|
||||
id += '-sub';
|
||||
}
|
||||
|
||||
return id;
|
||||
}, [partId, isSubTable]);
|
||||
|
||||
const table = useTable(tableId);
|
||||
|
||||
const [selectedAllocation, setSelectedAllocation] = useState<number>(0);
|
||||
|
||||
const tableFilters: TableFilter[] = useMemo(() => {
|
||||
const filters: TableFilter[] = [
|
||||
{
|
||||
name: 'outstanding',
|
||||
label: t`Outstanding`,
|
||||
description: t`Show outstanding allocations`
|
||||
},
|
||||
StockLocationFilter()
|
||||
];
|
||||
|
||||
if (!!partId) {
|
||||
filters.push(IncludeVariantsFilter());
|
||||
}
|
||||
|
||||
return filters;
|
||||
}, [partId]);
|
||||
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
return [
|
||||
ReferenceColumn({
|
||||
accessor: 'order_detail.reference',
|
||||
title: t`Transfer Order`,
|
||||
switchable: false,
|
||||
sortable: true,
|
||||
hidden: showOrderInfo != true
|
||||
}),
|
||||
DescriptionColumn({
|
||||
accessor: 'order_detail.description',
|
||||
hidden: showOrderInfo != true
|
||||
}),
|
||||
StatusColumn({
|
||||
accessor: 'order_detail.status',
|
||||
model: ModelType.transferorder,
|
||||
title: t`Order Status`,
|
||||
hidden: showOrderInfo != true
|
||||
}),
|
||||
PartColumn({
|
||||
hidden: showPartInfo != true,
|
||||
part: 'part_detail'
|
||||
}),
|
||||
DescriptionColumn({
|
||||
accessor: 'part_detail.description',
|
||||
hidden: showPartInfo != true
|
||||
}),
|
||||
{
|
||||
accessor: 'part_detail.IPN',
|
||||
title: t`IPN`,
|
||||
hidden: showPartInfo != true,
|
||||
sortable: true,
|
||||
ordering: 'IPN'
|
||||
},
|
||||
{
|
||||
accessor: 'serial',
|
||||
title: t`Serial Number`,
|
||||
sortable: true,
|
||||
switchable: true,
|
||||
render: (record: any) => record?.item_detail?.serial
|
||||
},
|
||||
{
|
||||
accessor: 'batch',
|
||||
title: t`Batch Code`,
|
||||
sortable: true,
|
||||
switchable: true,
|
||||
render: (record: any) => record?.item_detail?.batch
|
||||
},
|
||||
{
|
||||
accessor: 'available',
|
||||
title: t`Available Quantity`,
|
||||
sortable: false,
|
||||
hidden: isSubTable,
|
||||
render: (record: any) => record?.item_detail?.quantity
|
||||
},
|
||||
{
|
||||
accessor: 'quantity',
|
||||
title: t`Allocated Quantity`,
|
||||
sortable: true
|
||||
},
|
||||
LocationColumn({
|
||||
accessor: 'location_detail',
|
||||
switchable: true,
|
||||
sortable: true
|
||||
})
|
||||
];
|
||||
}, [showOrderInfo, showPartInfo, isSubTable]);
|
||||
|
||||
const editAllocationFields = useTransferOrderAllocationFields({
|
||||
orderId: orderId
|
||||
});
|
||||
|
||||
const editAllocation = useEditApiFormModal({
|
||||
url: ApiEndpoints.transfer_order_allocation_list,
|
||||
pk: selectedAllocation,
|
||||
fields: editAllocationFields,
|
||||
title: t`Edit Allocation`,
|
||||
onFormSuccess: () => table.refreshTable()
|
||||
});
|
||||
|
||||
const deleteAllocation = useDeleteApiFormModal({
|
||||
url: ApiEndpoints.transfer_order_allocation_list,
|
||||
pk: selectedAllocation,
|
||||
title: t`Remove Allocated Stock`,
|
||||
preFormContent: (
|
||||
<Alert color='red' title={t`Confirm Removal`}>
|
||||
{t`Are you sure you want to remove this allocated stock from the order?`}
|
||||
</Alert>
|
||||
),
|
||||
submitText: t`Remove`,
|
||||
onFormSuccess: () => table.refreshTable()
|
||||
});
|
||||
|
||||
const rowActions = useCallback(
|
||||
(record: any): RowAction[] => {
|
||||
return [
|
||||
RowEditAction({
|
||||
tooltip: t`Edit Allocation`,
|
||||
hidden: !allowEdit || !user.hasChangeRole(UserRoles.transfer_order),
|
||||
onClick: () => {
|
||||
setSelectedAllocation(record.pk);
|
||||
editAllocation.open();
|
||||
}
|
||||
}),
|
||||
{
|
||||
title: t`Remove`,
|
||||
tooltip: t`Remove allocated stock`,
|
||||
icon: <IconCircleX />,
|
||||
color: 'red',
|
||||
hidden: !allowEdit || !user.hasDeleteRole(UserRoles.transfer_order),
|
||||
onClick: () => {
|
||||
setSelectedAllocation(record.pk);
|
||||
deleteAllocation.open();
|
||||
}
|
||||
}
|
||||
];
|
||||
},
|
||||
[allowEdit, user]
|
||||
);
|
||||
|
||||
const stockOperationProps: StockOperationProps = useMemo(() => {
|
||||
// Extract stock items from the selected records
|
||||
// Note that the table is actually a list of TransferOrderAllocation instances,
|
||||
// so we need to reconstruct the stock item details
|
||||
const stockItems: any[] = table.selectedRecords
|
||||
.filter((item: any) => !!item.item_detail)
|
||||
.map((item: any) => {
|
||||
return {
|
||||
...item.item_detail,
|
||||
part_detail: item.part_detail,
|
||||
location_detail: item.location_detail
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
items: stockItems,
|
||||
model: ModelType.stockitem,
|
||||
refresh: table.refreshTable
|
||||
};
|
||||
}, [table.selectedRecords, table.refreshTable]);
|
||||
|
||||
const stockAdjustActions = useStockAdjustActions({
|
||||
formProps: stockOperationProps,
|
||||
merge: false,
|
||||
assign: false,
|
||||
delete: false,
|
||||
add: false,
|
||||
count: false,
|
||||
remove: false
|
||||
});
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
return [stockAdjustActions.dropdown];
|
||||
}, [allowEdit, orderId, user, stockAdjustActions.dropdown]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{editAllocation.modal}
|
||||
{deleteAllocation.modal}
|
||||
{!isSubTable && stockAdjustActions.modals.map((modal) => modal.modal)}
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.transfer_order_allocation_list)}
|
||||
tableState={table}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
params: {
|
||||
part_detail: showPartInfo ?? false,
|
||||
order_detail: showOrderInfo ?? false,
|
||||
item_detail: true,
|
||||
location_detail: true,
|
||||
line: lineItemId,
|
||||
part: partId,
|
||||
order: orderId,
|
||||
item: stockId
|
||||
},
|
||||
enableSearch: !isSubTable,
|
||||
enableRefresh: !isSubTable,
|
||||
enableColumnSwitching: !isSubTable,
|
||||
enableFilters: !isSubTable,
|
||||
enableDownload: !isSubTable,
|
||||
enableSelection: !isSubTable,
|
||||
minHeight: isSubTable ? 100 : undefined,
|
||||
rowActions: rowActions,
|
||||
tableActions: isSubTable ? undefined : tableActions,
|
||||
tableFilters: tableFilters,
|
||||
modelField: modelField ?? 'order',
|
||||
enableReports: !isSubTable,
|
||||
enableLabels: !isSubTable,
|
||||
printingAccessor: 'item',
|
||||
modelType: modelTarget ?? ModelType.transferorder
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,529 @@
|
|||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import useTable from '@lib/hooks/UseTable';
|
||||
import {
|
||||
ActionButton,
|
||||
AddItemButton,
|
||||
ModelType,
|
||||
ProgressBar,
|
||||
RowDeleteAction,
|
||||
RowDuplicateAction,
|
||||
RowEditAction,
|
||||
RowViewAction,
|
||||
UserRoles,
|
||||
formatDecimal
|
||||
} from '@lib/index';
|
||||
import type { TableFilter } from '@lib/types/Filters';
|
||||
import type { RowAction, TableColumn } from '@lib/types/Tables';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Group, Paper, Text } from '@mantine/core';
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconHash,
|
||||
IconShoppingCart,
|
||||
IconSquareArrowRight,
|
||||
IconTools
|
||||
} from '@tabler/icons-react';
|
||||
import type { DataTableRowExpansionProps } from 'mantine-datatable';
|
||||
import { type ReactNode, useCallback, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { RenderPart } from '../../components/render/Part';
|
||||
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
|
||||
import { useBuildOrderFields } from '../../forms/BuildForms';
|
||||
import {
|
||||
useAllocateToTransferOrderForm,
|
||||
useTransferOrderAllocateSerialsFields,
|
||||
useTransferOrderLineItemFields
|
||||
} from '../../forms/TransferOrderForms';
|
||||
import {
|
||||
useCreateApiFormModal,
|
||||
useDeleteApiFormModal,
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import {
|
||||
DateColumn,
|
||||
DecimalColumn,
|
||||
DescriptionColumn,
|
||||
LinkColumn,
|
||||
ProjectCodeColumn,
|
||||
RenderPartColumn
|
||||
} from '../ColumnRenderers';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
import RowExpansionIcon from '../RowExpansionIcon';
|
||||
import { TableHoverCard } from '../TableHoverCard';
|
||||
import TransferOrderAllocationTable from './TransferOrderAllocationTable';
|
||||
|
||||
export default function TransferOrderLineItemTable({
|
||||
orderId,
|
||||
sourceLocationId,
|
||||
orderDetailRefresh,
|
||||
editable
|
||||
}: Readonly<{
|
||||
orderId: number;
|
||||
sourceLocationId?: number;
|
||||
orderDetailRefresh: () => void;
|
||||
editable: boolean;
|
||||
}>) {
|
||||
const navigate = useNavigate();
|
||||
const user = useUserState();
|
||||
const table = useTable('transfer-order-line-item');
|
||||
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
accessor: 'part',
|
||||
sortable: true,
|
||||
switchable: false,
|
||||
minWidth: 175,
|
||||
render: (record: any) => {
|
||||
return (
|
||||
<Group wrap='nowrap'>
|
||||
{record.part_detail?.virtual || (
|
||||
<RowExpansionIcon
|
||||
enabled={record.allocated}
|
||||
expanded={table.isRowExpanded(record.pk)}
|
||||
/>
|
||||
)}
|
||||
<RenderPartColumn part={record.part_detail} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'part_detail.IPN',
|
||||
title: t`IPN`,
|
||||
switchable: true
|
||||
},
|
||||
DescriptionColumn({
|
||||
accessor: 'part_detail.description'
|
||||
}),
|
||||
{
|
||||
accessor: 'reference',
|
||||
sortable: false,
|
||||
switchable: true
|
||||
},
|
||||
ProjectCodeColumn({}),
|
||||
DecimalColumn({
|
||||
accessor: 'quantity',
|
||||
sortable: true
|
||||
}),
|
||||
DateColumn({
|
||||
accessor: 'target_date',
|
||||
sortable: true,
|
||||
title: t`Target Date`
|
||||
}),
|
||||
{
|
||||
accessor: 'stock',
|
||||
title: t`Available Stock`,
|
||||
render: (record: any) => {
|
||||
if (record.part_detail?.virtual) {
|
||||
return <Text size='sm' fs='italic'>{t`Virtual part`}</Text>;
|
||||
}
|
||||
|
||||
const part_stock = record?.available_stock ?? 0;
|
||||
const variant_stock = record?.available_variant_stock ?? 0;
|
||||
const available = part_stock + variant_stock;
|
||||
|
||||
const required = Math.max(
|
||||
record.quantity - record.allocated - record.shipped,
|
||||
0
|
||||
);
|
||||
|
||||
let color: string | undefined;
|
||||
let text = `${formatDecimal(available)}`;
|
||||
|
||||
const extra: ReactNode[] = [];
|
||||
|
||||
if (available <= 0) {
|
||||
color = 'red';
|
||||
text = t`No stock available`;
|
||||
} else if (available < required) {
|
||||
color = 'orange';
|
||||
}
|
||||
|
||||
if (variant_stock > 0) {
|
||||
extra.push(<Text size='sm'>{t`Includes variant stock`}</Text>);
|
||||
}
|
||||
|
||||
if (record.building > 0) {
|
||||
extra.push(
|
||||
<Text size='sm'>
|
||||
{t`In production`}: {formatDecimal(record.building)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (record.on_order > 0) {
|
||||
extra.push(
|
||||
<Text size='sm'>
|
||||
{t`On order`}: {formatDecimal(record.on_order)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableHoverCard
|
||||
value={<Text c={color}>{text}</Text>}
|
||||
extra={extra}
|
||||
title={t`Stock Information`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
accessor: 'allocated',
|
||||
sortable: true,
|
||||
render: (record: any) => {
|
||||
if (record.part_detail?.virtual) {
|
||||
return <Text size='sm' fs='italic'>{t`Virtual part`}</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ProgressBar
|
||||
progressLabel={true}
|
||||
value={record.allocated}
|
||||
maximum={record.quantity}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'transferred',
|
||||
title: t`Transferred`,
|
||||
sortable: true,
|
||||
render: (record: any) => {
|
||||
if (record.part_detail?.virtual) {
|
||||
return <Text size='sm' fs='italic'>{t`Virtual part`}</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ProgressBar
|
||||
progressLabel={true}
|
||||
value={record.transferred}
|
||||
maximum={record.quantity}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'notes'
|
||||
},
|
||||
LinkColumn({
|
||||
accessor: 'link'
|
||||
})
|
||||
];
|
||||
}, [table.isRowExpanded]);
|
||||
|
||||
const [initialData, setInitialData] = useState({});
|
||||
const [selectedItems, setSelectedItems] = useState<any[]>([]);
|
||||
const [selectedLineId, setSelectedLineId] = useState<number>(0);
|
||||
const [selectedPart, setSelectedPart] = useState<any>(null);
|
||||
const [partsToOrder, setPartsToOrder] = useState<any[]>([]);
|
||||
|
||||
const allocateStock = useAllocateToTransferOrderForm({
|
||||
orderId: orderId,
|
||||
lineItems: selectedItems.filter(
|
||||
(item) => item.part_detail?.virtual !== true
|
||||
),
|
||||
sourceLocationId: sourceLocationId,
|
||||
onFormSuccess: () => {
|
||||
table.refreshTable();
|
||||
table.clearSelectedRecords();
|
||||
}
|
||||
});
|
||||
|
||||
const orderPartsWizard = OrderPartsWizard({
|
||||
parts: partsToOrder
|
||||
});
|
||||
|
||||
const buildOrderFields = useBuildOrderFields({
|
||||
create: true,
|
||||
modalId: 'build-order-create-from-transfer-order'
|
||||
});
|
||||
|
||||
const newBuildOrder = useCreateApiFormModal({
|
||||
url: ApiEndpoints.build_order_list,
|
||||
title: t`Create Build Order`,
|
||||
modalId: 'build-order-create-from-transfer-order',
|
||||
fields: buildOrderFields,
|
||||
initialData: initialData,
|
||||
follow: true,
|
||||
modelType: ModelType.build
|
||||
});
|
||||
|
||||
const createLineFields = useTransferOrderLineItemFields({
|
||||
orderId: orderId,
|
||||
create: true
|
||||
});
|
||||
|
||||
const newLine = useCreateApiFormModal({
|
||||
url: ApiEndpoints.transfer_order_line_list,
|
||||
title: t`Add Line Item`,
|
||||
fields: createLineFields,
|
||||
initialData: {
|
||||
...initialData
|
||||
},
|
||||
onFormSuccess: orderDetailRefresh,
|
||||
table: table
|
||||
});
|
||||
|
||||
const editLineFields = useTransferOrderLineItemFields({
|
||||
orderId: orderId,
|
||||
create: false
|
||||
});
|
||||
|
||||
const editLine = useEditApiFormModal({
|
||||
url: ApiEndpoints.transfer_order_line_list,
|
||||
pk: selectedLineId,
|
||||
title: t`Edit Line Item`,
|
||||
fields: editLineFields,
|
||||
onFormSuccess: orderDetailRefresh,
|
||||
table: table
|
||||
});
|
||||
|
||||
const deleteLine = useDeleteApiFormModal({
|
||||
url: ApiEndpoints.transfer_order_line_list,
|
||||
pk: selectedLineId,
|
||||
title: t`Delete Line Item`,
|
||||
onFormSuccess: orderDetailRefresh,
|
||||
table: table
|
||||
});
|
||||
|
||||
const allocateSerialFields = useTransferOrderAllocateSerialsFields({
|
||||
itemId: selectedLineId,
|
||||
orderId: orderId
|
||||
});
|
||||
|
||||
const allocateBySerials = useCreateApiFormModal({
|
||||
url: ApiEndpoints.transfer_order_allocate_serials,
|
||||
pk: orderId,
|
||||
title: t`Allocate Serial Numbers`,
|
||||
preFormContent: selectedPart ? (
|
||||
<Paper withBorder p='sm'>
|
||||
<RenderPart instance={selectedPart} />
|
||||
</Paper>
|
||||
) : undefined,
|
||||
initialData: initialData,
|
||||
fields: allocateSerialFields,
|
||||
table: table
|
||||
});
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
return [
|
||||
<AddItemButton
|
||||
key='add-transfer-order-line-item'
|
||||
tooltip={t`Add Line Item`}
|
||||
onClick={() => {
|
||||
setInitialData({
|
||||
order: orderId
|
||||
});
|
||||
newLine.open();
|
||||
}}
|
||||
hidden={!editable || !user.hasAddRole(UserRoles.transfer_order)}
|
||||
/>,
|
||||
<ActionButton
|
||||
key='order-parts'
|
||||
hidden={!user.hasAddRole(UserRoles.purchase_order)}
|
||||
disabled={!table.hasSelectedRecords}
|
||||
tooltip={t`Order Parts`}
|
||||
icon={<IconShoppingCart />}
|
||||
color='blue'
|
||||
onClick={() => {
|
||||
setPartsToOrder(table.selectedRecords.map((r) => r.part_detail));
|
||||
orderPartsWizard.openWizard();
|
||||
}}
|
||||
/>,
|
||||
<ActionButton
|
||||
key='allocate-stock'
|
||||
tooltip={t`Allocate Stock`}
|
||||
icon={<IconArrowRight />}
|
||||
disabled={!table.hasSelectedRecords}
|
||||
color='green'
|
||||
onClick={() => {
|
||||
setSelectedItems(
|
||||
table.selectedRecords.filter((r: any) => r.allocated < r.quantity)
|
||||
);
|
||||
allocateStock.open();
|
||||
}}
|
||||
/>
|
||||
];
|
||||
}, [user, orderId, table.hasSelectedRecords, table.selectedRecords]);
|
||||
|
||||
const rowActions = useCallback(
|
||||
(record: any): RowAction[] => {
|
||||
const allocated = (record?.allocated ?? 0) > (record?.quantity ?? 0);
|
||||
const virtual = record?.part_detail?.virtual ?? false;
|
||||
|
||||
return [
|
||||
{
|
||||
hidden:
|
||||
allocated ||
|
||||
virtual ||
|
||||
!editable ||
|
||||
!user.hasChangeRole(UserRoles.transfer_order),
|
||||
title: t`Allocate Stock`,
|
||||
icon: <IconSquareArrowRight />,
|
||||
color: 'green',
|
||||
onClick: () => {
|
||||
setSelectedItems([record]);
|
||||
allocateStock.open();
|
||||
}
|
||||
},
|
||||
{
|
||||
hidden:
|
||||
!record?.part_detail?.trackable ||
|
||||
allocated ||
|
||||
virtual ||
|
||||
!editable ||
|
||||
!user.hasChangeRole(UserRoles.transfer_order),
|
||||
title: t`Allocate serials`,
|
||||
icon: <IconHash />,
|
||||
color: 'green',
|
||||
onClick: () => {
|
||||
setSelectedLineId(record.pk);
|
||||
setSelectedPart(record?.part_detail ?? null);
|
||||
setInitialData({
|
||||
quantity: record.quantity - record.allocated
|
||||
});
|
||||
allocateBySerials.open();
|
||||
}
|
||||
},
|
||||
{
|
||||
hidden:
|
||||
allocated ||
|
||||
virtual ||
|
||||
!user.hasAddRole(UserRoles.build) ||
|
||||
!record?.part_detail?.assembly,
|
||||
title: t`Build stock`,
|
||||
icon: <IconTools />,
|
||||
color: 'blue',
|
||||
onClick: () => {
|
||||
setInitialData({
|
||||
part: record.part,
|
||||
quantity: (record?.quantity ?? 1) - (record?.allocated ?? 0),
|
||||
transfer_order: orderId
|
||||
});
|
||||
newBuildOrder.open();
|
||||
}
|
||||
},
|
||||
{
|
||||
hidden:
|
||||
allocated ||
|
||||
virtual ||
|
||||
!user.hasAddRole(UserRoles.purchase_order) ||
|
||||
!record?.part_detail?.purchaseable,
|
||||
title: t`Order stock`,
|
||||
icon: <IconShoppingCart />,
|
||||
color: 'blue',
|
||||
onClick: () => {
|
||||
setPartsToOrder([record.part_detail]);
|
||||
orderPartsWizard.openWizard();
|
||||
}
|
||||
},
|
||||
RowEditAction({
|
||||
hidden: !editable || !user.hasChangeRole(UserRoles.transfer_order),
|
||||
onClick: () => {
|
||||
setSelectedLineId(record.pk);
|
||||
editLine.open();
|
||||
}
|
||||
}),
|
||||
RowDuplicateAction({
|
||||
hidden: !editable || !user.hasAddRole(UserRoles.transfer_order),
|
||||
onClick: () => {
|
||||
setInitialData(record);
|
||||
newLine.open();
|
||||
}
|
||||
}),
|
||||
RowDeleteAction({
|
||||
hidden: !editable || !user.hasDeleteRole(UserRoles.transfer_order),
|
||||
onClick: () => {
|
||||
setSelectedLineId(record.pk);
|
||||
deleteLine.open();
|
||||
}
|
||||
}),
|
||||
RowViewAction({
|
||||
title: t`View Part`,
|
||||
modelType: ModelType.part,
|
||||
modelId: record.part,
|
||||
navigate: navigate,
|
||||
hidden: !user.hasViewRole(UserRoles.part)
|
||||
})
|
||||
];
|
||||
},
|
||||
[navigate, user, editable]
|
||||
);
|
||||
|
||||
// Control row expansion
|
||||
const rowExpansion: DataTableRowExpansionProps<any> = useMemo(() => {
|
||||
return {
|
||||
allowMultiple: true,
|
||||
expandable: ({ record }: { record: any }) => {
|
||||
if (record?.part_detail?.virtual) {
|
||||
return false;
|
||||
}
|
||||
return table.isRowExpanded(record.pk) || record.allocated > 0;
|
||||
},
|
||||
content: ({ record }: { record: any }) => {
|
||||
return (
|
||||
<TransferOrderAllocationTable
|
||||
showOrderInfo={false}
|
||||
showPartInfo={false}
|
||||
orderId={orderId}
|
||||
lineItemId={record.pk}
|
||||
partId={record.part}
|
||||
allowEdit={editable}
|
||||
modelTarget={ModelType.stockitem}
|
||||
modelField={'item'}
|
||||
isSubTable
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
}, [orderId, table.isRowExpanded]);
|
||||
|
||||
const tableFilters: TableFilter[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
name: 'allocated',
|
||||
label: t`Allocated`,
|
||||
description: t`Show lines which are fully allocated`
|
||||
},
|
||||
{
|
||||
name: 'completed',
|
||||
label: t`Completed`,
|
||||
description: t`Show lines which are completed`
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{editLine.modal}
|
||||
{deleteLine.modal}
|
||||
{newLine.modal}
|
||||
{newBuildOrder.modal}
|
||||
{allocateBySerials.modal}
|
||||
{allocateStock.modal}
|
||||
{orderPartsWizard.wizard}
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.transfer_order_line_list)}
|
||||
tableState={table}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
enableSelection: true,
|
||||
enableDownload: true,
|
||||
params: {
|
||||
order: orderId,
|
||||
part_detail: true
|
||||
},
|
||||
rowActions: rowActions,
|
||||
tableActions: tableActions,
|
||||
tableFilters: tableFilters,
|
||||
rowExpansion: rowExpansion
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue