Merge 47a6b1ec8d into 5df2adae6d
This commit is contained in:
commit
949b3bd1fb
|
|
@ -757,6 +757,7 @@ class APISearchView(GenericAPIView):
|
|||
'partcategory': part.api.CategoryList,
|
||||
'purchaseorder': order.api.PurchaseOrderList,
|
||||
'returnorder': order.api.ReturnOrderList,
|
||||
'repairorder': order.api.RepairOrderList,
|
||||
'salesorder': order.api.SalesOrderList,
|
||||
'salesordershipment': order.api.SalesOrderShipmentList,
|
||||
'stockitem': stock.api.StockList,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 515
|
||||
INVENTREE_API_VERSION = 516
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v516 -> 2026-07-05 : https://github.com/inventree/InvenTree/pull/12303
|
||||
- Adds RepairOrder model and API endpoints
|
||||
- Adds RepairOrderStatus status codes
|
||||
|
||||
v515 -> 2026-07-03 : https://github.com/inventree/InvenTree/pull/12298
|
||||
- Change the file fields definition to binary (from uri) in the upload requests
|
||||
|
||||
|
|
|
|||
|
|
@ -889,6 +889,28 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
|||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'REPAIRORDER_REFERENCE_PATTERN': {
|
||||
'name': _('Repair Order Reference Pattern'),
|
||||
'description': _(
|
||||
'Required pattern for generating Repair Order reference field'
|
||||
),
|
||||
'default': 'RP-{ref:04d}',
|
||||
'validator': order.validators.validate_repair_order_reference_pattern,
|
||||
},
|
||||
'REPAIRORDER_REQUIRE_RESPONSIBLE': {
|
||||
'name': _('Require Responsible Owner'),
|
||||
'description': _('A responsible owner must be assigned to each order'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'REPAIRORDER_EDIT_COMPLETED_ORDERS': {
|
||||
'name': _('Edit Completed Repair Orders'),
|
||||
'description': _(
|
||||
'Allow editing of repair orders after they have been completed'
|
||||
),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'SALESORDER_REFERENCE_PATTERN': {
|
||||
'name': _('Sales Order Reference Pattern'),
|
||||
'description': _('Required pattern for generating Sales Order reference field'),
|
||||
|
|
|
|||
|
|
@ -172,6 +172,33 @@ class ReturnOrdeerExtraLineAdmin(GeneralExtraLineAdmin, admin.ModelAdmin):
|
|||
"""Admin class for the ReturnOrderExtraLine model."""
|
||||
|
||||
|
||||
@admin.register(models.RepairOrder)
|
||||
class RepairOrderAdmin(admin.ModelAdmin):
|
||||
"""Admin class for the RepairOrder model."""
|
||||
|
||||
exclude = ['reference_int']
|
||||
|
||||
list_display = ['reference', 'customer', 'status']
|
||||
|
||||
search_fields = ['reference', 'customer__name', 'description']
|
||||
|
||||
autocomplete_fields = ['customer', 'project_code', 'contact', 'address']
|
||||
|
||||
|
||||
@admin.register(models.RepairOrderLineItem)
|
||||
class RepairOrderLineItemAdmin(admin.ModelAdmin):
|
||||
"""Admin class for RepairOrderLine model."""
|
||||
|
||||
list_display = ['order', 'item', 'reference']
|
||||
|
||||
autocomplete_fields = ['item', 'order']
|
||||
|
||||
|
||||
@admin.register(models.RepairOrderExtraLine)
|
||||
class RepairOrderExtraLineAdmin(GeneralExtraLineAdmin, admin.ModelAdmin):
|
||||
"""Admin class for the RepairOrderExtraLine model."""
|
||||
|
||||
|
||||
class TransferOrderLineItemInlineAdmin(admin.StackedInline):
|
||||
"""Inline admin class for the TransferOrderLineItem model."""
|
||||
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ from order import models, serializers
|
|||
from order.status_codes import (
|
||||
PurchaseOrderStatus,
|
||||
PurchaseOrderStatusGroups,
|
||||
RepairOrderStatus,
|
||||
ReturnOrderLineStatus,
|
||||
ReturnOrderStatus,
|
||||
SalesOrderStatus,
|
||||
|
|
@ -1824,6 +1825,274 @@ class ReturnOrderExtraLineDetail(RetrieveUpdateDestroyAPI):
|
|||
serializer_class = serializers.ReturnOrderExtraLineSerializer
|
||||
|
||||
|
||||
class RepairOrderFilter(OrderFilter):
|
||||
"""Custom API filters for the RepairOrderList endpoint."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = models.RepairOrder
|
||||
fields = ['customer']
|
||||
|
||||
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 repair orders
|
||||
line_items = models.RepairOrderLineItem.objects.filter(item__part__in=parts)
|
||||
|
||||
# Generate a list of ID values for the matching repair orders
|
||||
repair_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=repair_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 RepairOrderMixin(SerializerContextMixin):
|
||||
"""Mixin class for RepairOrder endpoints."""
|
||||
|
||||
queryset = models.RepairOrder.objects.all()
|
||||
serializer_class = serializers.RepairOrderSerializer
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
"""Return annotated queryset for this endpoint."""
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
queryset = serializers.RepairOrderSerializer.annotate_queryset(queryset)
|
||||
queryset = queryset.prefetch_related(
|
||||
'contact', 'created_by', 'customer', 'responsible'
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class RepairOrderOutputOptions(OutputConfiguration):
|
||||
"""Output options for the RepairOrder endpoint."""
|
||||
|
||||
OPTIONS = [InvenTreeOutputOption(flag='customer_detail')]
|
||||
|
||||
|
||||
class RepairOrderList(
|
||||
RepairOrderMixin,
|
||||
OrderCreateMixin,
|
||||
DataExportViewMixin,
|
||||
OutputOptionsMixin,
|
||||
ParameterListMixin,
|
||||
ListCreateAPI,
|
||||
):
|
||||
"""API endpoint for accessing a list of RepairOrder objects."""
|
||||
|
||||
filterset_class = RepairOrderFilter
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
output_options = RepairOrderOutputOptions
|
||||
|
||||
ordering_field_aliases = {
|
||||
'reference': ['reference_int', 'reference'],
|
||||
'project_code': ['project_code__code'],
|
||||
}
|
||||
|
||||
ordering_fields = [
|
||||
'creation_date',
|
||||
'created_by',
|
||||
'reference',
|
||||
'customer__name',
|
||||
'customer_reference',
|
||||
'line_items',
|
||||
'status',
|
||||
'start_date',
|
||||
'target_date',
|
||||
'complete_date',
|
||||
'project_code',
|
||||
'updated_at',
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'customer__name',
|
||||
'reference',
|
||||
'description',
|
||||
'customer_reference',
|
||||
'project_code__code',
|
||||
]
|
||||
|
||||
ordering = '-reference'
|
||||
|
||||
|
||||
class RepairOrderDetail(RepairOrderMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI):
|
||||
"""API endpoint for detail view of a single RepairOrder object."""
|
||||
|
||||
output_options = RepairOrderOutputOptions
|
||||
|
||||
|
||||
class RepairOrderContextMixin:
|
||||
"""Simple mixin class to add a RepairOrder to the serializer context."""
|
||||
|
||||
queryset = models.RepairOrder.objects.all()
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""Add the RepairOrder object to the serializer context."""
|
||||
context = super().get_serializer_context()
|
||||
|
||||
# Pass the RepairOrder instance through to the serializer for validation
|
||||
try:
|
||||
context['order'] = models.RepairOrder.objects.get(
|
||||
pk=self.kwargs.get('pk', None)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
context['request'] = self.request
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class RepairOrderCancel(RepairOrderContextMixin, CreateAPI):
|
||||
"""API endpoint to cancel a RepairOrder."""
|
||||
|
||||
serializer_class = serializers.RepairOrderCancelSerializer
|
||||
|
||||
|
||||
class RepairOrderHold(RepairOrderContextMixin, CreateAPI):
|
||||
"""API endpoint to hold a RepairOrder."""
|
||||
|
||||
serializer_class = serializers.RepairOrderHoldSerializer
|
||||
|
||||
|
||||
class RepairOrderComplete(RepairOrderContextMixin, CreateAPI):
|
||||
"""API endpoint to complete a RepairOrder."""
|
||||
|
||||
serializer_class = serializers.RepairOrderCompleteSerializer
|
||||
|
||||
|
||||
class RepairOrderIssue(RepairOrderContextMixin, CreateAPI):
|
||||
"""API endpoint to issue (place) a RepairOrder."""
|
||||
|
||||
serializer_class = serializers.RepairOrderIssueSerializer
|
||||
|
||||
|
||||
class RepairOrderLineItemFilter(LineItemFilter):
|
||||
"""Custom filters for the RepairOrderLineItemList endpoint."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
price_field = 'price'
|
||||
model = models.RepairOrderLineItem
|
||||
fields = ['order', 'item']
|
||||
|
||||
outcome = rest_filters.NumberFilter(label='outcome')
|
||||
|
||||
|
||||
class RepairOrderLineItemMixin(SerializerContextMixin):
|
||||
"""Mixin class for RepairOrderLineItem endpoints."""
|
||||
|
||||
queryset = models.RepairOrderLineItem.objects.all()
|
||||
serializer_class = serializers.RepairOrderLineItemSerializer
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
"""Return annotated queryset for this endpoint."""
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
queryset = queryset.prefetch_related('order', 'item', 'item__part')
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class RepairOrderLineItemOutputOptions(OutputConfiguration):
|
||||
"""Output options for the RepairOrderLineItem endpoint."""
|
||||
|
||||
OPTIONS = [
|
||||
InvenTreeOutputOption('part_detail'),
|
||||
InvenTreeOutputOption('item_detail', default=True),
|
||||
InvenTreeOutputOption('order_detail'),
|
||||
]
|
||||
|
||||
|
||||
class RepairOrderLineItemList(
|
||||
RepairOrderLineItemMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateAPI
|
||||
):
|
||||
"""API endpoint for accessing a list of RepairOrderLineItem objects."""
|
||||
|
||||
filterset_class = RepairOrderLineItemFilter
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
output_options = RepairOrderLineItemOutputOptions
|
||||
|
||||
ordering_fields = ['part', 'IPN', 'stock', 'reference', 'target_date', 'line']
|
||||
|
||||
ordering_field_aliases = {
|
||||
'line': ['line_int', 'line', 'item__part__name'],
|
||||
'part': 'item__part__name',
|
||||
'IPN': 'item__part__IPN',
|
||||
'stock': ['item__quantity', 'item__serial_int', 'item__serial'],
|
||||
}
|
||||
|
||||
search_fields = [
|
||||
'item__serial',
|
||||
'item__part__name',
|
||||
'item__part__description',
|
||||
'reference',
|
||||
]
|
||||
|
||||
|
||||
class RepairOrderLineItemDetail(
|
||||
RepairOrderLineItemMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI
|
||||
):
|
||||
"""API endpoint for detail view of a RepairOrderLineItem object."""
|
||||
|
||||
output_options = RepairOrderLineItemOutputOptions
|
||||
|
||||
|
||||
class RepairOrderExtraLineList(GeneralExtraLineList, OutputOptionsMixin, ListCreateAPI):
|
||||
"""API endpoint for accessing a list of RepairOrderExtraLine objects."""
|
||||
|
||||
queryset = models.RepairOrderExtraLine.objects.all()
|
||||
serializer_class = serializers.RepairOrderExtraLineSerializer
|
||||
|
||||
|
||||
class RepairOrderExtraLineDetail(RetrieveUpdateDestroyAPI):
|
||||
"""API endpoint for detail view of a RepairOrderExtraLine object."""
|
||||
|
||||
queryset = models.RepairOrderExtraLine.objects.all()
|
||||
serializer_class = serializers.RepairOrderExtraLineSerializer
|
||||
|
||||
|
||||
class TransferOrderFilter(OrderFilter):
|
||||
"""Custom API filters for the TransferOrderList endpoint."""
|
||||
|
||||
|
|
@ -2417,6 +2686,8 @@ class OrderCalendarExport(ICalFeed):
|
|||
ordertype_title = _('Sales Order')
|
||||
elif obj['ordertype'] == 'return-order':
|
||||
ordertype_title = _('Return Order')
|
||||
elif obj['ordertype'] == 'repair-order':
|
||||
ordertype_title = _('Repair Order')
|
||||
elif obj['ordertype'] == 'transfer-order':
|
||||
ordertype_title = _('Transfer Order')
|
||||
else:
|
||||
|
|
@ -2464,6 +2735,15 @@ class OrderCalendarExport(ICalFeed):
|
|||
).filter(status__lt=ReturnOrderStatus.COMPLETE.value)
|
||||
else:
|
||||
outlist = models.ReturnOrder.objects.filter(target_date__isnull=False)
|
||||
elif obj['ordertype'] == 'repair-order':
|
||||
if obj['include_completed'] is False:
|
||||
# Do not include completed orders from list in this case
|
||||
# Complete status = 30
|
||||
outlist = models.RepairOrder.objects.filter(
|
||||
target_date__isnull=False
|
||||
).filter(status__lt=RepairOrderStatus.COMPLETE.value)
|
||||
else:
|
||||
outlist = models.RepairOrder.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
|
||||
|
|
@ -2810,6 +3090,91 @@ order_api_urls = [
|
|||
),
|
||||
]),
|
||||
),
|
||||
# API endpoints for repair orders
|
||||
path(
|
||||
'repair-order/',
|
||||
include([
|
||||
# Repair Order detail endpoints
|
||||
path(
|
||||
'<int:pk>/',
|
||||
include([
|
||||
path(
|
||||
'cancel/',
|
||||
RepairOrderCancel.as_view(),
|
||||
name='api-repair-order-cancel',
|
||||
),
|
||||
path(
|
||||
'hold/', RepairOrderHold.as_view(), name='api-repair-order-hold'
|
||||
),
|
||||
path(
|
||||
'complete/',
|
||||
RepairOrderComplete.as_view(),
|
||||
name='api-repair-order-complete',
|
||||
),
|
||||
path(
|
||||
'issue/',
|
||||
RepairOrderIssue.as_view(),
|
||||
name='api-repair-order-issue',
|
||||
),
|
||||
meta_path(models.RepairOrder),
|
||||
path(
|
||||
'', RepairOrderDetail.as_view(), name='api-repair-order-detail'
|
||||
),
|
||||
]),
|
||||
),
|
||||
# Repair order status code information
|
||||
path(
|
||||
'status/',
|
||||
StatusView.as_view(),
|
||||
{StatusView.MODEL_REF: RepairOrderStatus},
|
||||
name='api-repair-order-status-codes',
|
||||
),
|
||||
# Repair Order list
|
||||
path('', RepairOrderList.as_view(), name='api-repair-order-list'),
|
||||
]),
|
||||
),
|
||||
# API endpoints for repair order lines
|
||||
path(
|
||||
'repair-order-line/',
|
||||
include([
|
||||
path(
|
||||
'<int:pk>/',
|
||||
include([
|
||||
meta_path(models.RepairOrderLineItem),
|
||||
path(
|
||||
'',
|
||||
RepairOrderLineItemDetail.as_view(),
|
||||
name='api-repair-order-line-detail',
|
||||
),
|
||||
]),
|
||||
),
|
||||
path(
|
||||
'', RepairOrderLineItemList.as_view(), name='api-repair-order-line-list'
|
||||
),
|
||||
]),
|
||||
),
|
||||
# API endpoints for repair order extra line
|
||||
path(
|
||||
'repair-order-extra-line/',
|
||||
include([
|
||||
path(
|
||||
'<int:pk>/',
|
||||
include([
|
||||
meta_path(models.RepairOrderExtraLine),
|
||||
path(
|
||||
'',
|
||||
RepairOrderExtraLineDetail.as_view(),
|
||||
name='api-repair-order-extra-line-detail',
|
||||
),
|
||||
]),
|
||||
),
|
||||
path(
|
||||
'',
|
||||
RepairOrderExtraLineList.as_view(),
|
||||
name='api-repair-order-extra-line-list',
|
||||
),
|
||||
]),
|
||||
),
|
||||
# API endpoints for transfer orders
|
||||
path(
|
||||
'transfer-order/',
|
||||
|
|
@ -2900,7 +3265,7 @@ order_api_urls = [
|
|||
),
|
||||
# API endpoint for subscribing to ICS calendar of purchase/sales/return orders
|
||||
re_path(
|
||||
r'^calendar/(?P<ordertype>purchase-order|sales-order|return-order|transfer-order)/calendar.ics',
|
||||
r'^calendar/(?P<ordertype>purchase-order|sales-order|return-order|repair-order|transfer-order)/calendar.ics',
|
||||
OrderCalendarExport(),
|
||||
name='api-po-so-calendar',
|
||||
),
|
||||
|
|
|
|||
|
|
@ -39,6 +39,15 @@ class ReturnOrderEvents(BaseEventEnum):
|
|||
HOLD = 'returnorder.hold'
|
||||
|
||||
|
||||
class RepairOrderEvents(BaseEventEnum):
|
||||
"""Event enumeration for the Repair models."""
|
||||
|
||||
ISSUED = 'repairorder.issued'
|
||||
COMPLETED = 'repairorder.completed'
|
||||
CANCELLED = 'repairorder.cancelled'
|
||||
HOLD = 'repairorder.hold'
|
||||
|
||||
|
||||
class TransferOrderEvents(BaseEventEnum):
|
||||
"""Event enumeration for the PurchaseOrder models."""
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
- model: order.repairorder
|
||||
pk: 1
|
||||
fields:
|
||||
reference: 'RP-001'
|
||||
reference_int: 1
|
||||
description: 'Repair for a customer'
|
||||
customer: 4
|
||||
status: 10 # Pending
|
||||
|
||||
- model: order.repairorder
|
||||
pk: 2
|
||||
fields:
|
||||
reference: 'RP-002'
|
||||
reference_int: 2
|
||||
description: 'Repair for a customer'
|
||||
customer: 4
|
||||
status: 20 # In Progress
|
||||
|
||||
- model: order.repairorder
|
||||
pk: 3
|
||||
fields:
|
||||
reference: 'RP-003'
|
||||
reference_int: 3
|
||||
description: 'Repair for a customer'
|
||||
customer: 4
|
||||
status: 30 # Complete
|
||||
|
||||
- model: order.repairorder
|
||||
pk: 4
|
||||
fields:
|
||||
reference: 'RP-004'
|
||||
reference_int: 4
|
||||
description: 'Repair for a customer'
|
||||
customer: 5
|
||||
status: 40 # Cancelled
|
||||
|
||||
- model: order.repairorder
|
||||
pk: 5
|
||||
fields:
|
||||
reference: 'RP-005'
|
||||
reference_int: 5
|
||||
description: 'Repair for a customer'
|
||||
customer: 5
|
||||
status: 20 # In progress
|
||||
|
||||
- model: order.repairorder
|
||||
pk: 6
|
||||
fields:
|
||||
reference: 'RP-006'
|
||||
reference_int: 6
|
||||
description: 'Repair for a customer'
|
||||
customer: 5
|
||||
status: 10 # Pending
|
||||
|
||||
# 1 x R_4K7_0603
|
||||
- model: order.repairorderlineitem
|
||||
pk: 1
|
||||
fields:
|
||||
order: 6
|
||||
item: 1008
|
||||
quantity: 1
|
||||
|
||||
# An extra line item
|
||||
- model: order.repairorderextraline
|
||||
pk: 1
|
||||
fields:
|
||||
order: 6
|
||||
reference: 'Freight cost'
|
||||
quantity: 1
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
# Generated by Django 5.2.15 on 2026-07-05 07:34
|
||||
|
||||
import InvenTree.fields
|
||||
import InvenTree.models
|
||||
import InvenTree.validators
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import djmoney.models.fields
|
||||
import djmoney.models.validators
|
||||
import generic.states.fields
|
||||
import generic.states.states
|
||||
import generic.states.transition
|
||||
import generic.states.validators
|
||||
import order.status_codes
|
||||
import order.validators
|
||||
import taggit.managers
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('common', '0046_alter_emailmessage_global_id_and_more'),
|
||||
('company', '0080_company_tags'),
|
||||
('order', '0120_purchaseorder_tags_returnorder_tags_salesorder_tags_and_more'),
|
||||
('stock', '0123_remove_stockitem_review_needed'),
|
||||
('taggit', '0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx'),
|
||||
('users', '0015_alter_userprofile_type'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='RepairOrder',
|
||||
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')),
|
||||
('total_price_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True)),
|
||||
('total_price', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Total price for this order', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Total Price')),
|
||||
('order_currency', models.CharField(blank=True, help_text='Currency for this order (leave blank to use company default)', max_length=3, null=True, validators=[InvenTree.validators.validate_currency_code], verbose_name='Order Currency')),
|
||||
('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')),
|
||||
('updated_at', models.DateTimeField(blank=True, help_text='Timestamp of last update', null=True, verbose_name='Updated At')),
|
||||
('reference', models.CharField(default=order.validators.generate_next_repair_order_reference, help_text='Repair Order reference', max_length=64, unique=True, validators=[order.validators.validate_repair_order_reference], verbose_name='Reference')),
|
||||
('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.RepairOrderStatus)], verbose_name='Custom status key')),
|
||||
('status', generic.states.fields.InvenTreeCustomStatusModelField(choices=[(10, 'Pending'), (20, 'In Progress'), (25, 'On Hold'), (30, 'Complete'), (40, 'Cancelled')], default=10, help_text='Repair order status', validators=[generic.states.validators.CustomStatusCodeValidator(status_class=order.status_codes.RepairOrderStatus)], verbose_name='Status')),
|
||||
('customer_reference', models.CharField(blank=True, help_text='Customer order reference code', max_length=64, verbose_name='Customer Reference ')),
|
||||
('complete_date', models.DateField(blank=True, help_text='Date order was completed', null=True, verbose_name='Completion Date')),
|
||||
('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')),
|
||||
('customer', models.ForeignKey(help_text='Company for which items are being repaired', limit_choices_to={'is_customer': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='repair_orders', to='company.company', verbose_name='Customer')),
|
||||
('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')),
|
||||
('tags', taggit.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Repair 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='RepairOrderExtraLine',
|
||||
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')),
|
||||
('line', models.CharField(blank=True, default='', help_text='Line number for this item (optional)', max_length=20, verbose_name='Line Number')),
|
||||
('line_int', models.IntegerField(default=0)),
|
||||
('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')),
|
||||
('description', models.CharField(blank=True, help_text='Line item description (optional)', max_length=250, verbose_name='Description')),
|
||||
('context', models.JSONField(blank=True, help_text='Additional context for this line', null=True, verbose_name='Context')),
|
||||
('price_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True)),
|
||||
('price', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Unit price', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Price')),
|
||||
('order', models.ForeignKey(help_text='Repair Order', on_delete=django.db.models.deletion.CASCADE, related_name='extra_lines', to='order.repairorder', verbose_name='Order')),
|
||||
('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')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Repair Order Extra Line',
|
||||
},
|
||||
bases=(InvenTree.models.ContentTypeMixin, InvenTree.models.PluginValidationMixin, models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RepairOrderLineItem',
|
||||
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')),
|
||||
('line', models.CharField(blank=True, default='', help_text='Line number for this item (optional)', max_length=20, verbose_name='Line Number')),
|
||||
('line_int', models.IntegerField(default=0)),
|
||||
('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')),
|
||||
('quantity', models.DecimalField(decimal_places=5, default=1, help_text='Quantity to repair', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Quantity')),
|
||||
('outcome_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.ReturnOrderLineStatus)], verbose_name='Custom status key')),
|
||||
('outcome', generic.states.fields.InvenTreeCustomStatusModelField(choices=[(10, 'Pending'), (20, 'Return'), (30, 'Repair'), (40, 'Replace'), (50, 'Refund'), (60, 'Reject')], default=10, help_text='Outcome for this line item', validators=[generic.states.validators.CustomStatusCodeValidator(status_class=order.status_codes.ReturnOrderLineStatus)], verbose_name='Outcome')),
|
||||
('price_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True)),
|
||||
('price', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Cost associated with repair for this line item', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Price')),
|
||||
('item', models.ForeignKey(help_text='Select item to repair for customer', on_delete=django.db.models.deletion.CASCADE, related_name='repair_order_lines', to='stock.stockitem', verbose_name='Item')),
|
||||
('order', models.ForeignKey(help_text='Repair Order', on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='order.repairorder', verbose_name='Order')),
|
||||
('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')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Repair Order Line Item',
|
||||
'unique_together': {('order', 'item')},
|
||||
},
|
||||
bases=(generic.states.states.StatusCodeMixin, InvenTree.models.ContentTypeMixin, InvenTree.models.PluginValidationMixin, models.Model),
|
||||
),
|
||||
]
|
||||
|
|
@ -47,6 +47,7 @@ from InvenTree.helpers import decimal2string, pui_url
|
|||
from InvenTree.helpers_model import notify_responsible
|
||||
from order.events import (
|
||||
PurchaseOrderEvents,
|
||||
RepairOrderEvents,
|
||||
ReturnOrderEvents,
|
||||
SalesOrderEvents,
|
||||
TransferOrderEvents,
|
||||
|
|
@ -54,6 +55,8 @@ from order.events import (
|
|||
from order.status_codes import (
|
||||
PurchaseOrderStatus,
|
||||
PurchaseOrderStatusGroups,
|
||||
RepairOrderStatus,
|
||||
RepairOrderStatusGroups,
|
||||
ReturnOrderLineStatus,
|
||||
ReturnOrderStatus,
|
||||
ReturnOrderStatusGroups,
|
||||
|
|
@ -272,6 +275,28 @@ class ReturnOrderReportContext(report.mixins.BaseReportContext, TypedDict):
|
|||
customer: Optional[Company]
|
||||
|
||||
|
||||
class RepairOrderReportContext(report.mixins.BaseReportContext, TypedDict):
|
||||
"""Context for the repair order model.
|
||||
|
||||
Attributes:
|
||||
description: The description field of the RepairOrder
|
||||
reference: The reference field of the RepairOrder
|
||||
title: The title (string representation) of the RepairOrder
|
||||
extra_lines: Query set of all extra lines associated with the RepairOrder
|
||||
lines: Query set of all line items associated with the RepairOrder
|
||||
order: The RepairOrder instance itself
|
||||
customer: The customer object associated with the RepairOrder
|
||||
"""
|
||||
|
||||
description: str
|
||||
reference: str
|
||||
title: str
|
||||
extra_lines: report.mixins.QuerySet['RepairOrderExtraLine']
|
||||
lines: report.mixins.QuerySet['RepairOrderLineItem']
|
||||
order: 'RepairOrder'
|
||||
customer: Optional[Company]
|
||||
|
||||
|
||||
class TransferOrderReportContext(BaseOrderReportContext, TypedDict):
|
||||
"""Context for the transfer order model.
|
||||
|
||||
|
|
@ -3351,6 +3376,346 @@ class ReturnOrderExtraLine(OrderExtraLine):
|
|||
)
|
||||
|
||||
|
||||
class RepairOrder(TotalPriceMixin, Order):
|
||||
"""A RepairOrder represents a customer's item which is to be repaired.
|
||||
|
||||
Attributes:
|
||||
customer: Reference to the customer
|
||||
status: The status of the order (refer to status_codes.RepairOrderStatus)
|
||||
"""
|
||||
|
||||
REFERENCE_PATTERN_SETTING = 'REPAIRORDER_REFERENCE_PATTERN'
|
||||
REQUIRE_RESPONSIBLE_SETTING = 'REPAIRORDER_REQUIRE_RESPONSIBLE'
|
||||
STATUS_CLASS = RepairOrderStatus
|
||||
UNLOCK_SETTING = 'REPAIRORDER_EDIT_COMPLETED_ORDERS'
|
||||
|
||||
class Meta:
|
||||
"""Model meta options."""
|
||||
|
||||
verbose_name = _('Repair Order')
|
||||
|
||||
def report_context(self) -> RepairOrderReportContext:
|
||||
"""Generate report context data for this RepairOrder."""
|
||||
repair_ctx = super().report_context()
|
||||
|
||||
repair_ctx.update({'customer': self.customer}) # ty:ignore[invalid-key]
|
||||
return repair_ctx
|
||||
|
||||
def get_absolute_url(self):
|
||||
"""Get the 'web' URL for this order."""
|
||||
return pui_url(f'/sales/repair-order/{self.pk}')
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with the RepairOrder model."""
|
||||
return reverse('api-repair-order-list')
|
||||
|
||||
@classmethod
|
||||
def get_status_class(cls):
|
||||
"""Return the RepairOrderStatus class."""
|
||||
return RepairOrderStatusGroups
|
||||
|
||||
@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_repair_order_reference()
|
||||
}
|
||||
|
||||
return defaults
|
||||
|
||||
@classmethod
|
||||
def barcode_model_type_code(cls):
|
||||
"""Return the associated barcode model type code for this model."""
|
||||
return 'RP'
|
||||
|
||||
def subscribed_users(self) -> list[User]:
|
||||
"""Return a list of users subscribed to this RepairOrder.
|
||||
|
||||
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.item and line.item.part:
|
||||
# Add the part to the list of subscribed users
|
||||
for user in line.item.part.get_subscribers():
|
||||
subscribed_users.add(user)
|
||||
|
||||
return list(subscribed_users)
|
||||
|
||||
def __str__(self):
|
||||
"""Render a string representation of this RepairOrder."""
|
||||
return f'{self.reference} - {self.customer.name if self.customer else _("no customer")}'
|
||||
|
||||
reference = models.CharField(
|
||||
unique=True,
|
||||
max_length=64,
|
||||
blank=False,
|
||||
verbose_name=_('Reference'),
|
||||
help_text=_('Repair Order reference'),
|
||||
default=order.validators.generate_next_repair_order_reference,
|
||||
validators=[order.validators.validate_repair_order_reference],
|
||||
)
|
||||
|
||||
customer = models.ForeignKey(
|
||||
Company,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
limit_choices_to={'is_customer': True},
|
||||
related_name='repair_orders',
|
||||
verbose_name=_('Customer'),
|
||||
help_text=_('Company for which items are being repaired'),
|
||||
)
|
||||
|
||||
@property
|
||||
def company(self):
|
||||
"""Accessor helper for Order base class."""
|
||||
return self.customer
|
||||
|
||||
status = InvenTreeCustomStatusModelField(
|
||||
default=RepairOrderStatus.PENDING.value,
|
||||
choices=RepairOrderStatus.items(),
|
||||
status_class=RepairOrderStatus,
|
||||
verbose_name=_('Status'),
|
||||
help_text=_('Repair order status'),
|
||||
)
|
||||
|
||||
customer_reference = models.CharField(
|
||||
max_length=64,
|
||||
blank=True,
|
||||
verbose_name=_('Customer Reference '),
|
||||
help_text=_('Customer order reference code'),
|
||||
)
|
||||
|
||||
complete_date = models.DateField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Completion Date'),
|
||||
help_text=_('Date order was completed'),
|
||||
)
|
||||
|
||||
# region state changes
|
||||
@property
|
||||
def is_pending(self):
|
||||
"""Return True if this order is pending."""
|
||||
return self.status == RepairOrderStatus.PENDING
|
||||
|
||||
@property
|
||||
def is_open(self):
|
||||
"""Return True if this order is outstanding."""
|
||||
return self.status in RepairOrderStatusGroups.OPEN
|
||||
|
||||
@property
|
||||
def can_hold(self):
|
||||
"""Return True if this order can be placed on hold."""
|
||||
return self.status in [
|
||||
RepairOrderStatus.PENDING.value,
|
||||
RepairOrderStatus.IN_PROGRESS.value,
|
||||
]
|
||||
|
||||
def _action_hold(self, *args, **kwargs):
|
||||
"""Mark this order as 'on hold' (if allowed)."""
|
||||
if self.can_hold:
|
||||
self.status = RepairOrderStatus.ON_HOLD.value
|
||||
self.save()
|
||||
|
||||
trigger_event(RepairOrderEvents.HOLD, id=self.pk)
|
||||
|
||||
@property
|
||||
def can_cancel(self):
|
||||
"""Return True if this order can be cancelled."""
|
||||
return self.status in RepairOrderStatusGroups.OPEN
|
||||
|
||||
def _action_cancel(self, *args, **kwargs):
|
||||
"""Cancel this RepairOrder (if not already cancelled)."""
|
||||
if self.can_cancel:
|
||||
self.status = RepairOrderStatus.CANCELLED.value
|
||||
self.save()
|
||||
|
||||
trigger_event(RepairOrderEvents.CANCELLED, id=self.pk)
|
||||
|
||||
# Notify users that the order has been canceled
|
||||
notify_responsible(
|
||||
self,
|
||||
RepairOrder,
|
||||
exclude=self.created_by,
|
||||
content=InvenTreeNotificationBodies.OrderCanceled,
|
||||
extra_users=self.subscribed_users(),
|
||||
)
|
||||
|
||||
def _action_complete(self, *args, **kwargs):
|
||||
"""Complete this RepairOrder (if not already completed)."""
|
||||
if self.status == RepairOrderStatus.IN_PROGRESS.value:
|
||||
self.status = RepairOrderStatus.COMPLETE.value
|
||||
self.complete_date = InvenTree.helpers.current_date()
|
||||
self.save()
|
||||
|
||||
trigger_event(RepairOrderEvents.COMPLETED, id=self.pk)
|
||||
|
||||
def place_order(self):
|
||||
"""Deprecated version of 'issue_order."""
|
||||
self.issue_order()
|
||||
|
||||
@property
|
||||
def can_issue(self):
|
||||
"""Return True if this order can be issued."""
|
||||
return self.status in [
|
||||
RepairOrderStatus.PENDING.value,
|
||||
RepairOrderStatus.ON_HOLD.value,
|
||||
]
|
||||
|
||||
def _action_place(self, *args, **kwargs):
|
||||
"""Issue this RepairOrder (if currently pending)."""
|
||||
if self.can_issue:
|
||||
self.status = RepairOrderStatus.IN_PROGRESS.value
|
||||
self.issue_date = InvenTree.helpers.current_date()
|
||||
self.save()
|
||||
|
||||
trigger_event(RepairOrderEvents.ISSUED, id=self.pk)
|
||||
|
||||
# Notify users that the order has been placed
|
||||
notify_responsible(
|
||||
self,
|
||||
RepairOrder,
|
||||
exclude=self.created_by,
|
||||
content=InvenTreeNotificationBodies.NewOrder,
|
||||
extra_users=self.subscribed_users(),
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def hold_order(self):
|
||||
"""Attempt to transition to ON_HOLD status."""
|
||||
return self.handle_transition(
|
||||
self.status, RepairOrderStatus.ON_HOLD.value, self, self._action_hold
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def issue_order(self):
|
||||
"""Attempt to transition to IN_PROGRESS status."""
|
||||
return self.handle_transition(
|
||||
self.status, RepairOrderStatus.IN_PROGRESS.value, self, self._action_place
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def complete_order(self):
|
||||
"""Attempt to transition to COMPLETE status."""
|
||||
return self.handle_transition(
|
||||
self.status, RepairOrderStatus.COMPLETE.value, self, self._action_complete
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def cancel_order(self):
|
||||
"""Attempt to transition to CANCELLED status."""
|
||||
return self.handle_transition(
|
||||
self.status, RepairOrderStatus.CANCELLED.value, self, self._action_cancel
|
||||
)
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
class RepairOrderLineItem(StatusCodeMixin, OrderLineItem):
|
||||
"""Model for a single LineItem in a RepairOrder."""
|
||||
|
||||
STATUS_CLASS = ReturnOrderLineStatus
|
||||
STATUS_FIELD = 'outcome'
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options for this model."""
|
||||
|
||||
verbose_name = _('Repair Order Line Item')
|
||||
unique_together = [('order', 'item')]
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with this model."""
|
||||
return reverse('api-repair-order-line-list')
|
||||
|
||||
def clean(self):
|
||||
"""Perform extra validation steps for the RepairOrderLineItem model."""
|
||||
super().clean()
|
||||
|
||||
if not self.item:
|
||||
raise ValidationError({'item': _('Stock item must be specified')})
|
||||
|
||||
if self.quantity > self.item.quantity:
|
||||
raise ValidationError({
|
||||
'quantity': _('Repair quantity exceeds stock quantity')
|
||||
})
|
||||
|
||||
if self.quantity <= 0:
|
||||
raise ValidationError({
|
||||
'quantity': _('Repair quantity must be greater than zero')
|
||||
})
|
||||
|
||||
if self.item.serialized and self.quantity != 1:
|
||||
raise ValidationError({
|
||||
'quantity': _('Invalid quantity for serialized stock item')
|
||||
})
|
||||
|
||||
order = models.ForeignKey(
|
||||
RepairOrder,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='lines',
|
||||
verbose_name=_('Order'),
|
||||
help_text=_('Repair Order'),
|
||||
)
|
||||
|
||||
item = models.ForeignKey(
|
||||
stock.models.StockItem,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='repair_order_lines',
|
||||
verbose_name=_('Item'),
|
||||
help_text=_('Select item to repair for customer'),
|
||||
)
|
||||
|
||||
quantity = models.DecimalField(
|
||||
verbose_name=('Quantity'),
|
||||
help_text=('Quantity to repair'),
|
||||
max_digits=15,
|
||||
decimal_places=5,
|
||||
validators=[MinValueValidator(0)],
|
||||
default=1,
|
||||
)
|
||||
|
||||
outcome = InvenTreeCustomStatusModelField(
|
||||
default=ReturnOrderLineStatus.PENDING.value,
|
||||
choices=ReturnOrderLineStatus.items(),
|
||||
status_class=ReturnOrderLineStatus,
|
||||
verbose_name=_('Outcome'),
|
||||
help_text=_('Outcome for this line item'),
|
||||
)
|
||||
|
||||
price = InvenTreeModelMoneyField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_('Price'),
|
||||
help_text=_('Cost associated with repair for this line item'),
|
||||
)
|
||||
|
||||
|
||||
class RepairOrderExtraLine(OrderExtraLine):
|
||||
"""Model for a single ExtraLine in a RepairOrder."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options for this model."""
|
||||
|
||||
verbose_name = _('Repair Order Extra Line')
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with the RepairOrderExtraLine model."""
|
||||
return reverse('api-repair-order-extra-line-list')
|
||||
|
||||
order = models.ForeignKey(
|
||||
RepairOrder,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='extra_lines',
|
||||
verbose_name=_('Order'),
|
||||
help_text=_('Repair Order'),
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -2453,6 +2453,203 @@ class ReturnOrderExtraLineSerializer(
|
|||
)
|
||||
|
||||
|
||||
@register_importer()
|
||||
class RepairOrderSerializer(
|
||||
NotesFieldMixin,
|
||||
InvenTreeCustomStatusSerializerMixin,
|
||||
AbstractOrderSerializer,
|
||||
TotalPriceMixin,
|
||||
InvenTreeModelSerializer,
|
||||
):
|
||||
"""Serializer for the RepairOrder model class."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = order.models.RepairOrder
|
||||
fields = AbstractOrderSerializer.order_fields([
|
||||
'complete_date',
|
||||
'customer',
|
||||
'customer_detail',
|
||||
'customer_reference',
|
||||
'order_currency',
|
||||
'total_price',
|
||||
'updated_at',
|
||||
])
|
||||
read_only_fields = ['creation_date', 'updated_at']
|
||||
|
||||
def skip_create_fields(self):
|
||||
"""Skip these fields when instantiating a new object."""
|
||||
fields = super().skip_create_fields()
|
||||
|
||||
return [*fields, 'duplicate']
|
||||
|
||||
# Note: line items cannot be duplicated from a RepairOrder,
|
||||
# as they are linked to specific stock items
|
||||
duplicate = DuplicateOptionsSerializer(
|
||||
order.models.RepairOrder.objects.all(),
|
||||
copy_extra_lines=True,
|
||||
copy_parameters=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""Custom annotation for the serializer queryset."""
|
||||
queryset = AbstractOrderSerializer.annotate_queryset(queryset)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
completed_lines=SubqueryCount(
|
||||
'lines', filter=~Q(outcome=ReturnOrderLineStatus.PENDING.value)
|
||||
)
|
||||
)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
overdue=Case(
|
||||
When(
|
||||
order.models.RepairOrder.overdue_filter(),
|
||||
then=Value(True, output_field=BooleanField()),
|
||||
),
|
||||
default=Value(False, output_field=BooleanField()),
|
||||
)
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
customer_detail = OptionalField(
|
||||
serializer_class=CompanyBriefSerializer,
|
||||
serializer_kwargs={
|
||||
'source': 'customer',
|
||||
'many': False,
|
||||
'read_only': True,
|
||||
'allow_null': True,
|
||||
},
|
||||
prefetch_fields=['customer'],
|
||||
)
|
||||
|
||||
|
||||
class RepairOrderHoldSerializer(OrderAdjustSerializer):
|
||||
"""Serializers for holding a RepairOrder."""
|
||||
|
||||
def save(self):
|
||||
"""Save the serializer to 'hold' the order."""
|
||||
self.order.hold_order()
|
||||
|
||||
|
||||
class RepairOrderIssueSerializer(OrderAdjustSerializer):
|
||||
"""Serializer for issuing a RepairOrder."""
|
||||
|
||||
def save(self):
|
||||
"""Save the serializer to 'issue' the order."""
|
||||
self.order.issue_order()
|
||||
|
||||
|
||||
class RepairOrderCancelSerializer(OrderAdjustSerializer):
|
||||
"""Serializer for cancelling a RepairOrder."""
|
||||
|
||||
def save(self):
|
||||
"""Save the serializer to 'cancel' the order."""
|
||||
self.order.cancel_order()
|
||||
|
||||
|
||||
class RepairOrderCompleteSerializer(OrderAdjustSerializer):
|
||||
"""Serializer for completing a RepairOrder."""
|
||||
|
||||
def save(self):
|
||||
"""Save the serializer to 'complete' the order."""
|
||||
self.order.complete_order()
|
||||
|
||||
|
||||
@register_importer()
|
||||
class RepairOrderLineItemSerializer(
|
||||
DataImportExportSerializerMixin,
|
||||
AbstractLineItemSerializer,
|
||||
InvenTreeModelSerializer,
|
||||
):
|
||||
"""Serializer for a RepairOrderLineItem object."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = order.models.RepairOrderLineItem
|
||||
fields = AbstractLineItemSerializer.line_fields([
|
||||
'item',
|
||||
'outcome',
|
||||
'price',
|
||||
'price_currency',
|
||||
# Filterable detail fields
|
||||
'item_detail',
|
||||
'part_detail',
|
||||
])
|
||||
|
||||
order_detail = OptionalField(
|
||||
serializer_class=RepairOrderSerializer,
|
||||
serializer_kwargs={
|
||||
'source': 'order',
|
||||
'many': False,
|
||||
'read_only': True,
|
||||
'allow_null': True,
|
||||
},
|
||||
prefetch_fields=[
|
||||
'order__created_by',
|
||||
'order__responsible',
|
||||
'order__address',
|
||||
'order__project_code',
|
||||
'order__contact',
|
||||
],
|
||||
)
|
||||
|
||||
quantity = serializers.FloatField(
|
||||
label=_('Quantity'), help_text=_('Quantity to repair')
|
||||
)
|
||||
|
||||
item_detail = OptionalField(
|
||||
serializer_class=stock.serializers.StockItemSerializer,
|
||||
serializer_kwargs={
|
||||
'source': 'item',
|
||||
'many': False,
|
||||
'read_only': True,
|
||||
'allow_null': True,
|
||||
},
|
||||
prefetch_fields=['item__supplier_part'],
|
||||
)
|
||||
|
||||
part_detail = OptionalField(
|
||||
serializer_class=PartBriefSerializer,
|
||||
serializer_kwargs={
|
||||
'source': 'item.part',
|
||||
'many': False,
|
||||
'read_only': True,
|
||||
'allow_null': True,
|
||||
},
|
||||
)
|
||||
|
||||
price = InvenTreeMoneySerializer(allow_null=True)
|
||||
price_currency = InvenTreeCurrencySerializer(help_text=_('Line price currency'))
|
||||
|
||||
|
||||
@register_importer()
|
||||
class RepairOrderExtraLineSerializer(
|
||||
AbstractExtraLineSerializer, InvenTreeModelSerializer
|
||||
):
|
||||
"""Serializer for a RepairOrderExtraLine object."""
|
||||
|
||||
class Meta(AbstractExtraLineMeta):
|
||||
"""Metaclass options."""
|
||||
|
||||
model = order.models.RepairOrderExtraLine
|
||||
fields = AbstractExtraLineSerializer.extra_line_fields([])
|
||||
|
||||
order_detail = OptionalField(
|
||||
serializer_class=RepairOrderSerializer,
|
||||
serializer_kwargs={
|
||||
'source': 'order',
|
||||
'many': False,
|
||||
'read_only': True,
|
||||
'allow_null': True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@register_importer()
|
||||
class TransferOrderSerializer(
|
||||
NotesFieldMixin,
|
||||
|
|
|
|||
|
|
@ -118,6 +118,33 @@ class ReturnOrderLineStatus(StatusCode):
|
|||
REJECT = 60, _('Reject'), ColorEnum.danger
|
||||
|
||||
|
||||
class RepairOrderStatus(StatusCode):
|
||||
"""Defines a set of status codes for a RepairOrder."""
|
||||
|
||||
# Order is pending, waiting for the item to be repaired
|
||||
PENDING = 10, _('Pending'), ColorEnum.secondary
|
||||
|
||||
# Item is currently being repaired
|
||||
IN_PROGRESS = 20, _('In Progress'), ColorEnum.primary
|
||||
|
||||
ON_HOLD = 25, _('On Hold'), ColorEnum.warning
|
||||
|
||||
COMPLETE = 30, _('Complete'), ColorEnum.success
|
||||
CANCELLED = 40, _('Cancelled'), ColorEnum.danger
|
||||
|
||||
|
||||
class RepairOrderStatusGroups:
|
||||
"""Groups for RepairOrderStatus codes."""
|
||||
|
||||
OPEN = [
|
||||
RepairOrderStatus.PENDING.value,
|
||||
RepairOrderStatus.ON_HOLD.value,
|
||||
RepairOrderStatus.IN_PROGRESS.value,
|
||||
]
|
||||
|
||||
COMPLETE = [RepairOrderStatus.COMPLETE.value]
|
||||
|
||||
|
||||
class TransferOrderStatus(StatusCode):
|
||||
"""Defines a set of status codes for a TransferOrder."""
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ from order import models
|
|||
from order.models import SalesOrderAllocation, SalesOrderLineItem, SalesOrderShipment
|
||||
from order.status_codes import (
|
||||
PurchaseOrderStatus,
|
||||
RepairOrderStatus,
|
||||
ReturnOrderLineStatus,
|
||||
ReturnOrderStatus,
|
||||
SalesOrderStatus,
|
||||
|
|
@ -3017,6 +3018,176 @@ class ReturnOrderTests(InvenTreeAPITestCase):
|
|||
)
|
||||
|
||||
|
||||
class RepairOrderTests(InvenTreeAPITestCase):
|
||||
"""Unit tests for RepairOrder API endpoints."""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
'company',
|
||||
'repair_order',
|
||||
'part',
|
||||
'location',
|
||||
'supplier_part',
|
||||
'stock',
|
||||
]
|
||||
roles = ['repair_order.view']
|
||||
|
||||
def test_options(self):
|
||||
"""Test the OPTIONS endpoint."""
|
||||
self.assignRole('repair_order.add')
|
||||
data = self.options(reverse('api-repair-order-list'), expected_code=200).data
|
||||
|
||||
self.assertEqual(data['name'], 'Repair Order List')
|
||||
|
||||
# Some checks on the 'reference' field
|
||||
post = data['actions']['POST']
|
||||
reference = post['reference']
|
||||
|
||||
self.assertEqual(reference['default'], 'RP-0007')
|
||||
self.assertEqual(reference['label'], 'Reference')
|
||||
self.assertEqual(reference['help_text'], 'Repair Order reference')
|
||||
self.assertEqual(reference['required'], True)
|
||||
self.assertEqual(reference['type'], 'string')
|
||||
|
||||
def test_list(self):
|
||||
"""Tests for the list endpoint."""
|
||||
url = reverse('api-repair-order-list')
|
||||
|
||||
response = self.get(url, expected_code=200)
|
||||
|
||||
self.assertEqual(len(response.data), 6)
|
||||
|
||||
# Paginated query
|
||||
data = self.get(
|
||||
url,
|
||||
{'limit': 1, 'ordering': 'reference', 'customer_detail': True},
|
||||
expected_code=200,
|
||||
).data
|
||||
|
||||
self.assertEqual(data['count'], 6)
|
||||
self.assertEqual(len(data['results']), 1)
|
||||
result = data['results'][0]
|
||||
self.assertEqual(result['reference'], 'RP-001')
|
||||
self.assertEqual(result['customer_detail']['name'], 'A customer')
|
||||
|
||||
# Reverse ordering
|
||||
data = self.get(url, {'ordering': '-reference'}, expected_code=200).data
|
||||
|
||||
self.assertEqual(data[0]['reference'], 'RP-006')
|
||||
|
||||
# Filter by customer
|
||||
for cmp_id in [4, 5]:
|
||||
data = self.get(url, {'customer': cmp_id}, expected_code=200).data
|
||||
|
||||
self.assertEqual(len(data), 3)
|
||||
|
||||
for result in data:
|
||||
self.assertEqual(result['customer'], cmp_id)
|
||||
|
||||
# Filter by status
|
||||
data = self.get(
|
||||
url, {'status': RepairOrderStatus.IN_PROGRESS.value}, expected_code=200
|
||||
).data
|
||||
|
||||
self.assertEqual(len(data), 2)
|
||||
|
||||
for result in data:
|
||||
self.assertEqual(result['status'], 20)
|
||||
|
||||
def test_create(self):
|
||||
"""Test creation of RepairOrder via the API."""
|
||||
url = reverse('api-repair-order-list')
|
||||
|
||||
# Do not have required permissions yet
|
||||
self.post(
|
||||
url, {'customer': 1, 'description': 'a repair order'}, expected_code=403
|
||||
)
|
||||
|
||||
self.assignRole('repair_order.add')
|
||||
|
||||
data = self.post(
|
||||
url,
|
||||
{
|
||||
'customer': 4,
|
||||
'customer_reference': 'cr',
|
||||
'description': 'a repair order',
|
||||
},
|
||||
expected_code=201,
|
||||
).data
|
||||
|
||||
# Reference automatically generated
|
||||
self.assertEqual(data['reference'], 'RP-0007')
|
||||
self.assertEqual(data['customer_reference'], 'cr')
|
||||
|
||||
def test_detail(self):
|
||||
"""Test that we can view and update a RepairOrder via the API."""
|
||||
url = reverse('api-repair-order-detail', kwargs={'pk': 1})
|
||||
|
||||
# Test detail endpoint
|
||||
data = self.get(url, expected_code=200).data
|
||||
|
||||
self.assertEqual(data['reference'], 'RP-001')
|
||||
|
||||
# Attempt to update, incorrect permissions
|
||||
self.patch(
|
||||
url, {'customer_reference': 'My customer reference'}, expected_code=403
|
||||
)
|
||||
|
||||
self.assignRole('repair_order.change')
|
||||
|
||||
self.patch(url, {'customer_reference': 'customer ref'}, expected_code=200)
|
||||
|
||||
rp = models.RepairOrder.objects.get(pk=1)
|
||||
self.assertEqual(rp.customer_reference, 'customer ref')
|
||||
|
||||
def test_state_transitions(self):
|
||||
"""Test the state transition endpoints for a RepairOrder."""
|
||||
# Issue: PENDING -> IN_PROGRESS
|
||||
order = models.RepairOrder.objects.get(pk=1)
|
||||
self.assertEqual(order.status, RepairOrderStatus.PENDING)
|
||||
self.assertIsNone(order.issue_date)
|
||||
|
||||
url = reverse('api-repair-order-issue', kwargs={'pk': 1})
|
||||
|
||||
# POST without required permissions
|
||||
self.post(url, expected_code=403)
|
||||
|
||||
self.assignRole('repair_order.add')
|
||||
|
||||
self.post(url, expected_code=201)
|
||||
order.refresh_from_db()
|
||||
self.assertEqual(order.status, RepairOrderStatus.IN_PROGRESS)
|
||||
self.assertIsNotNone(order.issue_date)
|
||||
|
||||
# Hold: IN_PROGRESS -> ON_HOLD
|
||||
self.post(reverse('api-repair-order-hold', kwargs={'pk': 1}), expected_code=201)
|
||||
order.refresh_from_db()
|
||||
self.assertEqual(order.status, RepairOrderStatus.ON_HOLD)
|
||||
|
||||
# Issue again: ON_HOLD -> IN_PROGRESS
|
||||
self.post(url, expected_code=201)
|
||||
order.refresh_from_db()
|
||||
self.assertEqual(order.status, RepairOrderStatus.IN_PROGRESS)
|
||||
|
||||
# Complete: IN_PROGRESS -> COMPLETE
|
||||
self.post(
|
||||
reverse('api-repair-order-complete', kwargs={'pk': 1}), expected_code=201
|
||||
)
|
||||
order.refresh_from_db()
|
||||
self.assertEqual(order.status, RepairOrderStatus.COMPLETE)
|
||||
self.assertIsNotNone(order.complete_date)
|
||||
|
||||
# Cancel a different (still open) order
|
||||
cancel_order = models.RepairOrder.objects.get(pk=6)
|
||||
self.assertEqual(cancel_order.status, RepairOrderStatus.PENDING)
|
||||
|
||||
self.post(
|
||||
reverse('api-repair-order-cancel', kwargs={'pk': 6}), expected_code=201
|
||||
)
|
||||
cancel_order.refresh_from_db()
|
||||
self.assertEqual(cancel_order.status, RepairOrderStatus.CANCELLED)
|
||||
|
||||
|
||||
class ReturnOrderLineItemTests(InvenTreeAPITestCase):
|
||||
"""Unit tests for ReturnOrderLineItem API endpoints."""
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,13 @@ def generate_next_return_order_reference():
|
|||
return ReturnOrder.generate_reference()
|
||||
|
||||
|
||||
def generate_next_repair_order_reference():
|
||||
"""Generate the next available RepairOrder reference."""
|
||||
from order.models import RepairOrder
|
||||
|
||||
return RepairOrder.generate_reference()
|
||||
|
||||
|
||||
def generate_next_transfer_order_reference():
|
||||
"""Generate the next available TransferOrder reference."""
|
||||
from order.models import TransferOrder
|
||||
|
|
@ -50,6 +57,13 @@ def validate_return_order_reference_pattern(pattern):
|
|||
ReturnOrder.validate_reference_pattern(pattern)
|
||||
|
||||
|
||||
def validate_repair_order_reference_pattern(pattern):
|
||||
"""Validate the RepairOrder reference 'pattern' setting."""
|
||||
from order.models import RepairOrder
|
||||
|
||||
RepairOrder.validate_reference_pattern(pattern)
|
||||
|
||||
|
||||
def validate_sales_order_reference(value):
|
||||
"""Validate that the SalesOrder reference field matches the required pattern."""
|
||||
from order.models import SalesOrder
|
||||
|
|
@ -71,6 +85,13 @@ def validate_return_order_reference(value):
|
|||
ReturnOrder.validate_reference_field(value)
|
||||
|
||||
|
||||
def validate_repair_order_reference(value):
|
||||
"""Validate that the RepairOrder reference field matches the required pattern."""
|
||||
from order.models import RepairOrder
|
||||
|
||||
RepairOrder.validate_reference_field(value)
|
||||
|
||||
|
||||
def validate_transfer_order_reference_pattern(pattern):
|
||||
"""Validate the TransferOrder reference 'pattern' setting."""
|
||||
from order.models import TransferOrder
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ _roles = {
|
|||
'purchase_order': 'Role Purchase Orders',
|
||||
'sales_order': 'Role Sales Orders',
|
||||
'return_order': 'Role Return Orders',
|
||||
'repair_order': 'Role Repair 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'
|
||||
REPAIR_ORDER = 'repair_order'
|
||||
TRANSFER_ORDER = 'transfer_order'
|
||||
|
||||
|
||||
|
|
@ -35,6 +36,7 @@ RULESET_CHOICES = [
|
|||
(RuleSetEnum.PURCHASE_ORDER, _('Purchase Orders')),
|
||||
(RuleSetEnum.SALES_ORDER, _('Sales Orders')),
|
||||
(RuleSetEnum.RETURN_ORDER, _('Return Orders')),
|
||||
(RuleSetEnum.REPAIR_ORDER, _('Repair Orders')),
|
||||
(RuleSetEnum.TRANSFER_ORDER, _('Transfer Orders')),
|
||||
]
|
||||
|
||||
|
|
@ -163,6 +165,14 @@ def get_ruleset_models() -> dict:
|
|||
'order_returnorderlineitem',
|
||||
'order_returnorderextraline',
|
||||
],
|
||||
RuleSetEnum.REPAIR_ORDER: [
|
||||
'company_company',
|
||||
'company_contact',
|
||||
'company_address',
|
||||
'order_repairorder',
|
||||
'order_repairorderlineitem',
|
||||
'order_repairorderextraline',
|
||||
],
|
||||
RuleSetEnum.TRANSFER_ORDER: [
|
||||
'order_transferorder',
|
||||
'order_transferorderallocation',
|
||||
|
|
|
|||
Loading…
Reference in New Issue