diff --git a/InvenTree/InvenTree/status_codes.py b/InvenTree/InvenTree/status_codes.py index 73684817e8..15df0b5204 100644 --- a/InvenTree/InvenTree/status_codes.py +++ b/InvenTree/InvenTree/status_codes.py @@ -320,3 +320,23 @@ class BuildStatus(StatusCode): PENDING, PRODUCTION, ] + + +class ReturnOrderStatus(StatusCode): + """Defines a set of status codes for a ReturnOrder""" + + PENDING = 10 + COMPLETE = 30 + CANCELLED = 40 + + options = { + PENDING: _("Pending"), + COMPLETE: _("Complete"), + CANCELLED: _("Cancelled"), + } + + colors = { + PENDING: 'secondary', + COMPLETE: 'success', + CANCELLED: 'danger', + } diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 200ab54845..220b59d404 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -1423,6 +1423,13 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'validator': build.validators.validate_build_order_reference_pattern, }, + 'RETURNORDER_REFERENCE_PATTERN': { + 'name': _('Return Order Reference Pattern'), + 'description': _('Required pattern for generating Return Order reference field'), + 'default': 'RMA-{ref:04d}', + 'validator': order.validators.validate_return_order_reference_pattern, + }, + 'SALESORDER_REFERENCE_PATTERN': { 'name': _('Sales Order Reference Pattern'), 'description': _('Required pattern for generating Sales Order reference field'), diff --git a/InvenTree/order/admin.py b/InvenTree/order/admin.py index 2f0f5bd36d..9e53ead630 100644 --- a/InvenTree/order/admin.py +++ b/InvenTree/order/admin.py @@ -6,13 +6,9 @@ import import_export.widgets as widgets from import_export.admin import ImportExportModelAdmin from import_export.fields import Field +import order.models as models from InvenTree.admin import InvenTreeResource -from .models import (PurchaseOrder, PurchaseOrderExtraLine, - PurchaseOrderLineItem, SalesOrder, SalesOrderAllocation, - SalesOrderExtraLine, SalesOrderLineItem, - SalesOrderShipment) - # region general classes class GeneralExtraLineAdmin: @@ -42,7 +38,7 @@ class GeneralExtraLineMeta: class PurchaseOrderLineItemInlineAdmin(admin.StackedInline): """Inline admin class for the PurchaseOrderLineItem model""" - model = PurchaseOrderLineItem + model = models.PurchaseOrderLineItem extra = 0 @@ -103,7 +99,7 @@ class PurchaseOrderResource(InvenTreeResource): class Meta: """Metaclass""" - model = PurchaseOrder + model = models.PurchaseOrder skip_unchanged = True clean_model_instances = True exclude = [ @@ -122,7 +118,7 @@ class PurchaseOrderLineItemResource(InvenTreeResource): class Meta: """Metaclass""" - model = PurchaseOrderLineItem + model = models.PurchaseOrderLineItem skip_unchanged = True report_skipped = False clean_model_instances = True @@ -142,7 +138,7 @@ class PurchaseOrderExtraLineResource(InvenTreeResource): class Meta(GeneralExtraLineMeta): """Metaclass options.""" - model = PurchaseOrderExtraLine + model = models.PurchaseOrderExtraLine class SalesOrderResource(InvenTreeResource): @@ -150,7 +146,7 @@ class SalesOrderResource(InvenTreeResource): class Meta: """Metaclass options""" - model = SalesOrder + model = models.SalesOrder skip_unchanged = True clean_model_instances = True exclude = [ @@ -169,7 +165,7 @@ class SalesOrderLineItemResource(InvenTreeResource): class Meta: """Metaclass options""" - model = SalesOrderLineItem + model = models.SalesOrderLineItem skip_unchanged = True report_skipped = False clean_model_instances = True @@ -199,7 +195,7 @@ class SalesOrderExtraLineResource(InvenTreeResource): class Meta(GeneralExtraLineMeta): """Metaclass options.""" - model = SalesOrderExtraLine + model = models.SalesOrderExtraLine class PurchaseOrderLineItemAdmin(ImportExportModelAdmin): @@ -281,13 +277,41 @@ class SalesOrderAllocationAdmin(ImportExportModelAdmin): autocomplete_fields = ('line', 'shipment', 'item',) -admin.site.register(PurchaseOrder, PurchaseOrderAdmin) -admin.site.register(PurchaseOrderLineItem, PurchaseOrderLineItemAdmin) -admin.site.register(PurchaseOrderExtraLine, PurchaseOrderExtraLineAdmin) +class ReturnOrderAdmin(ImportExportModelAdmin): + """Admin class for the ReturnOrder model""" -admin.site.register(SalesOrder, SalesOrderAdmin) -admin.site.register(SalesOrderLineItem, SalesOrderLineItemAdmin) -admin.site.register(SalesOrderExtraLine, SalesOrderExtraLineAdmin) + exclude = [ + 'reference_int', + ] -admin.site.register(SalesOrderShipment, SalesOrderShipmentAdmin) -admin.site.register(SalesOrderAllocation, SalesOrderAllocationAdmin) + list_display = [ + 'reference', + 'customer', + 'status', + ] + + search_fields = [ + 'reference', + 'customer__name', + 'description', + ] + + autocomplete_fields = [ + 'customer', + ] + + +# Purchase Order models +admin.site.register(models.PurchaseOrder, PurchaseOrderAdmin) +admin.site.register(models.PurchaseOrderLineItem, PurchaseOrderLineItemAdmin) +admin.site.register(models.PurchaseOrderExtraLine, PurchaseOrderExtraLineAdmin) + +# Sales Order models +admin.site.register(models.SalesOrder, SalesOrderAdmin) +admin.site.register(models.SalesOrderLineItem, SalesOrderLineItemAdmin) +admin.site.register(models.SalesOrderExtraLine, SalesOrderExtraLineAdmin) +admin.site.register(models.SalesOrderShipment, SalesOrderShipmentAdmin) +admin.site.register(models.SalesOrderAllocation, SalesOrderAllocationAdmin) + +# Return Order models +admin.site.register(models.ReturnOrder, ReturnOrderAdmin) diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 00e2639aea..dfa4a48622 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -34,8 +34,9 @@ from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeNotesField, InvenTreeURLField, RoundingDecimalField) from InvenTree.helpers import decimal2string, getSetting, notify_responsible from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin -from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus, - StockHistoryCode, StockStatus) +from InvenTree.status_codes import (PurchaseOrderStatus, ReturnOrderStatus, + SalesOrderStatus, StockHistoryCode, + StockStatus) from part import models as PartModels from plugin.events import trigger_event from plugin.models import MetadataMixin @@ -199,7 +200,7 @@ class PurchaseOrder(Order): @classmethod def api_defaults(cls, request): - """Return default values for thsi model when issuing an API OPTIONS request""" + """Return default values for this model when issuing an API OPTIONS request""" defaults = { 'reference': order.validators.generate_next_purchase_order_reference(), @@ -684,13 +685,16 @@ class SalesOrder(Order): on_delete=models.SET_NULL, null=True, limit_choices_to={'is_customer': True}, - related_name='sales_orders', + related_name='return_orders', verbose_name=_('Customer'), help_text=_("Company to which the items are being sold"), ) - status = models.PositiveIntegerField(default=SalesOrderStatus.PENDING, choices=SalesOrderStatus.items(), - verbose_name=_('Status'), help_text=_('Purchase order status')) + status = models.PositiveIntegerField( + default=SalesOrderStatus.PENDING, + choices=SalesOrderStatus.items(), + verbose_name=_('Status'), help_text=_('Purchase order status') + ) @property def status_text(self): @@ -1547,3 +1551,100 @@ class SalesOrderAllocation(models.Model): # (It may have changed if the stock was split) self.item = item self.save() + + +class ReturnOrder(Order): + """A ReturnOrder represents goods returned from a customer, e.g. an RMA or warranty + + Attributes: + customer: Reference to the customer + sales_order: Reference to an existing SalesOrder (optional) + status: The status of the order (refer to status_codes.ReturnOrderStatus) + """ + + @staticmethod + def get_api_url(): + """Return the API URL associated with the ReturnOrder model""" + return reverse('api-return-list') + + @classmethod + def api_defaults(cls, request): + """Return default values for this model when issuing an API OPTIONS request""" + defaults = { + 'reference': order.validators.generate_next_return_order_reference(), + } + + return defaults + + REFERENCE_PATTERN_SETTING = 'RETURNORDER_REFERENCE_PATTERN' + + def __str__(self): + """Render a string representation of this ReturnOrder""" + + 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=_('Return Order reference'), + default=order.validators.generate_next_return_order_reference, + validators=[ + order.validators.validate_return_order_reference, + ] + ) + + customer = models.ForeignKey( + Company, + on_delete=models.SET_NULL, + null=True, + limit_choices_to={'is_customer': True}, + related_name='sales_orders', + verbose_name=_('Customer'), + help_text=_("Company from which items are being returned"), + ) + + status = models.PositiveIntegerField( + default=ReturnOrderStatus.PENDING, + choices=ReturnOrderStatus.items(), + verbose_name=_('Status'), help_text=_('Return order status') + ) + + customer_reference = models.CharField( + max_length=64, blank=True, + verbose_name=_('Customer Reference '), + help_text=_("Customer order reference code") + ) + + issue_date = models.DateField( + blank=True, null=True, + verbose_name=_('Issue Date'), + help_text=_('Date order was issued') + ) + + complete_date = models.DateField( + blank=True, null=True, + verbose_name=_('Completion Date'), + help_text=_('Date order was completed') + ) + + +class ReturnOrderAttachment(InvenTreeAttachment): + """Model for storing file attachments against a ReturnOrder object""" + + @staticmethod + def get_api_url(): + """Return the API URL associated with the ReturnOrderAttachment class""" + + return reverse('api-return-attachment-list') + + def getSubdir(self): + """Return the directory path where ReturnOrderAttachment files are located""" + return os.path.join('return_files', str(self.order.id)) + + order = models.ForeignKey( + ReturnOrder, + on_delete=models.CASCADE, + related_name='attachments', + ) diff --git a/InvenTree/order/validators.py b/InvenTree/order/validators.py index 3ca3a58940..ee9c832e05 100644 --- a/InvenTree/order/validators.py +++ b/InvenTree/order/validators.py @@ -17,6 +17,14 @@ def generate_next_purchase_order_reference(): return PurchaseOrder.generate_reference() +def generate_next_return_order_reference(): + """Generate the next available ReturnOrder reference""" + + from order.models import ReturnOrder + + return ReturnOrder.generate_reference() + + def validate_sales_order_reference_pattern(pattern): """Validate the SalesOrder reference 'pattern' setting""" @@ -33,6 +41,14 @@ def validate_purchase_order_reference_pattern(pattern): PurchaseOrder.validate_reference_pattern(pattern) +def validate_return_order_reference_pattern(pattern): + """Validate the ReturnOrder reference 'pattern' setting""" + + from order.models import ReturnOrder + + ReturnOrder.validate_reference_pattern(pattern) + + def validate_sales_order_reference(value): """Validate that the SalesOrder reference field matches the required pattern""" @@ -47,3 +63,11 @@ def validate_purchase_order_reference(value): from order.models import PurchaseOrder PurchaseOrder.validate_reference_field(value) + + +def validate_return_order_reference(value): + """Validate that the ReturnOrder reference field matches the required pattern""" + + from order.models import ReturnOrder + + ReturnOrder.validate_reference_field(value) diff --git a/InvenTree/templates/InvenTree/settings/returns.html b/InvenTree/templates/InvenTree/settings/returns.html new file mode 100644 index 0000000000..fc53910d33 --- /dev/null +++ b/InvenTree/templates/InvenTree/settings/returns.html @@ -0,0 +1,18 @@ +{% extends "panel.html" %} +{% load i18n %} + +{% block label %}return-order{% endblock %} + +{% block heading %} +{% trans "Return Order Settings" %} +{% endblock %} + +{% block content %} + +